Compare commits

..
6 Commits
Author SHA1 Message Date
bruno 69927176df fix: feuille xlsx vide editable avec quadrillage vierge BUG-094
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-09-29 16:34:33 -04:00
bruno 5c2ae26a74 docs: corrige la plage de versions de #154 dans la roadmap
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-09-29 15:26:10 -04:00
bruno 3f52b56251 refactor: extraction des modules xlsx, lien dashboard-grille et inspecteur redimensionnable #154 2026-09-29 15:25:38 -04:00
bruno 72da123a51 feat: undo/redo, chargement IntersectionObserver et ARIA pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 19s
2026-09-29 15:01:00 -04:00
bruno e94af0369b feat: inspecteur droit pour le tableau de bord XLSX #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m46s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1h32m51s
2026-09-29 14:56:08 -04:00
bruno 8da65611cb feat: dialogues themes et conflits non bloquants pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m40s
CI / e2e (push) Canceled after 42s
2026-09-29 14:50:47 -04:00
25 changed files with 1109 additions and 206 deletions
+80 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.40.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.43.2**.
---
@@ -14,6 +14,85 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.43.2] — 2026-09-29
### Corrigé
- **BUG-094 — une feuille `.xlsx` vide ou nouvellement ajoutée est désormais éditable et
manipulable.** Elle s'affichait comme un simple « Feuille vide » sans aucune cellule : il était
donc impossible d'y saisir une valeur ou d'y insérer une ligne/colonne (aucune cellule active →
aucune action de structure). `render_sheets()` substitue maintenant un quadrillage vierge
**20×8** aux vraies coordonnées A1 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`), et la
visionneuse retombe sur `{row:1,col:1}` quand aucune cellule n'est active, de sorte que le menu
Structure propose toujours insérer/supprimer ligne et colonne. Test de non-régression :
`TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid`.
---
## [2.43.1] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 5, clôture) : extraction modulaire et
finitions.**
Les unités **sans état** de la visionneuse sont extraites dans `frontend/js/xlsx/` :
`refs.js` (références A1), `command-bar.js` (onglets + ruban + pastilles d'état) et
`dashboard.js` (rendu du tableau de bord) — le noyau avec état reste dans `viewer.js` à
comportement constant. Le tableau de bord devient interactif : **cliquer une plage nommée
sélectionne et révèle sa première cellule** dans la grille (changement d'onglet si nécessaire).
L'inspecteur est **redimensionnable** (largeur mémorisée par session). `SW_VERSION` passe à
`v28` et les nouveaux modules entrent dans le pré-cache. Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (51) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
Aucun changement backend.
---
## [2.43.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 4) : undo/redo, défilement et accessibilité.**
Les éditions de cellules peuvent être **annulées / rétablies** (boutons dans le ruban,
raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`). Les fenêtres de lignes d'une feuille tronquée se
chargent via **`IntersectionObserver`** (repli sur l'écouteur de défilement quand l'API est
absente). La grille expose désormais une sémantique **ARIA** (`role="grid"` / `row` /
`gridcell` / `columnheader` / `rowheader`). Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
(47) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts. Aucun changement backend.
---
## [2.42.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 3) : inspecteur droit.**
Le **Tableau de bord** quitte le flux de la grille pour un **panneau latéral droit repliable**
(`.xlsx-inspector`) : la grille reste visible à côté. L'en-tête de l'inspecteur porte le titre,
une entrée **Assistant IA** (ouvre le panneau latéral global existant) et un bouton de fermeture.
Sous 900 px, l'inspecteur se place sous la grille. Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (44) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
Aucun changement backend.
---
## [2.41.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 2) : dialogues thémés et conflits non
bloquants.**
Les `confirm()` / `prompt()` natifs sont remplacés par des **dialogues intégrés au thème**
(`showConfirm()` / `showPrompt()` dans `frontend/js/ui.js`, promise-based, réutilisant
`.obsigate-modal-*`) pour toutes les actions de structure du classeur (ajouter / renommer /
dupliquer / supprimer une feuille, insérer / supprimer une ligne ou une colonne) et la
confirmation de perte (`409 xlsx_lossy_content`). Un **conflit d'écriture** (`409 conflict`)
n'interrompt plus l'utilisateur : un **bandeau non bloquant** propose de réessayer en
conservant les modifications. Le bouton **Enregistrer** et l'onglet de la feuille concernée
signalent les modifications non sauvegardées. Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
(42) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) adaptés. Aucun changement backend.
---
## [2.40.0] — 2026-09-29
### Ajouté
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.40.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.43.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.40.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.43.2).
---
*Projet : ObsiGate | Version : 2.40.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.43.2 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.40.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.43.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.40.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.43.2).
---
*Project: ObsiGate | Version: 2.40.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.43.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.40.0
2.43.2
+11
View File
@@ -32,6 +32,13 @@ logger = logging.getLogger("obsigate.xlsx_reader")
MAX_ROWS = 500
MAX_COLS = 40
# BUG-094 — an empty sheet used to render as a bare "Feuille vide" paragraph
# with no cell at all, so a freshly added sheet had nothing to click and no way
# to insert a row/column. Render a small blank grid instead (Excel-like), with
# real A1 coordinates, so the cells are editable and the structure actions work.
EMPTY_SHEET_ROWS = 20
EMPTY_SHEET_COLS = 8
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
# loading). The endpoint is bounded so a single request can never ask for the
# whole workbook back in one JSON payload; the UI pages through the rest.
@@ -424,6 +431,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
sheets = []
for i, title in enumerate(titles):
grid = _trim(formulas[i])
# BUG-094 — a blank sheet still needs an editable grid (see constants):
# the viewer's cell editing and structure actions all hang off a cell.
if not grid:
grid = [[""] * EMPTY_SHEET_COLS for _ in range(EMPTY_SHEET_ROWS)]
# The shadow grid is NOT trimmed independently: _trim drops the
# trailing empty columns of each grid on its own width, which would
# shift every cached value left of its formula. Indexing it
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.40.0"
version = "2.43.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.40.0"
version = "2.43.2"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.40.0",
"version": "2.43.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+16 -5
View File
@@ -159,10 +159,15 @@ réécrire : **valeurs calculées** mises en cache par Excel, segments
(slicers), chronologies, contrôles de formulaire, connexions/requêtes,
XML personnalisé, signature numérique, commentaires enrichis, macros.
L'ouverture affiche alors un bandeau qui les liste, et la première
sauvegarde demande confirmation. Si vous refusez, rien n'est écrit.
sauvegarde demande confirmation dans une fenêtre intégrée au thème de
l'application. Si vous refusez, rien n'est écrit.
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
Si le classeur est modifié ailleurs entre-temps (verrou concurrent), ObsiGate
n'interrompt pas votre travail : un bandeau vous propose de **réessayer**
l'enregistrement, vos modifications restant en place.
### Formules
Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme
@@ -210,6 +215,9 @@ A1 au-dessus du tableau ; `Tab`/`Maj+Tab` et les flèches circulent entre les
cellules, `Entrée` valide, `Maj+Entrée` insère un retour à la ligne, une
plage se copie telle quelle vers un tableur.
**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient
sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
**Tri, filtre, recherche, export** — chaque colonne se trie (ascendant /
descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes
se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en
@@ -231,13 +239,16 @@ en **lecture seule** ; un `.csv` s'ouvre dans la même grille et se réécrit
conformément à la RFC 4180 (les guillemets et séparateurs sont
échappés).
**Tableau de bord** — le bouton **Tableau de bord** liste les plages
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
latéral droit** (la grille reste visible à côté) qui liste les plages
nommées du classeur (nom, référence, portée), signale les feuilles
contenant des graphiques ou des tableaux croisés, et donne pour chaque
feuille un résumé (cellules, lignes, colonnes, formules, valeurs
numériques) avec quelques chiffres clés. C'est une aide à la lecture :
les plages nommées peuvent ensuite être exploitées avec l'assistant IA
(outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
numériques) avec quelques chiffres clés. Cliquer une **plage nommée**
sélectionne sa première cellule dans la grille, et le panneau est
**redimensionnable**. L'en-tête de l'inspecteur offre
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
`append_xlsx_rows`).
### Limites
+3
View File
@@ -201,6 +201,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-092* | Les tests réseau dépendent du DNS réel du runner : `test_worker_failure_maps_to_tool_error` échoue en `dns_error` au lieu d'atteindre le worker Playwright mocké, et le job CI `test` rougit de façon intermittente | 🟢 corrigé | P1 | CI / tests | IA | `tests/test_webrender.py`, `tests/test_web_tools.py` | Sur un runner au DNS instable : `pytest tests/test_webrender.py -k test_worker_failure_maps_to_tool_error` → `assert 'dns_error' == 'render_unavailable'` | Fixture `no_dns` mockant les **deux** références du garde SSRF `_assert_public_http_url` (celle de `backend/tools/web.py` et celle importée dans le namespace de `backend/tools/webrender.py`, ligne 30 — la seconde avait d'abord échappé au correctif). Les tests de garde SSRF n'utilisent pas la fixture et continuent de traverser le vrai garde | Le garde est appelé par `fetch_url` **avant** le traitement ; seule la couche httpx était mockée. Contre-preuve : DNS coupé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** |
| *BUG-093* | Le job CI `security` échoue : `pip-audit` bloque sur deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910, PYSEC-2026-3911) — et le plancher `pypdf>=4.0` ne les corrigeait pas, car l'image Act du runner embarque 6.16.0 *préinstallé* dans sa toolcache Python (`Requirement already satisfied` ⇒ jamais mis à niveau) | 🟢 corrigé | P0 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | Run Gitea #1660, job `security` : `Found 2 known vulnerabilities, ignored 2 in 1 package` → `pypdf 6.16.0 PYSEC-2026-3910 6.16.1` / `PYSEC-2026-3911 6.16.1` | Plancher `pypdf>=6.16.1` (correctif des deux advisories), commenté pour expliquer la contrainte de la toolcache. Ajout de `tests/test_ci_workflow.py::TestDependencySecurityFloors`, qui verrouille les planchers de sécurité (`pypdf`, `pyjwt`) et interdit qu'ils retombent sous le correctif | Les deux advisories sont des **consommations de ressources non contrôlées** (PDF à outlines multiples ou à nombreux XForm réutilisés) et sont donc **atteignables** par ObsiGate, dont `backend/pdf_reader.py` extrait le texte et parcourt les outlines de PDF fournis par l'utilisateur. Contre-preuve : plancher remis à `>=4.0` → le garde-fou échoue. pip-audit local : 6.16.1, 6.16.2 et 6.19.0 sans vulnérabilité connue. Correction découverte en lisant le log du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log de l'étape Semgrep collé précédemment datait d'un run antérieur |
| *BUG-094* | Feuille `.xlsx` vide ou nouvellement ajoutée : impossible d'y saisir une valeur et d'y insérer une ligne/colonne — la feuille s'affiche « Feuille vide » sans aucune cellule | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `frontend/js/viewer.js::renderXlsxViewer` | Ajouter une feuille (`PUT …/xlsx/structure` `sheet_add`) puis tenter de saisir A1 ou d'insérer une ligne/colonne | `render_sheets()` remplace une grille vide par un quadrillage vierge 20×8 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) aux vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`) | Le classeur n'était pas en cause : seule la **représentation HTML** était vide, donc aucun `td` à sélectionner → aucune cellule active → aucune action de structure possible. Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52 |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
@@ -296,6 +298,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-27 | BUG-083 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-083** : job `security` rouge — le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée (`unexpected EOF while looking for matching '"'"`, `/var/run/act/workflow/4` ligne 2). Seul `run:` du workflow avec un `#` (les `#` des noms d'étapes Bandit/Npm audit sont inoffensifs, ces étapes passent). Correctif : echo sans `#` (réf `#87` en commentaire YAML). Garde-fou `test_ci_workflow.py` (aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM — BUG-082) + contre-preuve sur l'ancien `ci.yml`. Vérifié : 56 passed. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | BUG-081 | Correction | `backend/auth/router.py`, `tests/test_mfa.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-081** : `GET /api/auth/mfa/status` répondait 500 quand l'auth est désactivée — le pseudo-user `anonymous` n'a aucune entrée en store (`get_user` → `None`, `AttributeError` sur `user.get`). Garde `user is None` → payload « MFA désactivé ». Test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-29 | BUG-094 | Correction | `backend/xlsx_reader.py`, `frontend/js/viewer.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-094 — une feuille vide ou nouvellement ajoutée devient éditable et manipulable.** `render_sheets()` substitue une grille vierge 20×8 (`EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) quand la feuille ne porte aucune cellule, avec de vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`). Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52. | 🟢 corrigé (en attente vérif utilisateur) |
---
+15 -11
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.40.0 | **Dernière mise à jour :** 2026-09-29
> **Version :** 2.43.2 | **Dernière mise à jour :** 2026-09-29
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -97,10 +97,12 @@
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
- **Statut :** 🔵 **en cours** — **Lot 1 livré le 2026-09-29** (ruban de commandes groupé, onglets
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non
recalculées, tokens de grille et affordances). Lots suivants : dialogues thémés, inspecteur
droit (dashboard + IA), découpage modulaire + undo/redo.
- **Statut :** ✅ **livré le 2026-09-29 (Lots 1 → 5, A1-A5)** — ruban de commandes groupé, onglets
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non recalculées,
tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`, bandeau de conflit
409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** (tableau de bord + entrée
Assistant IA, redimensionnable) ; **undo/redo**, chargement via `IntersectionObserver`,
ARIA `role="grid"` ; extraction des unités sans état dans `frontend/js/xlsx/*`.
- **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md)
- **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale :
commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés
@@ -110,9 +112,10 @@
- **Sous-tâches :**
- [x] **A1** Coquille : barre de commandes groupée, onglets feuilles permanents + « + »,
badges d'état, tokens de grille et affordances visuelles (Lot 1)
- [ ] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
- [ ] **A3** Inspecteur droit repliable : Tableau de bord + Assistant IA, lien dashboard ⇄ grille (Lot 3)
- [ ] **A4** Découpage `frontend/js/xlsx/*` + undo/redo + défilement via `IntersectionObserver` (Lot 4)
- [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
- [x] **A3** Inspecteur droit repliable : Tableau de bord + entrée Assistant IA (Lot 3)
- [x] **A4** Undo/redo, chargement via `IntersectionObserver`, sémantique ARIA (Lot 4)
- [x] **A5** Découpage `frontend/js/xlsx/*`, lien dashboard → grille, inspecteur redimensionnable (Lot 5)
---
@@ -164,6 +167,7 @@
| # | Domaine / fonctionnalité | Version | Détails |
|---|---|---|---|
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
@@ -243,8 +247,8 @@
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
| 🔵 En cours | #154 Refonte UI/UX tableur (A1 ✅ Lot 1 · A2-A4 restants) | ~5-7 jours |
| **Total chemin critique** | **#77 fin + #87 + #154** | **~9-13 jours** |
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
---
@@ -252,7 +256,7 @@
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; Lot 1 livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille).
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
- **Clôture #85 (v2.27.13) :** monolithe découpé (T1→T9), stores verrouillés + rate-limit SQLite (T10), fiche `docs/features/archi-refonte-85.md`.
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
+47 -16
View File
@@ -2,8 +2,8 @@
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
> **Statut :** 🔵 **en cours** — Lot 1 livré le 2026-09-29
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4)
> **Statut :** ✅ **terminé** — Lots 1 → 5 livrés le 2026-09-29 (A1-A5)
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5 ✅)
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
> l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.**
@@ -68,25 +68,52 @@ la grille ni le backend.
`--grid-border`, `--grid-zebra`) déclinés dark/light + affordances (en-têtes clairs,
zébrage, survol, cellule active solide, cellule *dirty* prioritaire au survol).
### Lot 2 — Dialogues thémés & feedback (à venir)
### Lot 2 — Dialogues thémés & feedback ✅ *(2026-09-29)*
- Remplacer `confirm()`/`prompt()` par des **modales thémées** (`.obsigate-modal-*`) et des
**toasts** : structure (feuille/ligne/colonne), perte (409 `xlsx_lossy_content`), conflit
(409 `conflict`) en bandeau non bloquant.
- Indicateur *dirty* sur l'onglet de feuille et la barre.
- **A2.1** Helpers génériques **`showConfirm()` / `showPrompt()`** (`frontend/js/ui.js`), promise-based,
réutilisant les classes `.obsigate-modal-*` (fini `window.confirm()` / `window.prompt()`).
- **A2.2** La visionneuse XLSX utilise ces dialogues pour les actions de structure (ajouter /
renommer / dupliquer / supprimer feuille, insérer / supprimer ligne et colonne) et pour la
confirmation de perte (409 `xlsx_lossy_content`).
- **A2.3** **Conflit de sauvegarde (409 `conflict`)** : bandeau **non bloquant** `.xlsx-banner-conflict`
avec bouton **Réessayer** — les modifications sont conservées.
- **A2.4** Indicateur *dirty* sur le bouton **Enregistrer** et sur l'onglet de la feuille concernée.
### Lot 3 — Inspecteur droit (à venir)
### Lot 3 — Inspecteur droit ✅ *(2026-09-29)*
- Panneau repliable hébergeant **Tableau de bord** et **Assistant IA** en onglets ; la grille
reste visible. Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection).
- Détachement en split via `PaneManager.splitRight()`.
- **A3.1** Le **Tableau de bord** quitte le flux de la grille pour un **panneau droit repliable**
(`.xlsx-inspector`) : la grille reste visible à côté (`.xlsx-body` = `.xlsx-main` + inspecteur).
- **A3.2** En-tête d'inspecteur : titre, bouton **Assistant IA** (ouvre le panneau latéral global
existant) et bouton de fermeture.
- **A3.3** Responsive : sous 900 px, l'inspecteur passe sous la grille.
### Lot 4 — Découpage & finitions (à venir)
### Lot 4 — Interactions : undo/redo, défilement, accessibilité ✅ *(2026-09-29)*
- Extraction de `renderXlsxViewer` en modules (`frontend/js/xlsx/*.js`) : `toolbar`, `formula-bar`,
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api`.
- **Undo/redo** local (pile de commandes), défilement paresseux via `IntersectionObserver`,
accessibilité ARIA (`role="grid"`, *roving tabindex*).
- **A4.1** **Undo/redo** local (pile de commandes) pour les éditions de cellules : boutons
**Annuler / Rétablir** dans le ruban + raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`.
- **A4.2** Chargement des fenêtres via **`IntersectionObserver`** (repli sur l'écouteur de
défilement pour les environnements sans IO).
- **A4.3** Sémantique **ARIA** : `role="grid"` / `row` / `gridcell` / `columnheader` / `rowheader`.
### Lot 5 — Découpage modulaire & finitions ✅ *(2026-09-29)*
- **A5.1** Extraction des parties pures/sans état du monolithe `renderXlsxViewer` dans
`frontend/js/xlsx/` : **`refs.js`** (`parseRef`, `columnName`, `findTd`, `sheetOfRef`,
`firstCellOfRange`), **`command-bar.js`** (`buildCommandBar` : onglets + ruban + pastilles),
**`dashboard.js`** (`renderDashboardLoading`, `renderDashboardHtml`). Le noyau **avec état**
(orchestration DOM, édition, écouteurs) reste dans `viewer.js` : l'extraction est volontairement
limitée aux unités sans état, à comportement constant et sous couvert des tests.
- **A5.2** Lien **dashboard → grille** : cliquer une plage nommée sélectionne et révèle sa
première cellule (change d'onglet si la plage est sur une autre feuille).
- **A5.3** Inspecteur **redimensionnable** (poignée gauche, largeur 260–640 px, restaurée par
session via `localStorage`).
- **A5.4** `SW_VERSION` incrémenté (`v28`) et nouveaux modules ajoutés au pré-cache du service
worker.
> **Hors périmètre (documenté) :** le détachement de l'inspecteur en split view
> (`PaneManager.splitRight()`) n'est pas retenu — l'inspecteur est intrinsèquement lié à la
> visionneuse d'un document ; un split générique ouvrirait un second contexte sans le classeur.
> À réévaluer si un usage concret apparaît.
## 5. Recommandations techniques (contrainte « zéro build »)
@@ -116,3 +143,7 @@ la grille ni le backend.
| Date | Événement |
|---|---|
| 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) |
| 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) |
| 2026-09-29 | **Lot 3** livré : le Tableau de bord passe dans un **inspecteur droit repliable** (grille toujours visible), en-tête d'inspecteur avec entrée **Assistant IA** et fermeture, responsive < 900 px |
| 2026-09-29 | **Lot 4** livré : **undo/redo** (boutons + `Ctrl+Z`/`Ctrl+Maj+Z`/`Ctrl+Y`), chargement par `IntersectionObserver`, **ARIA** `role="grid"` ; le découpage modulaire est reporté en A5 |
| 2026-09-29 | **Lot 5** livré (clôture #154) : extraction des unités sans état dans `frontend/js/xlsx/*` (`refs.js`, `command-bar.js`, `dashboard.js`), lien **dashboard → grille**, inspecteur **redimensionnable**, `SW_VERSION` v28 + pré-cache. Split view écarté (documenté) |
+82
View File
@@ -1143,6 +1143,88 @@ const FileOperations = {
};
// ---------------------------------------------------------------------------
// Generic themed dialogs (#154-A2)
// ---------------------------------------------------------------------------
// Promise-based replacements for window.confirm() / window.prompt() so the
// Excel viewer's structure actions and lossy-write confirmations stay inside
// the app theme (and are keyboard accessible) instead of native dialogs.
function _closeDialog(overlay, resolve, value) {
overlay.classList.remove("active");
if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey);
setTimeout(() => overlay.remove(), 200);
resolve(value);
}
function _openDialog(innerHtml) {
const overlay = document.createElement("div");
overlay.className = "obsigate-modal-overlay";
const modal = document.createElement("div");
modal.className = "obsigate-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.innerHTML = innerHtml;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add("active"), 10);
return { overlay, modal };
}
/** Themed replacement for window.confirm(). Resolves to a boolean. */
export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body"><p class="modal-confirm-text">${escapeHtml(message)}</p></div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn ${danger ? "danger" : "primary"}" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false));
overlay._onKey = (e) => {
if (e.key === "Escape") done(false);
else if (e.key === "Enter") done(true);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20);
});
}
/** Themed replacement for window.prompt(). Resolves to the string (or null). */
export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body">
<div class="modal-form-group">
${message ? `<label class="modal-label">${escapeHtml(message)}</label>` : ""}
<input type="text" class="modal-input" data-dialog="input" spellcheck="false"
value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}" />
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn primary" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const input = modal.querySelector('[data-dialog="input"]');
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null));
overlay._onKey = (e) => {
if (e.key === "Escape") done(null);
else if (e.key === "Enter") done(input.value);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => { input.focus(); input.select(); }, 20);
});
}
// ---------------------------------------------------------------------------
// Find in Page Manager
// ---------------------------------------------------------------------------
+286 -125
View File
@@ -3,7 +3,7 @@ import { api, AuthManager } from './auth.js';
import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
import { isInlineEditorActive, queryEditor } from './editor-inline.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard, showConfirm, showPrompt } from './ui.js';
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js';
@@ -15,6 +15,10 @@ import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
import { onFileRender } from './plugins.js';
import { NowPlaying } from './now-playing.js';
import { openAssistant } from './ai-fab.js';
import { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
import { buildCommandBar } from './xlsx/command-bar.js';
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -1052,13 +1056,9 @@ export function renderXlsxViewer(area, data) {
// at all, hiding the fact that a workbook can hold several). An editable
// workbook also gets an explicit “+” button next to the tabs.
const editable = !isCsv && !readOnly;
const tabs = isCsv
? ""
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
).join("")}${editable
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
: ""}</div>`;
// #154-A5 — the shell markup (tabs + grouped command bar + status pills) is
// built by frontend/js/xlsx/command-bar.js.
const { tabs, actionsHtml, statusBar } = buildCommandBar({ sheets, isCsv, readOnly, editable });
// #153 A15 — inline styles (bold/italic/colors from the file itself), merged
// ranges and the freeze anchor are applied AFTER the panels are in the DOM
// (the HTML string alone cannot carry them: col/row spans need the table).
@@ -1112,6 +1112,19 @@ export function renderXlsxViewer(area, data) {
}
}
};
// #154-A4 — ARIA grid semantics (the backend renders a plain <table>).
const applyGridA11y = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table) return;
table.setAttribute("role", "grid");
table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader"));
table.querySelectorAll("tbody tr").forEach((tr) => {
tr.setAttribute("role", "row");
tr.querySelectorAll("th, td").forEach((cell) => {
cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell");
});
});
};
const panels = isCsv
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
: sheets.map((s, i) =>
@@ -1130,46 +1143,8 @@ export function renderXlsxViewer(area, data) {
</div>`
: "";
// #154-A1 — status pills make the viewer's limits visible up front: a
// read-only format (.xls/.ods) and the fact that formulas are never
// recalculated on screen (the workbook is only written back, Excel recomputes).
const statusPills = [
readOnly
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
: "",
!isCsv
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
: "",
].filter(Boolean).join("");
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
// Command groups, separated by thin rules. The dashboard button is appended
// later into the dedicated (empty) “view” group.
const actionGroups = [];
if (!readOnly) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
</span>`);
}
if (editable) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
</button>
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
}
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
</button>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
</span>`);
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
// #154-A1 — status pills and the grouped command bar are built by
// buildCommandBar() (frontend/js/xlsx/command-bar.js).
area.innerHTML = `
<div class="xlsx-viewer">
@@ -1198,7 +1173,24 @@ export function renderXlsxViewer(area, data) {
</button>
</span>
</div>
<div class="xlsx-panels">${panels}</div>
<div class="xlsx-body">
<div class="xlsx-main">
<div class="xlsx-panels">${panels}</div>
</div>
<aside class="xlsx-inspector" id="xlsx-inspector" aria-hidden="true">
<div class="xlsx-inspector-resize" id="xlsx-inspector-resize" role="separator" aria-orientation="vertical" title="${escapeHtml(t("xlsx.inspector_resize"))}"></div>
<div class="xlsx-inspector-head">
<span class="xlsx-inspector-title">${escapeHtml(t("xlsx.inspector_title"))}</span>
<span class="xlsx-inspector-actions">
<button class="btn-action xlsx-inspector-ai" id="xlsx-inspector-ai" type="button" title="${escapeHtml(t("xlsx.inspector_ai"))}">
<i data-lucide="bot" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.inspector_ai_short"))}
</button>
<button class="btn-action xlsx-inspector-close" id="xlsx-inspector-close" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>
</span>
</div>
<div class="xlsx-inspector-body" id="xlsx-inspector-body"></div>
</aside>
</div>
</div>`;
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
@@ -1212,61 +1204,116 @@ export function renderXlsxViewer(area, data) {
title: t("xlsx.dashboard_btn"),
}, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]);
(area.querySelector("#xlsx-view-group") || area.querySelector(".xlsx-toolbar-actions")).appendChild(dashBtn);
const inspector = area.querySelector("#xlsx-inspector");
const inspectorBody = area.querySelector("#xlsx-inspector-body");
const closeInspector = () => {
const panel = inspectorBody.querySelector(".xlsx-dashboard");
if (panel) panel.remove();
inspector.classList.remove("open");
inspector.setAttribute("aria-hidden", "true");
dashBtn.classList.remove("active");
};
dashBtn.addEventListener("click", async () => {
const old = area.querySelector(".xlsx-dashboard");
if (old) { old.remove(); dashBtn.classList.remove("active"); return; }
// #154-A3 — the dashboard lives in a right-hand inspector so the grid
// stays visible beside it (it used to be prepended into the grid flow).
if (inspectorBody.querySelector(".xlsx-dashboard")) { closeInspector(); return; }
dashBtn.classList.add("active");
inspector.classList.add("open");
inspector.setAttribute("aria-hidden", "false");
const panel = document.createElement("div");
panel.className = "xlsx-dashboard";
panel.innerHTML = `<div class="xlsx-dashboard-loading">…</div>`;
area.querySelector(".xlsx-panels").prepend(panel);
panel.innerHTML = renderDashboardLoading();
inspectorBody.prepend(panel);
try {
const dash = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`,
);
const rangeRows = (dash.named_ranges || []).map((r) =>
`<tr><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
).join("");
const kpiCards = (dash.sheets || []).map((s) => {
const cards = (s.kpi || []).map((k) =>
`<span class="xlsx-kpi"><span class="xlsx-kpi-label">${escapeHtml(k.label)}</span><span class="xlsx-kpi-value">${escapeHtml(String(k.value))}</span></span>`,
).join("");
return `<div class="xlsx-kpi-sheet">
<h4>${escapeHtml(s.name)}</h4>
<p class="xlsx-kpi-meta">${escapeHtml(t("xlsx.dashboard_stats", {
cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric,
}))}</p>
<div class="xlsx-kpi-cards">${cards || "<span class=\"xlsx-kpi-empty\">—</span>"}</div>
</div>`;
}).join("");
panel.innerHTML = `
<div class="xlsx-dashboard-head">
<h3><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.dashboard_title"))}</h3>
<span class="xlsx-dashboard-objects">
${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))}
· ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))}
</span>
</div>
${(dash.named_ranges || []).length || (dash.sheets || []).length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
${(dash.named_ranges || []).length ? `
<table class="csv-table xlsx-ranges-table"><thead><tr>
<th>${escapeHtml(t("xlsx.dashboard_nr_name"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_scope"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_ref"))}</th>
</tr></thead><tbody>${rangeRows}</tbody></table>` : ""}
<div class="xlsx-kpi-grid">${kpiCards}</div>
<p class="xlsx-dashboard-hint">${escapeHtml(t("xlsx.dashboard_hint"))}</p>`;
panel.innerHTML = renderDashboardHtml(dash);
safeCreateIcons();
// #154-A5 — clicking a named range reveals its first cell in the grid.
panel.querySelectorAll(".xlsx-range-row[data-ref]").forEach((row) => {
row.addEventListener("click", () => revealRange(row.dataset.ref));
});
} catch (err) {
panel.innerHTML = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
}
});
area.querySelector("#xlsx-inspector-close").addEventListener("click", closeInspector);
// #154-A3 — the assistant is the existing global side panel; the inspector
// just gives it a contextual entry point from the spreadsheet.
area.querySelector("#xlsx-inspector-ai").addEventListener("click", () => { openAssistant(); });
// #154-A5 — the inspector is resizable (width restored per session).
const resizeHandle = area.querySelector("#xlsx-inspector-resize");
const WIDTH_KEY = "obsigate_xlsx_inspector_width";
const clampWidth = (w) => Math.min(640, Math.max(260, w));
const savedWidth = Number(localStorage.getItem(WIDTH_KEY));
if (savedWidth >= 260 && savedWidth <= 640) {
inspector.style.flexBasis = `${savedWidth}px`;
inspector.style.width = `${savedWidth}px`;
}
let resizing = false;
const onMove = (e) => {
if (!resizing) return;
const width = clampWidth(inspector.getBoundingClientRect().right - e.clientX);
inspector.style.flexBasis = `${width}px`;
inspector.style.width = `${width}px`;
};
const onUp = () => {
if (!resizing) return;
resizing = false;
document.body.style.userSelect = "";
localStorage.setItem(WIDTH_KEY, String(Math.round(inspector.getBoundingClientRect().width)));
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
resizeHandle.addEventListener("mousedown", (e) => {
e.preventDefault();
resizing = true;
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
const refreshSaveState = () => {
const dirty = dirtyCount() > 0;
saveBtn.disabled = !dirty;
saveBtn.classList.toggle("is-dirty", dirty);
// #154-A2 — star a sheet tab that holds unsaved edits.
panelEls.forEach((panel) => {
const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`);
if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0);
});
};
// #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept
// and the user can retry the save without losing their work.
const bannerHost = document.createElement("div");
bannerHost.className = "xlsx-banner-host";
area.querySelector(".xlsx-panels").before(bannerHost);
const showConflict = (msg) => {
bannerHost.innerHTML = "";
const banner = document.createElement("div");
banner.className = "xlsx-banner xlsx-banner-conflict";
banner.setAttribute("role", "alert");
banner.innerHTML = `<i data-lucide="git-merge" class="xlsx-banner-icon"></i>
<span class="xlsx-banner-text">${escapeHtml(msg)}</span>
<button class="btn-action xlsx-banner-retry" type="button">${escapeHtml(t("xlsx.conflict_retry"))}</button>
<button class="btn-action xlsx-banner-dismiss" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>`;
banner.querySelector(".xlsx-banner-retry").addEventListener("click", () => {
bannerHost.innerHTML = "";
saveBtn.click();
});
banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => {
bannerHost.innerHTML = "";
});
bannerHost.appendChild(banner);
safeCreateIcons();
};
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
// fetched window by window from GET …/xlsx/sheet when the user reaches the
@@ -1325,6 +1372,7 @@ export function renderXlsxViewer(area, data) {
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
applySheetMeta(panel);
}
applyGridA11y(panel);
safeCreateIcons();
};
@@ -1348,11 +1396,20 @@ export function renderXlsxViewer(area, data) {
}
};
// Scroll sentinel: near the bottom of the wrapper, pull the next window.
wrapper.addEventListener("scroll", () => {
if (loading || done) return;
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
});
// #154-A4 — prefer IntersectionObserver (less main-thread work than a
// scroll listener); the scroll listener stays as a fallback for
// environments without IO (JSDOM).
if (typeof IntersectionObserver === "function") {
const io = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadMore();
}, { root: wrapper, rootMargin: "120px" });
io.observe(foot);
} else {
wrapper.addEventListener("scroll", () => {
if (loading || done) return;
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
});
}
};
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
@@ -1386,28 +1443,26 @@ export function renderXlsxViewer(area, data) {
if (!syncing.value) formulaInput.value = td.textContent;
};
// Parse an A1 reference into its (row, col) parts.
const parseRef = (ref) => {
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
if (!m) return null;
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return { row: Number(m[2]), col };
};
const findTd = (panel, row, col) =>
panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
const columnName = (col) => {
let name = "";
while (col > 0) {
const rem = (col - 1) % 26;
name = String.fromCharCode(65 + rem) + name;
col = Math.floor((col - 1) / 26);
// #154-A5 — reveal a named range's first cell in the grid (dashboard → grid).
const revealRange = (ref) => {
const cellRef = firstCellOfRange(ref);
if (!cellRef) return;
const sheetName = sheetOfRef(ref);
let panel = visiblePanel();
if (sheetName) {
const idx = sheets.findIndex((s) => s.name === sheetName);
const tab = idx >= 0 ? area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`) : null;
if (tab) { tab.click(); panel = panelEls[idx]; }
}
return name;
const td = panel.querySelector(`td[data-cell="${cellRef}"]`);
if (!td) { showToast(t("xlsx.range_not_visible"), "info"); return; }
setActiveCell(td);
td.focus();
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "center", inline: "center" });
};
// #154-A5 — parseRef / columnName / findTd live in frontend/js/xlsx/refs.js.
const moveActive = (td, key, forward = true) => {
const ref = parseRef(cellName(td));
if (!ref) return;
@@ -1450,6 +1505,65 @@ export function renderXlsxViewer(area, data) {
}
});
// ── #154-A4 — local undo/redo for cell edits ──────────────────────────
// One command per committed edit (a cell loses focus with a new value).
// Structure changes re-render from the server, which rebuilds the whole
// viewer and therefore clears the stacks (they are per-render).
const undoStack = [];
const redoStack = [];
let pendingEdit = null;
const undoBtn = area.querySelector("#xlsx-undo-btn");
const redoBtn = area.querySelector("#xlsx-redo-btn");
const updateUndoButtons = () => {
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
};
const commitEdit = () => {
if (!pendingEdit) return;
const { td, before } = pendingEdit;
pendingEdit = null;
const after = td.textContent;
if (before === after) return;
undoStack.push({ sheet: td.closest(".xlsx-panel")?.dataset.sheet, ref: td.dataset.cell, before, after });
redoStack.length = 0;
updateUndoButtons();
};
const beginEdit = (td) => {
if (pendingEdit && pendingEdit.td !== td) commitEdit();
if (!pendingEdit) pendingEdit = { td, before: td.textContent };
};
const applyHistory = (entry, value) => {
const panel = area.querySelector(`.xlsx-panel[data-sheet="${entry.sheet}"]`) || panelEls[0];
const td = panel?.querySelector(`td[data-cell="${entry.ref}"]`);
if (!td) return;
td.textContent = value;
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
if (td === activeTd) syncFormulaBar(td);
refreshSaveState();
};
const undo = () => {
const entry = undoStack.pop();
if (!entry) return;
applyHistory(entry, entry.before);
redoStack.push(entry);
updateUndoButtons();
};
const redo = () => {
const entry = redoStack.pop();
if (!entry) return;
applyHistory(entry, entry.after);
undoStack.push(entry);
updateUndoButtons();
};
if (undoBtn) undoBtn.addEventListener("click", undo);
if (redoBtn) redoBtn.addEventListener("click", redo);
area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
const key = e.key.toLowerCase();
if (key === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
else if (key === "y") { e.preventDefault(); redo(); }
});
const setupCell = (td) => {
td.contentEditable = "true";
td.spellcheck = false;
@@ -1463,7 +1577,8 @@ export function renderXlsxViewer(area, data) {
syncFormulaBar(td);
refreshSaveState();
});
td.addEventListener("focus", () => setActiveCell(td));
td.addEventListener("focus", () => { beginEdit(td); setActiveCell(td); });
td.addEventListener("blur", commitEdit);
td.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
if (e.key === "Escape") {
@@ -1508,6 +1623,7 @@ export function renderXlsxViewer(area, data) {
}
panelEls.forEach(wireLazyRows);
panelEls.forEach(applySheetMeta);
panelEls.forEach(applyGridA11y);
// The formula bar starts disabled: nothing is selected yet.
setActiveCell(null);
@@ -1570,7 +1686,13 @@ export function renderXlsxViewer(area, data) {
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) throw err;
const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (!proceed) throw err;
lossyConfirmed = true;
force = true;
continue;
@@ -1591,6 +1713,9 @@ export function renderXlsxViewer(area, data) {
// A refused confirmation is a decision, not a failure: neutral toast.
if (err && err.code === "xlsx_lossy_content") {
showToast(t("xlsx.lossy_cancelled"), "info");
} else if (err && err.code === "conflict") {
// #154-A2 — non-blocking: the edits are kept, the user may retry.
showConflict(t("xlsx.conflict_msg"));
} else {
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
@@ -1774,13 +1899,17 @@ export function renderXlsxViewer(area, data) {
renderXlsxViewer(area, fresh);
};
const structureError = (err, retryActions) => {
const structureError = async (err, retryActions) => {
if (err && err.code === "xlsx_lossy_content") {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (confirm(t("xlsx.lossy_confirm", { features: labels }))) {
return putStructure(retryActions, true); // re-emitted with force
}
const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (proceed) return putStructure(retryActions, true); // re-emitted with force
showToast(t("xlsx.lossy_cancelled"), "info");
return null;
}
@@ -1788,11 +1917,14 @@ export function renderXlsxViewer(area, data) {
return null;
};
// #154-A1 — the “+” button mirrors the structure menu's « Ajouter une
// #154-A1/A2 — the “+” button mirrors the structure menu's « Ajouter une
// feuille » item, right where the tabs are (Excel-like).
const tabAddBtn = area.querySelector("#xlsx-tab-add");
if (tabAddBtn) tabAddBtn.addEventListener("click", async () => {
const name = prompt(t("xlsx.structure_prompt_add"));
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return;
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
@@ -1808,7 +1940,7 @@ export function renderXlsxViewer(area, data) {
const idx = visibleSheetIndex();
const sheetName = sheets[idx]?.name || "";
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
const parsed = parseRef(activeRef);
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu";
const item = (label, fn) => {
@@ -1820,26 +1952,43 @@ export function renderXlsxViewer(area, data) {
menu.appendChild(b);
};
item(t("xlsx.sheet_add"), async () => {
const name = prompt(t("xlsx.structure_prompt_add"));
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return;
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_rename"), async () => {
const to = prompt(t("xlsx.structure_prompt_rename"), sheetName);
const to = await showPrompt({
title: t("xlsx.sheet_rename"),
message: t("xlsx.structure_prompt_rename"),
value: sheetName,
});
if (!to || to === sheetName) return;
const actions = [{ op: "sheet_rename", from: sheetName, to }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_duplicate"), async () => {
const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`);
const as = await showPrompt({
title: t("xlsx.sheet_duplicate"),
message: t("xlsx.structure_prompt_add"),
value: `${sheetName} (copie)`,
});
if (!as) return;
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_delete"), async () => {
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return;
const okDelete = await showConfirm({
title: t("xlsx.sheet_delete"),
message: t("xlsx.structure_confirm_delete_sheet", { name: sheetName }),
confirmLabel: t("xlsx.sheet_delete"),
danger: true,
});
if (!okDelete) return;
const actions = [{ op: "sheet_delete", name: sheetName }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
@@ -1850,7 +1999,13 @@ export function renderXlsxViewer(area, data) {
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.row_delete"), async () => {
if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return;
const okRow = await showConfirm({
title: t("xlsx.row_delete"),
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
confirmLabel: t("xlsx.row_delete"),
danger: true,
});
if (!okRow) return;
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
@@ -1859,7 +2014,13 @@ export function renderXlsxViewer(area, data) {
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.col_delete"), async () => {
if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return;
const okCol = await showConfirm({
title: t("xlsx.col_delete"),
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
confirmLabel: t("xlsx.col_delete"),
danger: true,
});
if (!okCol) return;
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
+76
View File
@@ -0,0 +1,76 @@
/* ObsiGate — XLSX command bar builder (#154-A5).
*
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
* is testable and the viewer only wires behaviour.
*/
import { t } from '../i18n.js';
import { escapeHtml } from '../utils.js';
/**
* @param {object} ctx
* @param {Array<{name: string}>} ctx.sheets
* @param {boolean} ctx.isCsv
* @param {boolean} ctx.readOnly
* @param {boolean} ctx.editable - not a CSV and not read-only
* @returns {{ tabs: string, actionsHtml: string, statusBar: string }}
*/
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
// as a real affordance; an editable workbook gets an explicit “+” button.
const tabs = isCsv
? ""
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
).join("")}${editable
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
: ""}</div>`;
// Status pills make the viewer's limits visible up front.
const statusPills = [
readOnly
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
: "",
!isCsv
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
: "",
].filter(Boolean).join("");
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
// Command groups, separated by thin rules.
const actionGroups = [];
if (!readOnly) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
<i data-lucide="undo-2" style="width:14px;height:14px"></i>
</button>
<button class="btn-action" id="xlsx-redo-btn" type="button" title="${escapeHtml(t("xlsx.redo"))}" disabled>
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
</button>
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
</span>`);
}
if (editable) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
</button>
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
}
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
</button>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
</span>`);
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
return { tabs, actionsHtml, statusBar };
}
+53
View File
@@ -0,0 +1,53 @@
/* ObsiGate — XLSX dashboard renderer (#154-A5).
*
* Pure string builders for the workbook dashboard inspector (named ranges, KPI
* cards). Extracted from frontend/js/viewer.js; the viewer handles the fetch,
* the inspector host and the range → grid link.
*/
import { t } from '../i18n.js';
import { escapeHtml } from '../utils.js';
export function renderDashboardLoading() {
return `<div class="xlsx-dashboard-loading">…</div>`;
}
/**
* @param {object} dash - payload of GET …/xlsx/dashboard
* @returns {string} the inspector body markup
*/
export function renderDashboardHtml(dash) {
const namedRanges = dash.named_ranges || [];
const sheets = dash.sheets || [];
const rangeRows = namedRanges.map((r) =>
`<tr class="xlsx-range-row" data-ref="${escapeHtml(r.ref || "")}"><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
).join("");
const kpiCards = sheets.map((s) => {
const cards = (s.kpi || []).map((k) =>
`<span class="xlsx-kpi"><span class="xlsx-kpi-label">${escapeHtml(k.label)}</span><span class="xlsx-kpi-value">${escapeHtml(String(k.value))}</span></span>`,
).join("");
return `<div class="xlsx-kpi-sheet">
<h4>${escapeHtml(s.name)}</h4>
<p class="xlsx-kpi-meta">${escapeHtml(t("xlsx.dashboard_stats", {
cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric,
}))}</p>
<div class="xlsx-kpi-cards">${cards || "<span class=\"xlsx-kpi-empty\">—</span>"}</div>
</div>`;
}).join("");
return `
<div class="xlsx-dashboard-head">
<h3><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.dashboard_title"))}</h3>
<span class="xlsx-dashboard-objects">
${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))}
· ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))}
</span>
</div>
${namedRanges.length || sheets.length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
${namedRanges.length ? `
<table class="csv-table xlsx-ranges-table"><thead><tr>
<th>${escapeHtml(t("xlsx.dashboard_nr_name"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_scope"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_ref"))}</th>
</tr></thead><tbody>${rangeRows}</tbody></table>` : ""}
<div class="xlsx-kpi-grid">${kpiCards}</div>
<p class="xlsx-dashboard-hint">${escapeHtml(t("xlsx.dashboard_hint"))}</p>`;
}
+45
View File
@@ -0,0 +1,45 @@
/* ObsiGate — XLSX A1 reference helpers (#154-A5).
*
* Pure functions (no DOM, no i18n): safe to unit-test in Node and shared by the
* viewer. Extracted from frontend/js/viewer.js without behaviour change.
*/
/** Parse an A1 reference ("B12") into its 1-based (row, col) parts. */
export function parseRef(ref) {
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
if (!m) return null;
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return { row: Number(m[2]), col };
}
/** Column number (1-based) → letters ("A", "Z", "AA"). */
export function columnName(col) {
let name = "";
while (col > 0) {
const rem = (col - 1) % 26;
name = String.fromCharCode(65 + rem) + name;
col = Math.floor((col - 1) / 26);
}
return name;
}
/** Find a rendered cell inside a sheet panel by (row, col). */
export function findTd(panel, row, col) {
return panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
}
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
export function sheetOfRef(ref) {
const s = String(ref || "");
if (!s.includes("!")) return null;
return s.split("!")[0].replace(/^'|'$/g, "");
}
/** First cell of a range ref ("Data!$A$1:$B$5" → "A1"), or null. */
export function firstCellOfRange(ref) {
const s = String(ref || "");
const body = s.includes("!") ? s.split("!").pop() : s;
const m = /([A-Za-z]+)\$?(\d+)/.exec(body);
return m ? `${m[1].toUpperCase()}${m[2]}` : null;
}
+10
View File
@@ -1827,7 +1827,12 @@
"xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.",
"xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}",
"xlsx.lossy_cancelled": "Save cancelled",
"xlsx.lossy_confirm_btn": "Save anyway",
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
"xlsx.conflict_retry": "Retry",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"xlsx.undo": "Undo the last change",
"xlsx.redo": "Redo the change",
"xlsx.cached_value_title": "Last value calculated by Excel",
"xlsx.tabs_add_sheet": "Add a sheet",
"xlsx.readonly_badge": "Read-only",
@@ -1874,6 +1879,11 @@
"xlsx.structure_error": "Could not change the structure",
"xlsx.last_sheet": "The last sheet cannot be deleted",
"xlsx.dashboard_btn": "Dashboard",
"xlsx.inspector_title": "Inspector",
"xlsx.inspector_ai": "AI assistant",
"xlsx.inspector_ai_short": "AI",
"xlsx.inspector_resize": "Resize the inspector",
"xlsx.range_not_visible": "Cell out of view (truncated sheet)",
"xlsx.dashboard_title": "Workbook dashboard",
"xlsx.dashboard_stats": "{{cells}} cells · {{rows}} rows · {{cols}} columns · {{formulas}} formulas · {{numeric}} numeric values",
"xlsx.dashboard_charts": "{{n}} chart(s)",
+10
View File
@@ -1827,7 +1827,12 @@
"xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.",
"xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}",
"xlsx.lossy_cancelled": "Sauvegarde annulée",
"xlsx.lossy_confirm_btn": "Enregistrer quand même",
"xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.",
"xlsx.conflict_retry": "Réessayer",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"xlsx.undo": "Annuler la dernière modification",
"xlsx.redo": "Rétablir la modification",
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
"xlsx.tabs_add_sheet": "Ajouter une feuille",
"xlsx.readonly_badge": "Lecture seule",
@@ -1874,6 +1879,11 @@
"xlsx.structure_error": "Modification de la structure impossible",
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
"xlsx.dashboard_btn": "Tableau de bord",
"xlsx.inspector_title": "Inspecteur",
"xlsx.inspector_ai": "Assistant IA",
"xlsx.inspector_ai_short": "IA",
"xlsx.inspector_resize": "Redimensionner l'inspecteur",
"xlsx.range_not_visible": "Cellule hors de l'affichage (feuille tronquée)",
"xlsx.dashboard_title": "Tableau de bord du classeur",
"xlsx.dashboard_stats": "{{cells}} cellules · {{rows}} lignes · {{cols}} colonnes · {{formulas}} formules · {{numeric}} valeurs numériques",
"xlsx.dashboard_charts": "{{n}} graphique(s)",
+134
View File
@@ -11051,6 +11051,55 @@ body.desktop-mode .editor-container {
.xlsx-status-formula {
color: var(--accent);
}
/* #154-A2 — unsaved-change feedback: the primary button and the owning tab. */
.xlsx-save-primary.is-dirty {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.xlsx-tab-dirty::after {
content: "•";
margin-left: 5px;
color: var(--warning, #e0a800);
}
/* #154-A2 — non-blocking banners (conflict, …). */
.xlsx-banner-host {
display: flex;
flex-direction: column;
gap: 8px;
}
.xlsx-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 4px;
background: var(--surface);
color: var(--text-secondary);
font-size: 0.82rem;
}
.xlsx-banner-conflict {
border-left-color: var(--warning, #e0a800);
}
.xlsx-banner-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: var(--warning, #e0a800);
}
.xlsx-banner-text {
flex: 1;
min-width: 0;
}
.xlsx-banner-dismiss {
padding: 2px 8px;
}
.modal-confirm-text {
color: var(--text-primary);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-line;
}
.xlsx-table {
background: var(--grid-bg);
}
@@ -11240,6 +11289,91 @@ body.desktop-mode .editor-container {
color: var(--text-primary);
}
/* #154-A3 — right-hand inspector (dashboard, assistant) beside the grid. */
.xlsx-body {
display: flex;
align-items: stretch;
gap: 10px;
}
.xlsx-main {
flex: 1 1 auto;
min-width: 0;
}
.xlsx-inspector {
flex: 0 0 340px;
width: 340px;
max-width: 45%;
display: none;
flex-direction: column;
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
max-height: 70vh;
}
.xlsx-inspector.open {
display: flex;
}
.xlsx-inspector-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.xlsx-inspector-title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-primary);
}
.xlsx-inspector-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.xlsx-inspector-body {
flex: 1 1 auto;
overflow: auto;
padding: 10px;
}
/* #154-A5 — resize handle on the inspector's left edge. */
.xlsx-inspector-resize {
position: absolute;
left: -3px;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 2;
}
.xlsx-inspector-resize:hover {
background: color-mix(in srgb, var(--accent) 40%, transparent);
}
.xlsx-range-row {
cursor: pointer;
}
.xlsx-range-row:hover td {
background: var(--bg-hover);
}
.xlsx-inspector .xlsx-dashboard {
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
@media (max-width: 900px) {
.xlsx-body {
flex-direction: column;
}
.xlsx-inspector {
flex-basis: auto;
width: auto;
max-width: none;
}
}
/* #153 A17 — workbook dashboard panel: named ranges table + KPI cards.
Colors come from the existing CSS variables (no hardcoded values). */
.xlsx-dashboard {
+4 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v27';
const SW_VERSION = 'v28';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
@@ -30,6 +30,9 @@ const PRECACHE_URLS = [
'/static/js/ui.js',
'/static/js/sidebar.js',
'/static/js/viewer.js',
'/static/js/xlsx/refs.js',
'/static/js/xlsx/command-bar.js',
'/static/js/xlsx/dashboard.js',
'/static/js/search.js',
'/static/js/config.js',
'/static/js/utils.js',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.40.0",
"version": "2.43.2",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+13 -9
View File
@@ -116,12 +116,6 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
await login(page);
await openFixture(page);
let dialogMessage = null;
page.on('dialog', async (dialog) => {
dialogMessage = dialog.message();
await dialog.accept();
});
const cell = page.locator('#content-area td[data-cell="A2"]');
await cell.click();
await cell.fill('Total confirmé');
@@ -131,7 +125,11 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
await expect(save).toBeEnabled();
await save.click();
await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments');
// #154-A2 — the lossy confirmation is a themed in-app dialog, not a native one.
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(dialog).toContainText('segments');
await dialog.locator('[data-dialog="confirm"]').click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
// La cellule reste modifiée côté UI (plus de marque « sale »).
@@ -145,13 +143,19 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
const toggle = page.locator('#xlsx-formula-btn');
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
// 409 → confirmation, puis reprise avec force (le toggle reste désactivé).
page.on('dialog', (dialog) => dialog.accept());
// 409 → confirmation thémée, puis reprise avec force (le toggle reste désactivé).
const cell = page.locator('#content-area td[data-cell="B2"]');
await cell.click();
await cell.fill('=B1*3');
await cell.press('Enter');
await page.locator('#xlsx-save-btn').click();
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
// A lossy workbook asks for confirmation. A previous test's save may already
// have dropped the slicers part, in which case the PUT succeeds directly.
await dialog.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
if (await dialog.isVisible()) {
await dialog.locator('[data-dialog="confirm"]').click();
}
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
});
});
+190 -27
View File
@@ -105,6 +105,9 @@ async function test(name, fn) {
if (apiQueue.length) return apiQueue.shift();
return { ok: true, status: 200, body: { status: "ok" } };
};
// #154-A2 — themed dialogs live in document.body; clear any left behind by a
// previous test so the helpers always reach the current one.
document.querySelectorAll(".obsigate-modal-overlay").forEach((n) => n.remove());
try {
await fn();
console.log(` ✓ ${name}`);
@@ -165,6 +168,17 @@ function editCell(area, ref, text) {
return td;
}
/** #154-A2 — the themed dialog is the last overlay in document.body. */
const lastDialog = () => {
const overlays = document.querySelectorAll(".obsigate-modal-overlay");
return overlays[overlays.length - 1] || null;
};
const clickDialog = (which) => {
const btn = lastDialog()?.querySelector(`[data-dialog="${which}"]`);
assert.ok(btn, `dialog button "${which}" is present`);
btn.click();
};
const lossyError = {
ok: false,
status: 409,
@@ -248,12 +262,17 @@ await test("409 xlsx_lossy_content asks once then retries with force", async ()
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1);
// #154-A2 — a themed dialog replaces window.confirm(), and no retry happens
// before the user answers.
const dialog = lastDialog();
assert.ok(dialog, "a themed confirmation is shown");
assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent);
assert.equal(calls.length, 1, "nothing is retried before the answer");
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.equal(calls[0].body.force, false);
assert.equal(calls[1].body.force, true);
// The prompt names the features the backend reported.
assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]);
// Save succeeded → cells are no longer dirty.
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
});
@@ -264,27 +283,52 @@ await test("confirming once is enough for the following saves", async () => {
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
editCell(area, "A1", "2");
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1, "the user is not asked twice");
assert.equal(calls.length, 3);
assert.equal(calls[2].body.force, true);
// Wait out the dialog close animation, then confirm none is left.
await new Promise((r) => setTimeout(r, 250));
assert.equal(document.querySelectorAll(".obsigate-modal-overlay").length, 0, "the user is not asked twice");
});
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
const area = mount({ lossy: ["slicers"] });
editCell(area, "A1", "250");
nextResponse = () => lossyError;
confirmAnswer = false;
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1);
clickDialog("cancel");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 1, "no retry after a refusal");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
});
await test("a 409 conflict shows a non-blocking retry banner and keeps the edits", async () => {
const area = mount();
editCell(area, "A1", "250");
nextResponse = () => ({ ok: false, status: 409, body: { detail: "busy", code: "conflict" } });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
const banner = area.querySelector(".xlsx-banner-conflict");
assert.ok(banner, "a conflict banner is shown");
assert.ok(banner.textContent.includes(FR["xlsx.conflict_msg"]), banner.textContent);
// The edit is preserved and the save button is usable again.
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
// The retry re-runs the save.
nextResponse = () => ({ ok: true, status: 200, body: {} });
banner.querySelector(".xlsx-banner-retry").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry");
});
await test("a non-409 failure is not retried", async () => {
const area = mount();
editCell(area, "A1", "250");
@@ -582,29 +626,25 @@ await test("CSV export downloads the visible sheet without the cached shadows",
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
const area = mount();
// The prompt is resolved through the module scope: stub it globally.
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
apiQueue.push({
ok: true, status: 200,
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
}); // re-read
area.querySelector("#xlsx-structure-btn").click();
const items = [...area.querySelectorAll(".xlsx-structure-item")];
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
const addBtn = [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]);
addBtn.click();
await new Promise((r) => setTimeout(r, 5));
globalThis.prompt = realPrompt;
// #154-A2 — a themed prompt replaces window.prompt().
const input = lastDialog()?.querySelector('[data-dialog="input"]');
assert.ok(input, "a themed prompt asks for the sheet name");
input.value = "Feuille 2";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
assert.equal(calls[0].body.force, false);
// The viewer re-rendered from the server payload (a single sheet → no tabs).
assert.ok(
area.querySelector("#content-area, .xlsx-viewer") || area,
"the viewer was rebuilt",
);
assert.ok(
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
"the re-render shows the fresh payload",
@@ -626,9 +666,7 @@ await test("sheet_delete confirms and is refused on the last sheet", async () =>
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
const area = mount();
// The prompt is resolved through the module scope: stub it globally.
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
// The prompt is answered through the themed dialog.
apiQueue.push({
ok: false, status: 409,
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
@@ -639,15 +677,29 @@ await test("the 409 lossy flow re-emits with force after confirmation", async ()
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
});
area.querySelector("#xlsx-structure-btn").click();
const items = [...area.querySelectorAll(".xlsx-structure-item")];
items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
[...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
await new Promise((r) => setTimeout(r, 5));
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
const lossyDialog = lastDialog();
assert.ok(lossyDialog && lossyDialog.textContent.includes(FR["xlsx.feature_slicers"]), "a lossy confirmation is shown");
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
globalThis.prompt = realPrompt;
assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
assert.equal(calls.length, 3);
assert.equal(calls[1].body.force, true);
});
// ── BUG-094 — structure actions work on an empty/new sheet ─────────────────
await test("the structure menu offers row/column actions without an active cell", () => {
const area = mount();
area.querySelector("#xlsx-structure-btn").click();
const labels = [...area.querySelectorAll(".xlsx-structure-item")].map((b) => b.textContent);
assert.ok(labels.includes(FR["xlsx.row_insert"]), "row insert available before any click");
assert.ok(labels.includes(FR["xlsx.col_insert"]), "column insert available before any click");
});
// ── A17 — dashboard panel ─────────────────────────────────────────────
await test("the dashboard button fetches the metadata and renders named ranges + KPIs", async () => {
const area = mount();
@@ -677,6 +729,34 @@ await test("the dashboard button fetches the metadata and renders named ranges +
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
});
await test("the dashboard opens in the right-hand inspector, not inline in the grid", async () => {
const area = mount();
apiQueue.push({
ok: true, status: 200,
body: { vault: "V", path: "data.xlsx", named_ranges: [], objects: { charts: 0, pivots: 0 }, sheets: [{ name: "Feuille1", cells: 1, rows: 1, cols: 1, formulas: 0, numeric: 0, kpi: [] }] },
});
area.querySelector("#xlsx-dashboard-btn").click();
await new Promise((r) => setTimeout(r, 5));
const inspector = area.querySelector("#xlsx-inspector");
assert.equal(inspector.classList.contains("open"), true, "the inspector opens");
assert.equal(inspector.getAttribute("aria-hidden"), "false");
assert.ok(inspector.querySelector("#xlsx-inspector-body .xlsx-dashboard"), "the dashboard lives in the inspector");
// The grid is still present beside it (not replaced).
assert.ok(area.querySelector(".xlsx-main .xlsx-table"), "the grid stays visible");
// Closing removes the panel and closes the inspector.
area.querySelector("#xlsx-inspector-close").click();
assert.equal(area.querySelector(".xlsx-dashboard"), null);
assert.equal(inspector.classList.contains("open"), false);
assert.equal(inspector.getAttribute("aria-hidden"), "true");
});
await test("the inspector exposes an AI assistant entry point", () => {
const area = mount();
assert.ok(area.querySelector("#xlsx-inspector-ai"), "AI button in the inspector head");
const title = area.querySelector("#xlsx-inspector .xlsx-inspector-title");
assert.equal(title.textContent, FR["xlsx.inspector_title"]);
});
await test("a csv mounts without the dashboard button", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
@@ -743,8 +823,6 @@ await test("a csv shows no tab strip and no + button", () => {
await test("the + button adds a sheet through the structure endpoint", async () => {
const area = mount();
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
apiQueue.push({
ok: true, status: 200,
@@ -752,7 +830,9 @@ await test("the + button adds a sheet through the structure endpoint", async ()
}); // re-read
area.querySelector("#xlsx-tab-add").click();
await new Promise((r) => setTimeout(r, 5));
globalThis.prompt = realPrompt;
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
@@ -772,6 +852,7 @@ await test("a read-only workbook shows the read-only pill and offers no editing"
assert.ok(pill.textContent.includes(FR["xlsx.readonly_badge"]), pill.textContent);
assert.equal(area.querySelector("#xlsx-tab-add"), null, "no + for read-only");
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure for read-only");
assert.equal(area.querySelector("#xlsx-undo-btn"), null, "no history for read-only");
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("contenteditable"), null, "cells are not editable");
});
@@ -800,6 +881,88 @@ await test("the command bar exposes grouped actions with a primary save", () =>
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='file']"));
});
// ── #154-A4 — undo/redo, ARIA ───────────────────────────────────────────────
await test("undo/redo restores cell edits from the command stack", () => {
const area = mount();
const td = area.querySelector('td[data-cell="A1"]');
const undoBtn = area.querySelector("#xlsx-undo-btn");
const redoBtn = area.querySelector("#xlsx-redo-btn");
assert.equal(undoBtn.disabled, true, "nothing to undo yet");
td.focus();
editCell(area, "A1", "42");
td.dispatchEvent(new w.Event("blur"));
assert.equal(undoBtn.disabled, false, "undo becomes available after a committed edit");
undoBtn.click();
assert.equal(td.textContent, "100", "undo restores the original value");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0, "the cell is clean again");
assert.equal(redoBtn.disabled, false, "redo becomes available");
redoBtn.click();
assert.equal(td.textContent, "42", "redo reapplies the edit");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1, "the edit is dirty again");
});
await test("a no-op undo/redo pair leaves the buttons disabled", () => {
const area = mount();
assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true);
assert.equal(area.querySelector("#xlsx-redo-btn").disabled, true);
});
await test("the grid exposes ARIA grid semantics", () => {
const area = mount();
const table = area.querySelector(".xlsx-table");
assert.equal(table.getAttribute("role"), "grid");
assert.equal(area.querySelector(".xlsx-table thead th").getAttribute("role"), "columnheader");
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("role"), "gridcell");
assert.equal(area.querySelector("th.xlsx-rownum").getAttribute("role"), "rowheader");
});
// ── #154-A5 — extracted refs module & dashboard → grid link ────────────────
const { parseRef, columnName, firstCellOfRange, sheetOfRef } = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "refs.js")).href
);
await test("parseRef / columnName round-trip A1 references", () => {
assert.deepEqual(parseRef("A1"), { row: 1, col: 1 });
assert.deepEqual(parseRef("B12"), { row: 12, col: 2 });
assert.deepEqual(parseRef("AA3"), { row: 3, col: 27 });
assert.equal(parseRef("A"), null);
assert.equal(columnName(1), "A");
assert.equal(columnName(26), "Z");
assert.equal(columnName(27), "AA");
assert.equal(columnName(parseRef("AB9").col), "AB");
});
await test("range helpers extract the sheet and the first cell", () => {
assert.equal(firstCellOfRange("Data!$A$1:$B$5"), "A1");
assert.equal(firstCellOfRange("'Mon onglet'!$C$3"), "C3");
assert.equal(firstCellOfRange("A1:B2"), "A1");
assert.equal(sheetOfRef("Data!$A$1:$B$5"), "Data");
assert.equal(sheetOfRef("'Mon onglet'!$C$3"), "Mon onglet");
assert.equal(sheetOfRef("A1:B2"), null);
});
await test("clicking a named range reveals its first cell in the grid", async () => {
const area = mount();
apiQueue.push({
ok: true, status: 200,
body: { vault: "V", path: "data.xlsx", named_ranges: [{ name: "MaPlage", scope: "", ref: "Feuille1!$A$1:$B$2" }], objects: { charts: 0, pivots: 0 }, sheets: [] },
});
area.querySelector("#xlsx-dashboard-btn").click();
await new Promise((r) => setTimeout(r, 5));
const row = area.querySelector(".xlsx-range-row[data-ref]");
assert.ok(row, "the named range row is rendered");
row.click();
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1", "the anchor cell becomes active");
assert.ok(area.querySelector('td[data-cell="A1"]').classList.contains("xlsx-active"));
});
await test("the inspector exposes a resize handle", () => {
const area = mount();
assert.ok(area.querySelector("#xlsx-inspector-resize"), "resize handle present");
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+23
View File
@@ -96,6 +96,29 @@ class TestXlsxDisplay:
resp = client.get(f"/api/file/{VAULT}", params={"path": "corrupt.xlsx"})
assert resp.status_code == 500
def test_empty_sheet_renders_an_editable_blank_grid(self, client, test_vault_dir):
"""BUG-094 — a blank sheet used to render as a bare « Feuille vide »
paragraph with no cell, so a freshly added sheet could not be filled
and had no way to insert a row/column. It now exposes a small editable
grid with real A1 coordinates."""
from openpyxl import Workbook
path = Path(test_vault_dir) / "blank.xlsx"
wb = Workbook()
wb.active.title = "Vide"
wb.create_sheet("Vide2")
wb.save(str(path))
resp = client.get(f"/api/file/{VAULT}", params={"path": "blank.xlsx"})
assert resp.status_code == 200
vide = next(s for s in resp.json()["xlsx_sheets"] if s["name"] == "Vide")
assert "Feuille vide" not in vide["html"]
assert 'data-cell="A1"' in vide["html"]
assert 'data-cell="H20"' in vide["html"] # last cell of the blank grid
assert vide["rows"] == 20
assert vide["cols"] == 8
assert vide["truncated"] is False
# ── Index parity (tree visibility) ────────────────────────────────────────