Compare commits

...
3 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
23 changed files with 441 additions and 142 deletions
+34 -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.43.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.43.2**.
---
@@ -14,6 +14,39 @@ 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é
+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.43.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.43.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.43.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.43.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.43.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.43.2).
---
*Project: ObsiGate | Version: 2.43.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.43.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.43.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.43.0"
version = "2.43.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.43.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.43.0",
"version": "2.43.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+3 -1
View File
@@ -244,7 +244,9 @@ 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. L'en-tête de l'inspecteur offre
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`).
+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) |
---
+12 -12
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.43.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,13 +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** — **Lots 1, 2, 3 & 4 livrés 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 ; dialogues thémés `showConfirm`/`showPrompt`,
bandeau de conflit 409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** pour le
tableau de bord avec entrée Assistant IA ; **undo/redo**, chargement via `IntersectionObserver`,
ARIA `role="grid"`). Reste **A5** : découpage modulaire (`frontend/js/xlsx/*`) et finitions
(lien dashboard → grille, inspecteur redimensionnable, split).
- **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
@@ -116,7 +115,7 @@
- [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)
- [ ] **A5** Découpage `frontend/js/xlsx/*` + lien dashboard → grille, inspecteur redimensionnable, split
- [x] **A5** Découpage `frontend/js/xlsx/*`, lien dashboard → grille, inspecteur redimensionnable (Lot 5)
---
@@ -168,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) |
@@ -247,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** |
---
@@ -256,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.
+21 -8
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** — Lots 1, 2, 3 & 4 livrés le 2026-09-29 (reste A5, découpage modulaire)
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5)
> **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.**
@@ -95,13 +95,25 @@ la grille ni le backend.
défilement pour les environnements sans IO).
- **A4.3** Sémantique **ARIA** : `role="grid"` / `row` / `gridcell` / `columnheader` / `rowheader`.
### Lot 5 — Découpage modulaire & finitions (à venir)
### Lot 5 — Découpage modulaire & finitions ✅ *(2026-09-29)*
- Extraction de `renderXlsxViewer` en modules (`frontend/js/xlsx/*.js`) : `toolbar`, `formula-bar`,
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api` (refactor sans changement de
comportement, sous couvert des tests JSDOM/E2E existants).
- Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection), inspecteur
**redimensionnable**, détachement en split via `PaneManager.splitRight()`.
- **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 »)
@@ -134,3 +146,4 @@ la grille ni le backend.
| 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é) |
+65 -108
View File
@@ -16,6 +16,9 @@ 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
@@ -1053,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).
@@ -1144,54 +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="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>');
// #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">
@@ -1225,6 +1178,7 @@ export function renderXlsxViewer(area, data) {
<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">
@@ -1268,45 +1222,18 @@ export function renderXlsxViewer(area, data) {
inspector.setAttribute("aria-hidden", "false");
const panel = document.createElement("div");
panel.className = "xlsx-dashboard";
panel.innerHTML = `<div class="xlsx-dashboard-loading">…</div>`;
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>`;
}
@@ -1315,6 +1242,38 @@ export function renderXlsxViewer(area, data) {
// #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");
@@ -1484,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;
@@ -1983,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) => {
+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;
}
+2
View File
@@ -1882,6 +1882,8 @@
"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)",
+2
View File
@@ -1882,6 +1882,8 @@
"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)",
+20
View File
@@ -11305,6 +11305,7 @@ body.desktop-mode .editor-container {
max-width: 45%;
display: none;
flex-direction: column;
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
@@ -11336,6 +11337,25 @@ body.desktop-mode .editor-container {
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;
+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.43.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": {
+56
View File
@@ -690,6 +690,16 @@ await test("the 409 lossy flow re-emits with force after confirmation", async ()
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();
@@ -907,6 +917,52 @@ await test("the grid exposes ARIA grid semantics", () => {
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) ────────────────────────────────────────