Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69927176df | ||
|
|
5c2ae26a74 | ||
|
|
3f52b56251 |
+34
-1
@@ -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
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.43.0"
|
||||
version = "2.43.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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,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",
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user