Compare commits

...
8 Commits
Author SHA1 Message Date
bruno 1bacfd69d9 fix: plancher pyjwt >=2.14.0 - CVE-2026-102274 corrige le job security BUG-095 2026-09-29 17:57:35 -04:00
bruno 383ffa6a65 feat: menu contextuel et selection type Excel dans la grille XLSX #155
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m20s
CI / build (push) Successful in 1m41s
CI / e2e (push) Successful in 15m30s
2026-09-29 16:43:39 -04:00
bruno 69927176df fix: feuille xlsx vide editable avec quadrillage vierge BUG-094
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-09-29 16:34:33 -04:00
bruno 5c2ae26a74 docs: corrige la plage de versions de #154 dans la roadmap
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-09-29 15:26:10 -04:00
bruno 3f52b56251 refactor: extraction des modules xlsx, lien dashboard-grille et inspecteur redimensionnable #154 2026-09-29 15:25:38 -04:00
bruno 72da123a51 feat: undo/redo, chargement IntersectionObserver et ARIA pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 19s
2026-09-29 15:01:00 -04:00
bruno e94af0369b feat: inspecteur droit pour le tableau de bord XLSX #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m46s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1h32m51s
2026-09-29 14:56:08 -04:00
bruno 8da65611cb feat: dialogues themes et conflits non bloquants pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m40s
CI / e2e (push) Canceled after 42s
2026-09-29 14:50:47 -04:00
30 changed files with 1801 additions and 241 deletions
+3 -2
View File
@@ -168,8 +168,9 @@ jobs:
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
# s'exécutent jamais. PYSEC-2026-178 (pyjwt) est, lui, corrigé par le
# plancher pyjwt>=2.13.0 de backend/requirements.txt (BUG-091).
# s'exécutent jamais. Les advisories pyjwt (PYSEC-2026-178 puis
# CVE-2026-102274) sont corrigées par le plancher pyjwt>=2.14.0 de
# backend/requirements.txt (BUG-091, BUG-095).
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
# l'extraction de texte et la lecture d'outlines — donc atteignables
# via backend/pdf_reader.py) sont corrigés par le plancher
+113 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.40.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.44.1**.
---
@@ -14,6 +14,118 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.44.1] — 2026-09-29
### Sécurité
- **BUG-095 — le job CI `security` repasse au vert : `pyjwt` monté au-dessus du dernier
correctif.** `pip-audit` bloquait sur **CVE-2026-102274** dans `pyjwt` 2.13.0 (correctif
**2.14.0**) : le plancher introduit par BUG-091 (`>=2.13.0`, qui corrigeait PYSEC-2026-178)
était devenu insuffisant. Le plancher passe à `pyjwt[crypto]>=2.14.0` — volontairement au-dessus
de la version préinstallée dans la toolcache du runner (sinon `pip` répond « already satisfied »
et n'aligne jamais la version). Le garde-fou
`tests/test_ci_workflow.py::TestDependencySecurityFloors` verrouille ce nouveau plancher
(contre-preuve : plancher remis à `2.13.0` → test rouge).
---
## [2.44.0] — 2026-09-29
### Ajouté
- **#155 — Ergonomie du tableur : menu contextuel (clic droit / appui long) et sélection type
Excel.** Un **menu contextuel** thémé (nouveau module `frontend/js/xlsx/context-menu.js`)
s'ouvre au **clic droit** sur une cellule, un numéro de ligne ou un en-tête de colonne, et par
**appui long** sur mobile : insérer une ligne au-dessus/en dessous, une colonne à gauche/à
droite (avec `count` pour une plage), supprimer la/les ligne(s) ou colonne(s) après confirmation,
trier A→Z / Z→A et effacer le contenu. La grille adopte la **sélection d'Excel** : plage par
**glisser**, extension par **`Maj+clic`** et **`Maj+flèches`**, sélection de la ligne/colonne
entière au clic sur son en-tête, **curseur croix** sur les cellules et zone **Nom** affichant la
plage (`A1:B3`). Le tri par simple clic sur l'en-tête laisse place à la sélection (tri déplacé
dans le menu contextuel). `SW_VERSION` passe à `v29`. Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (60, dont 8 nouveaux) et E2E (9) verts.
---
## [2.43.2] — 2026-09-29
### Corrigé
- **BUG-094 — une feuille `.xlsx` vide ou nouvellement ajoutée est désormais éditable et
manipulable.** Elle s'affichait comme un simple « Feuille vide » sans aucune cellule : il était
donc impossible d'y saisir une valeur ou d'y insérer une ligne/colonne (aucune cellule active →
aucune action de structure). `render_sheets()` substitue maintenant un quadrillage vierge
**20×8** aux vraies coordonnées A1 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`), et la
visionneuse retombe sur `{row:1,col:1}` quand aucune cellule n'est active, de sorte que le menu
Structure propose toujours insérer/supprimer ligne et colonne. Test de non-régression :
`TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid`.
---
## [2.43.1] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 5, clôture) : extraction modulaire et
finitions.**
Les unités **sans état** de la visionneuse sont extraites dans `frontend/js/xlsx/` :
`refs.js` (références A1), `command-bar.js` (onglets + ruban + pastilles d'état) et
`dashboard.js` (rendu du tableau de bord) — le noyau avec état reste dans `viewer.js` à
comportement constant. Le tableau de bord devient interactif : **cliquer une plage nommée
sélectionne et révèle sa première cellule** dans la grille (changement d'onglet si nécessaire).
L'inspecteur est **redimensionnable** (largeur mémorisée par session). `SW_VERSION` passe à
`v28` et les nouveaux modules entrent dans le pré-cache. Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (51) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
Aucun changement backend.
---
## [2.43.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 4) : undo/redo, défilement et accessibilité.**
Les éditions de cellules peuvent être **annulées / rétablies** (boutons dans le ruban,
raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`). Les fenêtres de lignes d'une feuille tronquée se
chargent via **`IntersectionObserver`** (repli sur l'écouteur de défilement quand l'API est
absente). La grille expose désormais une sémantique **ARIA** (`role="grid"` / `row` /
`gridcell` / `columnheader` / `rowheader`). Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
(47) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts. Aucun changement backend.
---
## [2.42.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 3) : inspecteur droit.**
Le **Tableau de bord** quitte le flux de la grille pour un **panneau latéral droit repliable**
(`.xlsx-inspector`) : la grille reste visible à côté. L'en-tête de l'inspecteur porte le titre,
une entrée **Assistant IA** (ouvre le panneau latéral global existant) et un bouton de fermeture.
Sous 900 px, l'inspecteur se place sous la grille. Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (44) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
Aucun changement backend.
---
## [2.41.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 2) : dialogues thémés et conflits non
bloquants.**
Les `confirm()` / `prompt()` natifs sont remplacés par des **dialogues intégrés au thème**
(`showConfirm()` / `showPrompt()` dans `frontend/js/ui.js`, promise-based, réutilisant
`.obsigate-modal-*`) pour toutes les actions de structure du classeur (ajouter / renommer /
dupliquer / supprimer une feuille, insérer / supprimer une ligne ou une colonne) et la
confirmation de perte (`409 xlsx_lossy_content`). Un **conflit d'écriture** (`409 conflict`)
n'interrompt plus l'utilisateur : un **bandeau non bloquant** propose de réessayer en
conservant les modifications. Le bouton **Enregistrer** et l'onglet de la feuille concernée
signalent les modifications non sauvegardées. Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
(42) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) adaptés. Aucun changement backend.
---
## [2.40.0] — 2026-09-29
### Ajouté
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.40.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.44.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.40.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.44.1).
---
*Projet : ObsiGate | Version : 2.40.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.44.1 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.40.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.44.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.40.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.44.1).
---
*Project: ObsiGate | Version: 2.40.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.44.1 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.40.0
2.44.1
+4 -3
View File
@@ -26,9 +26,10 @@ webauthn==2.6.0
psutil>=5.9
pywebpush>=2.3.0
mcp==1.28.1
# Plancher de sécurité (BUG-091) : pyjwt est une dépendance transitive (mcp) ;
# 2.12.x est vulnérable (PYSEC-2026-178, fix 2.13.0) et pip-audit bloque sinon.
pyjwt[crypto]>=2.13.0
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
pyjwt[crypto]>=2.14.0
sse-starlette==2.1.3
openpyxl>=3.1
xlrd==2.0.2
+11
View File
@@ -32,6 +32,13 @@ logger = logging.getLogger("obsigate.xlsx_reader")
MAX_ROWS = 500
MAX_COLS = 40
# BUG-094 — an empty sheet used to render as a bare "Feuille vide" paragraph
# with no cell at all, so a freshly added sheet had nothing to click and no way
# to insert a row/column. Render a small blank grid instead (Excel-like), with
# real A1 coordinates, so the cells are editable and the structure actions work.
EMPTY_SHEET_ROWS = 20
EMPTY_SHEET_COLS = 8
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
# loading). The endpoint is bounded so a single request can never ask for the
# whole workbook back in one JSON payload; the UI pages through the rest.
@@ -424,6 +431,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
sheets = []
for i, title in enumerate(titles):
grid = _trim(formulas[i])
# BUG-094 — a blank sheet still needs an editable grid (see constants):
# the viewer's cell editing and structure actions all hang off a cell.
if not grid:
grid = [[""] * EMPTY_SHEET_COLS for _ in range(EMPTY_SHEET_ROWS)]
# The shadow grid is NOT trimmed independently: _trim drops the
# trailing empty columns of each grid on its own width, which would
# shift every cached value left of its formula. Indexing it
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.40.0"
version = "2.44.1"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.40.0"
version = "2.44.1"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.40.0",
"version": "2.44.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+26 -10
View File
@@ -159,10 +159,15 @@ réécrire : **valeurs calculées** mises en cache par Excel, segments
(slicers), chronologies, contrôles de formulaire, connexions/requêtes,
XML personnalisé, signature numérique, commentaires enrichis, macros.
L'ouverture affiche alors un bandeau qui les liste, et la première
sauvegarde demande confirmation. Si vous refusez, rien n'est écrit.
sauvegarde demande confirmation dans une fenêtre intégrée au thème de
l'application. Si vous refusez, rien n'est écrit.
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
Si le classeur est modifié ailleurs entre-temps (verrou concurrent), ObsiGate
n'interrompt pas votre travail : un bandeau vous propose de **réessayer**
l'enregistrement, vos modifications restant en place.
### Formules
Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme
@@ -210,11 +215,19 @@ A1 au-dessus du tableau ; `Tab`/`Maj+Tab` et les flèches circulent entre les
cellules, `Entrée` valide, `Maj+Entrée` insère un retour à la ligne, une
plage se copie telle quelle vers un tableur.
**Tri, filtre, recherche, export** — chaque colonne se trie (ascendant /
descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes
se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en
correspondance, et la feuille s'exporte en CSV. Rien de tout cela ne
modifie le classeur.
**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient
sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
**Sélection et menu contextuel** — cliquer une cellule l'active, **glisser**
ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
droit** (ou un **appui long** sur mobile) ouvre un menu : insérer/supprimer
une ligne ou une colonne, trier A→Z / Z→A, effacer le contenu.
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent,
la recherche (`Ctrl+F` du panneau) va de correspondance en correspondance, et
la feuille s'exporte en CSV. Rien de tout cela ne modifie le classeur.
**Structure** — le menu **Structure** de la barre d'outils ajoute,
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
@@ -231,13 +244,16 @@ en **lecture seule** ; un `.csv` s'ouvre dans la même grille et se réécrit
conformément à la RFC 4180 (les guillemets et séparateurs sont
échappés).
**Tableau de bord** — le bouton **Tableau de bord** liste les plages
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
latéral droit** (la grille reste visible à côté) qui liste les plages
nommées du classeur (nom, référence, portée), signale les feuilles
contenant des graphiques ou des tableaux croisés, et donne pour chaque
feuille un résumé (cellules, lignes, colonnes, formules, valeurs
numériques) avec quelques chiffres clés. C'est une aide à la lecture :
les plages nommées peuvent ensuite être exploitées avec l'assistant IA
(outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
numériques) avec quelques chiffres clés. Cliquer une **plage nommée**
sélectionne sa première cellule dans la grille, et le panneau est
**redimensionnable**. L'en-tête de l'inspecteur offre
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
`append_xlsx_rows`).
### Limites
+5
View File
@@ -201,6 +201,9 @@ 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 |
| *BUG-095* | Le job CI `security` échoue : `pip-audit` bloque sur CVE-2026-102274 dans `pyjwt` 2.13.0 (correctif 2.14.0) — le plancher `>=2.13.0` (BUG-091) est désormais sous le dernier correctif | 🟢 corrigé | P1 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | Run Gitea du push v2.44.0, job `security` : `Found 1 known vulnerability, ignored 2 in 1 package` → `pyjwt 2.13.0 CVE-2026-102274 2.14.0` | Plancher `pyjwt[crypto]>=2.14.0` (au-dessus de la version préinstallée de la toolcache du runner, sinon `pip` répond « already satisfied »). Garde-fou `TestDependencySecurityFloors` : `FLOORS["pyjwt"]` porté à `(2, 14, 0)` — contre-preuve : plancher remis à `2.13.0` → test rouge. Commentaire de la job `security` mis à jour | Le plancher de BUG-091 (2.13.0) corrigeait PYSEC-2026-178 mais est lui-même vulnérable depuis. Même mécanisme que BUG-093 (pypdf) : un plancher de sécurité doit rester au-dessus du dernier correctif. Vérifié : `test_ci_workflow.py` 9 passed ; pip-audit local OK (pyjwt 2.15.1 ≥ 2.14.0) |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
@@ -296,6 +299,8 @@ 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) |
| 2026-09-29 | BUG-095 | Correction CI / sécurité | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Le job `security` repasse au vert** : `pyjwt` 2.13.0 est vulnérable (CVE-2026-102274, correctif 2.14.0) et le plancher de BUG-091 (`>=2.13.0`) était donc sous le dernier correctif. Plancher porté à `pyjwt[crypto]>=2.14.0` (au-dessus de la toolcache du runner, sinon `pip` répond « already satisfied »), garde-fou `TestDependencySecurityFloors` mis à jour (contre-preuve : plancher remis à 2.13.0 → test rouge), commentaire de la job `security` aligné. Vérifié : `test_ci_workflow.py` 9 passed, pip-audit local OK (pyjwt 2.15.1). | 🟢 corrigé (en attente vérif utilisateur) |
---
+36 -11
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.40.0 | **Dernière mise à jour :** 2026-09-29
> **Version :** 2.44.1 | **Dernière mise à jour :** 2026-09-29
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -97,10 +97,12 @@
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
- **Statut :** 🔵 **en cours** — **Lot 1 livré le 2026-09-29** (ruban de commandes groupé, onglets
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non
recalculées, tokens de grille et affordances). Lots suivants : dialogues thémés, inspecteur
droit (dashboard + IA), découpage modulaire + undo/redo.
- **Statut :** ✅ **livré le 2026-09-29 (Lots 1 → 5, A1-A5)** — ruban de commandes groupé, onglets
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non recalculées,
tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`, bandeau de conflit
409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** (tableau de bord + entrée
Assistant IA, redimensionnable) ; **undo/redo**, chargement via `IntersectionObserver`,
ARIA `role="grid"` ; extraction des unités sans état dans `frontend/js/xlsx/*`.
- **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md)
- **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale :
commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés
@@ -110,9 +112,28 @@
- **Sous-tâches :**
- [x] **A1** Coquille : barre de commandes groupée, onglets feuilles permanents + « + »,
badges d'état, tokens de grille et affordances visuelles (Lot 1)
- [ ] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
- [ ] **A3** Inspecteur droit repliable : Tableau de bord + Assistant IA, lien dashboard ⇄ grille (Lot 3)
- [ ] **A4** Découpage `frontend/js/xlsx/*` + undo/redo + défilement via `IntersectionObserver` (Lot 4)
- [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
- [x] **A3** Inspecteur droit repliable : Tableau de bord + entrée Assistant IA (Lot 3)
- [x] **A4** Undo/redo, chargement via `IntersectionObserver`, sémantique ARIA (Lot 4)
- [x] **A5** Découpage `frontend/js/xlsx/*`, lien dashboard → grille, inspecteur redimensionnable (Lot 5)
---
## 🔵 En cours — Ergonomie tableur
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
- **Effort :** 2-4 jours | **Impact :** 🟡
- **Statut :** ✅ **livré** — 2026-09-29 (menu contextuel clic droit + appui long, sélection type Excel)
- **Analyse, conception et critères :** [features/xlsx-context-menu.md](./features/xlsx-context-menu.md)
- **Description :** retours utilisateur après #154 — ajouter un **menu contextuel** (clic droit +
appui long tactile) sur les cellules et en-têtes, et aligner la **sélection/curseur** sur le
comportement d'Excel (plage par glisser, `Maj`, sélection de ligne/colonne par en-tête).
- **Sous-tâches :**
- [x] **A1** Menu contextuel thémé (clic droit + appui long) : insérer/supprimer ligne & colonne,
trier, effacer le contenu
- [x] **A2** Sélection type Excel : plage (glisser / `Maj+clic` / `Maj+flèches`), en-têtes
ligne/colonne, curseur croix, zone Nom affichant la plage
---
@@ -164,6 +185,8 @@
| # | 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) |
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
@@ -243,8 +266,9 @@
| ⚪ 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 |
| ✅ Terminé | #155 Ergonomie tableur — menu contextuel & sélection type Excel | 0 jour restant |
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
---
@@ -252,7 +276,8 @@
- **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/*`).
- **Ajout 2026-09-29 :** #155 livré — menu contextuel (clic droit / appui long) et sélection type Excel sur la grille ([features/xlsx-context-menu.md](./features/xlsx-context-menu.md)) ; au passage **BUG-094** corrigé (feuille vide/nouvelle désormais éditable, quadrillage vierge 20×8).
- **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.
+72
View File
@@ -0,0 +1,72 @@
# #155 — Ergonomie tableur : menu contextuel, sélection type Excel
> **Item de roadmap :** [#155 — Ergonomie tableur (menu contextuel + sélection)](../ROADMAP.md)
> **Origine :** retours utilisateur après #154 (refonte UI/UX tableur)
> **Statut :** ✅ **livré** — 2026-09-29
> **Effort estimé :** 2-4 jours (réalisé en 1 lot)
---
## 1. Demande
1. **Menu contextuel** au clic droit (et **appui long** sur mobile/tactile) dans la grille, sur une
cellule, un numéro de ligne ou un en-tête de colonne.
2. **Comportement du curseur / de la sélection comme Excel** : le curseur en forme de croix
au-dessus des cellules, sélection d'une **plage** (glisser, `Maj+clic`, `Maj+flèches`), clic sur
un en-tête de ligne/colonne pour sélectionner toute la ligne/colonne.
3. (BUG-094, traité séparément) une **feuille vide/nouvelle** doit rester éditable et permettre
d'ajouter lignes/colonnes.
## 2. Conception
### Menu contextuel
- Positionné au **pointeur** (souris) ou au point de contact (tactile), borné au viewport.
- Thème de l'application (classes `.xlsx-context-menu`, réutilise `.btn-action`).
- Contenu selon la cible et le mode :
- **Insérer** : ligne au-dessus / en dessous, colonne à gauche / à droite (sur la plage
sélectionnée, via `PUT …/xlsx/structure`, `count` = taille de la plage).
- **Supprimer** : ligne(s) / colonne(s) — confirmation modale (destructif).
- **Trier** : A→Z / Z→A sur la colonne de gauche de la sélection.
- **Effacer le contenu** : vide les cellules sélectionnées (modification locale, non
sauvegardée tant que l'utilisateur n'enregistre pas).
- Masqué pour les formats en lecture seule (`.xls`/`.ods`) ; sans les actions de structure pour un
`.csv`.
- **Appui long** (tactile) : minuteur ~500 ms annulé au déplacement / relâchement.
### Sélection type Excel
- **Clic** : cellule active + sélection simple ; la barre de formule (zone Nom) affiche la plage
(`A1` ou `A1:B3`).
- **Glisser** : sélection rectangulaire (`mousedown` → `mouseover` sur une autre cellule →
`mouseup`) ; le texte de la cellule n'est plus sélectionné pendant le glisser.
- **`Maj+clic`** et **`Maj+flèches`** : étendent la sélection depuis l'ancre.
- **En-têtes** : clic sur un numéro de ligne / lettre de colonne sélectionne la ligne/colonne
entière. Le tri n'est plus déclenché par simple clic sur l'en-tête (il passe au menu contextuel),
conformément au comportement Excel.
- **Curseur** : `cursor: cell` (croix) sur les cellules, `pointer` sur les en-têtes.
## 3. Fichiers
| Couche | Fichier | Rôle |
|---|---|---|
| Menu | `frontend/js/xlsx/context-menu.js` (nouveau) | Menu contextuel générique thémé (build + position + dismiss) |
| Sélection + câblage | `frontend/js/viewer.js` | État de sélection, drag, `Maj`, en-têtes, ouverture du menu, actions |
| Styles | `frontend/style.css` | `.xlsx-selected`, `.xlsx-context-menu`, curseurs |
| i18n | `frontend/locales/{fr,en}.json` | Libellés du menu |
| Tests | `tests/frontend/xlsx-viewer.test.mjs` | Sélection, menu contextuel, tri via menu |
## 4. Critères d'acceptation
- Clic droit sur une cellule ouvre un menu thémé aux coordonnées du pointeur ; `Échap` ou clic
ailleurs le ferme.
- Insérer/supprimer ligne/colonne via le menu envoie la bonne action `PUT …/xlsx/structure`
(avec `count` pour une plage) puis re-rend.
- Glisser de A1 vers B2 surligne A1:B2 et la zone Nom affiche `A1:B2`.
- `Maj+clic` étend la sélection ; cliquer un en-tête sélectionne la ligne/colonne entière.
- Aucune régression des tests #154 (édition, save, undo/redo, dashboard).
## 5. Historique
| Date | Événement |
|---|---|
| 2026-09-29 | Ouverture #155 (menu contextuel + sélection type Excel) suite aux retours utilisateur |
| 2026-09-29 | **Livré** : menu contextuel thémé (`frontend/js/xlsx/context-menu.js`) ouvert au clic droit et à l'appui long, actions résultat de `PUT …/xlsx/structure` (insérer/supprimer ligne & colonne avec `count`), tri et effacement ; sélection type Excel (glisser, `Maj+clic`, `Maj+flèches`, en-têtes ligne/colonne, curseur croix, zone Nom affichant la plage) |
+47 -16
View File
@@ -2,8 +2,8 @@
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
> **Statut :** 🔵 **en cours** — Lot 1 livré le 2026-09-29
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4)
> **Statut :** ✅ **terminé** — Lots 1 → 5 livrés le 2026-09-29 (A1-A5)
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5 ✅)
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
> l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.**
@@ -68,25 +68,52 @@ la grille ni le backend.
`--grid-border`, `--grid-zebra`) déclinés dark/light + affordances (en-têtes clairs,
zébrage, survol, cellule active solide, cellule *dirty* prioritaire au survol).
### Lot 2 — Dialogues thémés & feedback (à venir)
### Lot 2 — Dialogues thémés & feedback ✅ *(2026-09-29)*
- Remplacer `confirm()`/`prompt()` par des **modales thémées** (`.obsigate-modal-*`) et des
**toasts** : structure (feuille/ligne/colonne), perte (409 `xlsx_lossy_content`), conflit
(409 `conflict`) en bandeau non bloquant.
- Indicateur *dirty* sur l'onglet de feuille et la barre.
- **A2.1** Helpers génériques **`showConfirm()` / `showPrompt()`** (`frontend/js/ui.js`), promise-based,
réutilisant les classes `.obsigate-modal-*` (fini `window.confirm()` / `window.prompt()`).
- **A2.2** La visionneuse XLSX utilise ces dialogues pour les actions de structure (ajouter /
renommer / dupliquer / supprimer feuille, insérer / supprimer ligne et colonne) et pour la
confirmation de perte (409 `xlsx_lossy_content`).
- **A2.3** **Conflit de sauvegarde (409 `conflict`)** : bandeau **non bloquant** `.xlsx-banner-conflict`
avec bouton **Réessayer** — les modifications sont conservées.
- **A2.4** Indicateur *dirty* sur le bouton **Enregistrer** et sur l'onglet de la feuille concernée.
### Lot 3 — Inspecteur droit (à venir)
### Lot 3 — Inspecteur droit ✅ *(2026-09-29)*
- Panneau repliable hébergeant **Tableau de bord** et **Assistant IA** en onglets ; la grille
reste visible. Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection).
- Détachement en split via `PaneManager.splitRight()`.
- **A3.1** Le **Tableau de bord** quitte le flux de la grille pour un **panneau droit repliable**
(`.xlsx-inspector`) : la grille reste visible à côté (`.xlsx-body` = `.xlsx-main` + inspecteur).
- **A3.2** En-tête d'inspecteur : titre, bouton **Assistant IA** (ouvre le panneau latéral global
existant) et bouton de fermeture.
- **A3.3** Responsive : sous 900 px, l'inspecteur passe sous la grille.
### Lot 4 — Découpage & finitions (à venir)
### Lot 4 — Interactions : undo/redo, défilement, accessibilité ✅ *(2026-09-29)*
- Extraction de `renderXlsxViewer` en modules (`frontend/js/xlsx/*.js`) : `toolbar`, `formula-bar`,
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api`.
- **Undo/redo** local (pile de commandes), défilement paresseux via `IntersectionObserver`,
accessibilité ARIA (`role="grid"`, *roving tabindex*).
- **A4.1** **Undo/redo** local (pile de commandes) pour les éditions de cellules : boutons
**Annuler / Rétablir** dans le ruban + raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`.
- **A4.2** Chargement des fenêtres via **`IntersectionObserver`** (repli sur l'écouteur de
défilement pour les environnements sans IO).
- **A4.3** Sémantique **ARIA** : `role="grid"` / `row` / `gridcell` / `columnheader` / `rowheader`.
### Lot 5 — Découpage modulaire & finitions ✅ *(2026-09-29)*
- **A5.1** Extraction des parties pures/sans état du monolithe `renderXlsxViewer` dans
`frontend/js/xlsx/` : **`refs.js`** (`parseRef`, `columnName`, `findTd`, `sheetOfRef`,
`firstCellOfRange`), **`command-bar.js`** (`buildCommandBar` : onglets + ruban + pastilles),
**`dashboard.js`** (`renderDashboardLoading`, `renderDashboardHtml`). Le noyau **avec état**
(orchestration DOM, édition, écouteurs) reste dans `viewer.js` : l'extraction est volontairement
limitée aux unités sans état, à comportement constant et sous couvert des tests.
- **A5.2** Lien **dashboard → grille** : cliquer une plage nommée sélectionne et révèle sa
première cellule (change d'onglet si la plage est sur une autre feuille).
- **A5.3** Inspecteur **redimensionnable** (poignée gauche, largeur 260–640 px, restaurée par
session via `localStorage`).
- **A5.4** `SW_VERSION` incrémenté (`v28`) et nouveaux modules ajoutés au pré-cache du service
worker.
> **Hors périmètre (documenté) :** le détachement de l'inspecteur en split view
> (`PaneManager.splitRight()`) n'est pas retenu — l'inspecteur est intrinsèquement lié à la
> visionneuse d'un document ; un split générique ouvrirait un second contexte sans le classeur.
> À réévaluer si un usage concret apparaît.
## 5. Recommandations techniques (contrainte « zéro build »)
@@ -116,3 +143,7 @@ la grille ni le backend.
| Date | Événement |
|---|---|
| 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) |
| 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) |
| 2026-09-29 | **Lot 3** livré : le Tableau de bord passe dans un **inspecteur droit repliable** (grille toujours visible), en-tête d'inspecteur avec entrée **Assistant IA** et fermeture, responsive < 900 px |
| 2026-09-29 | **Lot 4** livré : **undo/redo** (boutons + `Ctrl+Z`/`Ctrl+Maj+Z`/`Ctrl+Y`), chargement par `IntersectionObserver`, **ARIA** `role="grid"` ; le découpage modulaire est reporté en A5 |
| 2026-09-29 | **Lot 5** livré (clôture #154) : extraction des unités sans état dans `frontend/js/xlsx/*` (`refs.js`, `command-bar.js`, `dashboard.js`), lien **dashboard → grille**, inspecteur **redimensionnable**, `SW_VERSION` v28 + pré-cache. Split view écarté (documenté) |
+82
View File
@@ -1143,6 +1143,88 @@ const FileOperations = {
};
// ---------------------------------------------------------------------------
// Generic themed dialogs (#154-A2)
// ---------------------------------------------------------------------------
// Promise-based replacements for window.confirm() / window.prompt() so the
// Excel viewer's structure actions and lossy-write confirmations stay inside
// the app theme (and are keyboard accessible) instead of native dialogs.
function _closeDialog(overlay, resolve, value) {
overlay.classList.remove("active");
if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey);
setTimeout(() => overlay.remove(), 200);
resolve(value);
}
function _openDialog(innerHtml) {
const overlay = document.createElement("div");
overlay.className = "obsigate-modal-overlay";
const modal = document.createElement("div");
modal.className = "obsigate-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.innerHTML = innerHtml;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add("active"), 10);
return { overlay, modal };
}
/** Themed replacement for window.confirm(). Resolves to a boolean. */
export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body"><p class="modal-confirm-text">${escapeHtml(message)}</p></div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn ${danger ? "danger" : "primary"}" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false));
overlay._onKey = (e) => {
if (e.key === "Escape") done(false);
else if (e.key === "Enter") done(true);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20);
});
}
/** Themed replacement for window.prompt(). Resolves to the string (or null). */
export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body">
<div class="modal-form-group">
${message ? `<label class="modal-label">${escapeHtml(message)}</label>` : ""}
<input type="text" class="modal-input" data-dialog="input" spellcheck="false"
value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}" />
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn primary" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const input = modal.querySelector('[data-dialog="input"]');
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null));
overlay._onKey = (e) => {
if (e.key === "Escape") done(null);
else if (e.key === "Enter") done(input.value);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => { input.focus(); input.select(); }, 20);
});
}
// ---------------------------------------------------------------------------
// Find in Page Manager
// ---------------------------------------------------------------------------
+573 -143
View File
@@ -3,7 +3,7 @@ import { api, AuthManager } from './auth.js';
import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
import { isInlineEditorActive, queryEditor } from './editor-inline.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard, showConfirm, showPrompt } from './ui.js';
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js';
@@ -15,6 +15,11 @@ import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
import { onFileRender } from './plugins.js';
import { NowPlaying } from './now-playing.js';
import { openAssistant } from './ai-fab.js';
import { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
import { buildCommandBar } from './xlsx/command-bar.js';
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js';
import { openContextMenu, closeContextMenu } from './xlsx/context-menu.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -1052,13 +1057,9 @@ export function renderXlsxViewer(area, data) {
// at all, hiding the fact that a workbook can hold several). An editable
// workbook also gets an explicit “+” button next to the tabs.
const editable = !isCsv && !readOnly;
const tabs = isCsv
? ""
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
).join("")}${editable
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
: ""}</div>`;
// #154-A5 — the shell markup (tabs + grouped command bar + status pills) is
// built by frontend/js/xlsx/command-bar.js.
const { tabs, actionsHtml, statusBar } = buildCommandBar({ sheets, isCsv, readOnly, editable });
// #153 A15 — inline styles (bold/italic/colors from the file itself), merged
// ranges and the freeze anchor are applied AFTER the panels are in the DOM
// (the HTML string alone cannot carry them: col/row spans need the table).
@@ -1112,6 +1113,19 @@ export function renderXlsxViewer(area, data) {
}
}
};
// #154-A4 — ARIA grid semantics (the backend renders a plain <table>).
const applyGridA11y = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table) return;
table.setAttribute("role", "grid");
table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader"));
table.querySelectorAll("tbody tr").forEach((tr) => {
tr.setAttribute("role", "row");
tr.querySelectorAll("th, td").forEach((cell) => {
cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell");
});
});
};
const panels = isCsv
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
: sheets.map((s, i) =>
@@ -1130,46 +1144,8 @@ export function renderXlsxViewer(area, data) {
</div>`
: "";
// #154-A1 — status pills make the viewer's limits visible up front: a
// read-only format (.xls/.ods) and the fact that formulas are never
// recalculated on screen (the workbook is only written back, Excel recomputes).
const statusPills = [
readOnly
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
: "",
!isCsv
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
: "",
].filter(Boolean).join("");
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
// Command groups, separated by thin rules. The dashboard button is appended
// later into the dedicated (empty) “view” group.
const actionGroups = [];
if (!readOnly) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
</span>`);
}
if (editable) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
</button>
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
}
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
</button>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
</span>`);
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
// #154-A1 — status pills and the grouped command bar are built by
// buildCommandBar() (frontend/js/xlsx/command-bar.js).
area.innerHTML = `
<div class="xlsx-viewer">
@@ -1198,9 +1174,29 @@ export function renderXlsxViewer(area, data) {
</button>
</span>
</div>
<div class="xlsx-panels">${panels}</div>
<div class="xlsx-body">
<div class="xlsx-main">
<div class="xlsx-panels">${panels}</div>
</div>
<aside class="xlsx-inspector" id="xlsx-inspector" aria-hidden="true">
<div class="xlsx-inspector-resize" id="xlsx-inspector-resize" role="separator" aria-orientation="vertical" title="${escapeHtml(t("xlsx.inspector_resize"))}"></div>
<div class="xlsx-inspector-head">
<span class="xlsx-inspector-title">${escapeHtml(t("xlsx.inspector_title"))}</span>
<span class="xlsx-inspector-actions">
<button class="btn-action xlsx-inspector-ai" id="xlsx-inspector-ai" type="button" title="${escapeHtml(t("xlsx.inspector_ai"))}">
<i data-lucide="bot" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.inspector_ai_short"))}
</button>
<button class="btn-action xlsx-inspector-close" id="xlsx-inspector-close" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>
</span>
</div>
<div class="xlsx-inspector-body" id="xlsx-inspector-body"></div>
</aside>
</div>
</div>`;
// A re-render invalidates any open context menu (it targets the old DOM).
closeContextMenu();
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
// Fetched lazily on first open of the dashboard tab; a workbook without
// anything notable hides the button entirely (no empty panel).
@@ -1212,61 +1208,116 @@ export function renderXlsxViewer(area, data) {
title: t("xlsx.dashboard_btn"),
}, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]);
(area.querySelector("#xlsx-view-group") || area.querySelector(".xlsx-toolbar-actions")).appendChild(dashBtn);
const inspector = area.querySelector("#xlsx-inspector");
const inspectorBody = area.querySelector("#xlsx-inspector-body");
const closeInspector = () => {
const panel = inspectorBody.querySelector(".xlsx-dashboard");
if (panel) panel.remove();
inspector.classList.remove("open");
inspector.setAttribute("aria-hidden", "true");
dashBtn.classList.remove("active");
};
dashBtn.addEventListener("click", async () => {
const old = area.querySelector(".xlsx-dashboard");
if (old) { old.remove(); dashBtn.classList.remove("active"); return; }
// #154-A3 — the dashboard lives in a right-hand inspector so the grid
// stays visible beside it (it used to be prepended into the grid flow).
if (inspectorBody.querySelector(".xlsx-dashboard")) { closeInspector(); return; }
dashBtn.classList.add("active");
inspector.classList.add("open");
inspector.setAttribute("aria-hidden", "false");
const panel = document.createElement("div");
panel.className = "xlsx-dashboard";
panel.innerHTML = `<div class="xlsx-dashboard-loading">…</div>`;
area.querySelector(".xlsx-panels").prepend(panel);
panel.innerHTML = renderDashboardLoading();
inspectorBody.prepend(panel);
try {
const dash = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`,
);
const rangeRows = (dash.named_ranges || []).map((r) =>
`<tr><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
).join("");
const kpiCards = (dash.sheets || []).map((s) => {
const cards = (s.kpi || []).map((k) =>
`<span class="xlsx-kpi"><span class="xlsx-kpi-label">${escapeHtml(k.label)}</span><span class="xlsx-kpi-value">${escapeHtml(String(k.value))}</span></span>`,
).join("");
return `<div class="xlsx-kpi-sheet">
<h4>${escapeHtml(s.name)}</h4>
<p class="xlsx-kpi-meta">${escapeHtml(t("xlsx.dashboard_stats", {
cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric,
}))}</p>
<div class="xlsx-kpi-cards">${cards || "<span class=\"xlsx-kpi-empty\">—</span>"}</div>
</div>`;
}).join("");
panel.innerHTML = `
<div class="xlsx-dashboard-head">
<h3><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.dashboard_title"))}</h3>
<span class="xlsx-dashboard-objects">
${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))}
· ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))}
</span>
</div>
${(dash.named_ranges || []).length || (dash.sheets || []).length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
${(dash.named_ranges || []).length ? `
<table class="csv-table xlsx-ranges-table"><thead><tr>
<th>${escapeHtml(t("xlsx.dashboard_nr_name"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_scope"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_ref"))}</th>
</tr></thead><tbody>${rangeRows}</tbody></table>` : ""}
<div class="xlsx-kpi-grid">${kpiCards}</div>
<p class="xlsx-dashboard-hint">${escapeHtml(t("xlsx.dashboard_hint"))}</p>`;
panel.innerHTML = renderDashboardHtml(dash);
safeCreateIcons();
// #154-A5 — clicking a named range reveals its first cell in the grid.
panel.querySelectorAll(".xlsx-range-row[data-ref]").forEach((row) => {
row.addEventListener("click", () => revealRange(row.dataset.ref));
});
} catch (err) {
panel.innerHTML = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
}
});
area.querySelector("#xlsx-inspector-close").addEventListener("click", closeInspector);
// #154-A3 — the assistant is the existing global side panel; the inspector
// just gives it a contextual entry point from the spreadsheet.
area.querySelector("#xlsx-inspector-ai").addEventListener("click", () => { openAssistant(); });
// #154-A5 — the inspector is resizable (width restored per session).
const resizeHandle = area.querySelector("#xlsx-inspector-resize");
const WIDTH_KEY = "obsigate_xlsx_inspector_width";
const clampWidth = (w) => Math.min(640, Math.max(260, w));
const savedWidth = Number(localStorage.getItem(WIDTH_KEY));
if (savedWidth >= 260 && savedWidth <= 640) {
inspector.style.flexBasis = `${savedWidth}px`;
inspector.style.width = `${savedWidth}px`;
}
let resizing = false;
const onMove = (e) => {
if (!resizing) return;
const width = clampWidth(inspector.getBoundingClientRect().right - e.clientX);
inspector.style.flexBasis = `${width}px`;
inspector.style.width = `${width}px`;
};
const onUp = () => {
if (!resizing) return;
resizing = false;
document.body.style.userSelect = "";
localStorage.setItem(WIDTH_KEY, String(Math.round(inspector.getBoundingClientRect().width)));
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
resizeHandle.addEventListener("mousedown", (e) => {
e.preventDefault();
resizing = true;
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
const refreshSaveState = () => {
const dirty = dirtyCount() > 0;
saveBtn.disabled = !dirty;
saveBtn.classList.toggle("is-dirty", dirty);
// #154-A2 — star a sheet tab that holds unsaved edits.
panelEls.forEach((panel) => {
const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`);
if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0);
});
};
// #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept
// and the user can retry the save without losing their work.
const bannerHost = document.createElement("div");
bannerHost.className = "xlsx-banner-host";
area.querySelector(".xlsx-panels").before(bannerHost);
const showConflict = (msg) => {
bannerHost.innerHTML = "";
const banner = document.createElement("div");
banner.className = "xlsx-banner xlsx-banner-conflict";
banner.setAttribute("role", "alert");
banner.innerHTML = `<i data-lucide="git-merge" class="xlsx-banner-icon"></i>
<span class="xlsx-banner-text">${escapeHtml(msg)}</span>
<button class="btn-action xlsx-banner-retry" type="button">${escapeHtml(t("xlsx.conflict_retry"))}</button>
<button class="btn-action xlsx-banner-dismiss" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>`;
banner.querySelector(".xlsx-banner-retry").addEventListener("click", () => {
bannerHost.innerHTML = "";
saveBtn.click();
});
banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => {
bannerHost.innerHTML = "";
});
bannerHost.appendChild(banner);
safeCreateIcons();
};
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
// fetched window by window from GET …/xlsx/sheet when the user reaches the
@@ -1325,6 +1376,7 @@ export function renderXlsxViewer(area, data) {
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
applySheetMeta(panel);
}
applyGridA11y(panel);
safeCreateIcons();
};
@@ -1348,11 +1400,20 @@ export function renderXlsxViewer(area, data) {
}
};
// Scroll sentinel: near the bottom of the wrapper, pull the next window.
wrapper.addEventListener("scroll", () => {
if (loading || done) return;
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
});
// #154-A4 — prefer IntersectionObserver (less main-thread work than a
// scroll listener); the scroll listener stays as a fallback for
// environments without IO (JSDOM).
if (typeof IntersectionObserver === "function") {
const io = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadMore();
}, { root: wrapper, rootMargin: "120px" });
io.observe(foot);
} else {
wrapper.addEventListener("scroll", () => {
if (loading || done) return;
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
});
}
};
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
@@ -1363,9 +1424,67 @@ export function renderXlsxViewer(area, data) {
const activeCellEl = area.querySelector("#xlsx-active-cell");
const formulaInput = area.querySelector("#xlsx-formula-input");
const syncing = { value: false }; // guard against input-event feedback loops
// #155 — Excel-like range selection: { panel, anchor, focus } in (row, col).
let selection = null;
let suppressSelectionReset = false;
const cellName = (td) => td?.dataset.cell || "";
const clearSelectionClasses = () => {
const host = selection && selection.panel ? selection.panel : area;
host.querySelectorAll("td.xlsx-selected").forEach((c) => c.classList.remove("xlsx-selected"));
};
const selectionBounds = () => {
if (!selection) return null;
const { anchor, focus } = selection;
return {
r1: Math.min(anchor.row, focus.row),
r2: Math.max(anchor.row, focus.row),
c1: Math.min(anchor.col, focus.col),
c2: Math.max(anchor.col, focus.col),
};
};
const rangeLabel = () => {
const b = selectionBounds();
if (!b) return "";
const first = `${columnName(b.c1)}${b.r1}`;
return b.r1 === b.r2 && b.c1 === b.c2 ? first : `${first}:${columnName(b.c2)}${b.r2}`;
};
// Paint the current selection and show its address in the Name Box.
const applySelection = () => {
if (!selection) return;
const { panel } = selection;
const b = selectionBounds();
panel.querySelectorAll("td.xlsx-selected").forEach((c) => c.classList.remove("xlsx-selected"));
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(panel, r, c);
if (td) td.classList.add("xlsx-selected");
}
}
activeCellEl.textContent = rangeLabel();
};
const setSelection = (anchorTd, focusTd = anchorTd) => {
if (!anchorTd) { clearSelectionClasses(); selection = null; return; }
const panel = anchorTd.closest(".xlsx-panel");
const anchor = parseRef(cellName(anchorTd));
const focus = parseRef(cellName(focusTd));
if (!panel || !anchor || !focus) return;
selection = { panel, anchor, focus };
applySelection();
};
const inSelection = (td) => {
const b = selectionBounds();
const p = parseRef(cellName(td));
if (!b || !p || !selection || selection.panel !== td.closest(".xlsx-panel")) return false;
return p.row >= b.r1 && p.row <= b.r2 && p.col >= b.c1 && p.col <= b.c2;
};
const setActiveCell = (td) => {
activeTd = td || null;
if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active");
@@ -1373,12 +1492,14 @@ export function renderXlsxViewer(area, data) {
activeCellEl.textContent = "—";
formulaInput.value = "";
formulaInput.disabled = true;
setSelection(null);
return;
}
td.classList.add("xlsx-active");
activeCellEl.textContent = cellName(td);
formulaInput.disabled = false;
formulaInput.value = td.textContent;
setSelection(td);
};
const syncFormulaBar = (td) => {
@@ -1386,29 +1507,27 @@ 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" });
};
const moveActive = (td, key, forward = true) => {
// #154-A5 — parseRef / columnName / findTd live in frontend/js/xlsx/refs.js.
const moveActive = (td, key, forward = true, extend = false) => {
const ref = parseRef(cellName(td));
if (!ref) return;
let { row, col } = ref;
@@ -1421,6 +1540,17 @@ export function renderXlsxViewer(area, data) {
const panel = td.closest(".xlsx-panel");
const next = findTd(panel, row, col);
if (!next) return; // edge of the rendered window: no wrap
// #155 — Shift+arrow extends the selection from the current anchor.
if (extend && selection && selection.panel === panel) {
suppressSelectionReset = true;
next.focus();
suppressSelectionReset = false;
selection.focus = { row, col };
applySelection();
activeTd = next;
formulaInput.value = next.textContent;
return;
}
td.blur();
next.focus();
setActiveCell(next);
@@ -1450,6 +1580,65 @@ export function renderXlsxViewer(area, data) {
}
});
// ── #154-A4 — local undo/redo for cell edits ──────────────────────────
// One command per committed edit (a cell loses focus with a new value).
// Structure changes re-render from the server, which rebuilds the whole
// viewer and therefore clears the stacks (they are per-render).
const undoStack = [];
const redoStack = [];
let pendingEdit = null;
const undoBtn = area.querySelector("#xlsx-undo-btn");
const redoBtn = area.querySelector("#xlsx-redo-btn");
const updateUndoButtons = () => {
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
};
const commitEdit = () => {
if (!pendingEdit) return;
const { td, before } = pendingEdit;
pendingEdit = null;
const after = td.textContent;
if (before === after) return;
undoStack.push({ sheet: td.closest(".xlsx-panel")?.dataset.sheet, ref: td.dataset.cell, before, after });
redoStack.length = 0;
updateUndoButtons();
};
const beginEdit = (td) => {
if (pendingEdit && pendingEdit.td !== td) commitEdit();
if (!pendingEdit) pendingEdit = { td, before: td.textContent };
};
const applyHistory = (entry, value) => {
const panel = area.querySelector(`.xlsx-panel[data-sheet="${entry.sheet}"]`) || panelEls[0];
const td = panel?.querySelector(`td[data-cell="${entry.ref}"]`);
if (!td) return;
td.textContent = value;
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
if (td === activeTd) syncFormulaBar(td);
refreshSaveState();
};
const undo = () => {
const entry = undoStack.pop();
if (!entry) return;
applyHistory(entry, entry.before);
redoStack.push(entry);
updateUndoButtons();
};
const redo = () => {
const entry = redoStack.pop();
if (!entry) return;
applyHistory(entry, entry.after);
undoStack.push(entry);
updateUndoButtons();
};
if (undoBtn) undoBtn.addEventListener("click", undo);
if (redoBtn) redoBtn.addEventListener("click", redo);
area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
const key = e.key.toLowerCase();
if (key === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
else if (key === "y") { e.preventDefault(); redo(); }
});
const setupCell = (td) => {
td.contentEditable = "true";
td.spellcheck = false;
@@ -1463,7 +1652,19 @@ export function renderXlsxViewer(area, data) {
syncFormulaBar(td);
refreshSaveState();
});
td.addEventListener("focus", () => setActiveCell(td));
td.addEventListener("focus", () => {
beginEdit(td);
// #155 — a range extension keeps the selection; only the bar follows.
if (suppressSelectionReset) {
activeTd = td;
activeCellEl.textContent = cellName(td);
formulaInput.disabled = false;
formulaInput.value = td.textContent;
return;
}
setActiveCell(td);
});
td.addEventListener("blur", commitEdit);
td.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
if (e.key === "Escape") {
@@ -1482,7 +1683,7 @@ export function renderXlsxViewer(area, data) {
const forward = e.key === "Tab"
? !e.shiftKey
: (e.key === "ArrowRight" || e.key === "ArrowDown");
moveActive(td, e.key, forward);
moveActive(td, e.key, forward, e.shiftKey && e.key.startsWith("Arrow"));
}
});
td.addEventListener("paste", (e) => {
@@ -1508,6 +1709,7 @@ export function renderXlsxViewer(area, data) {
}
panelEls.forEach(wireLazyRows);
panelEls.forEach(applySheetMeta);
panelEls.forEach(applyGridA11y);
// The formula bar starts disabled: nothing is selected yet.
setActiveCell(null);
@@ -1570,7 +1772,13 @@ export function renderXlsxViewer(area, data) {
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) throw err;
const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (!proceed) throw err;
lossyConfirmed = true;
force = true;
continue;
@@ -1591,6 +1799,9 @@ export function renderXlsxViewer(area, data) {
// A refused confirmation is a decision, not a failure: neutral toast.
if (err && err.code === "xlsx_lossy_content") {
showToast(t("xlsx.lossy_cancelled"), "info");
} else if (err && err.code === "conflict") {
// #154-A2 — non-blocking: the edits are kept, the user may retry.
showConflict(t("xlsx.conflict_msg"));
} else {
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
@@ -1637,22 +1848,38 @@ export function renderXlsxViewer(area, data) {
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
};
// Header click cycles: asc → desc → back to the sheet order.
const wireHeaderSort = (panel) => {
const thead = panel.querySelector(".xlsx-table thead");
if (!thead || thead.dataset.sortWired) return;
thead.dataset.sortWired = "1";
thead.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => {
// #155 — Excel-like: clicking a column/row header selects the whole
// column/row. Sorting moved to the context menu (and the sort reset button).
const selectColumn = (panel, col) => {
const rows = panel.querySelectorAll("th.xlsx-rownum").length;
if (!rows || !col) return;
selection = { panel, anchor: { row: 1, col }, focus: { row: rows, col } };
applySelection();
const td = findTd(panel, 1, col);
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = td.textContent; }
};
const selectRow = (panel, row) => {
const cols = panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length;
if (!row || !cols) return;
selection = { panel, anchor: { row, col: 1 }, focus: { row, col: cols } };
applySelection();
const td = findTd(panel, row, 1);
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = td.textContent; }
};
const wireHeaderSelection = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table || table.dataset.selectWired) return;
table.dataset.selectWired = "1";
table.querySelectorAll("thead th:not(.xlsx-corner)").forEach((th) => {
th.style.cursor = "pointer";
th.title = t("xlsx.sort_asc");
th.addEventListener("click", () => {
// The corner th is column 0, so the child index IS the column number.
const col = [...th.parentElement.children].indexOf(th);
const current = sortState && sortState.col === col ? sortState.dir : null;
const dir = current === "asc" ? "desc" : "asc";
applySort(panel, col, dir);
th.title = dir === "asc" ? t("xlsx.sort_desc") : t("xlsx.sort_asc");
});
th.title = t("xlsx.select_column");
th.addEventListener("click", () => selectColumn(panel, [...th.parentElement.children].indexOf(th)));
});
table.querySelectorAll("th.xlsx-rownum").forEach((th) => {
th.style.cursor = "pointer";
th.title = t("xlsx.select_row");
th.addEventListener("click", () => selectRow(panel, Number(th.textContent)));
});
};
@@ -1666,7 +1893,7 @@ export function renderXlsxViewer(area, data) {
if (n) sortResetBtn.style.display = "";
};
panelEls.forEach(wireHeaderSort);
panelEls.forEach(wireHeaderSelection);
const sortResetBtn = area.querySelector("#xlsx-sort-reset");
sortResetBtn.addEventListener("click", () => {
@@ -1774,13 +2001,17 @@ export function renderXlsxViewer(area, data) {
renderXlsxViewer(area, fresh);
};
const structureError = (err, retryActions) => {
const structureError = async (err, retryActions) => {
if (err && err.code === "xlsx_lossy_content") {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (confirm(t("xlsx.lossy_confirm", { features: labels }))) {
return putStructure(retryActions, true); // re-emitted with force
}
const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (proceed) return putStructure(retryActions, true); // re-emitted with force
showToast(t("xlsx.lossy_cancelled"), "info");
return null;
}
@@ -1788,11 +2019,14 @@ export function renderXlsxViewer(area, data) {
return null;
};
// #154-A1 — the “+” button mirrors the structure menu's « Ajouter une
// #154-A1/A2 — the “+” button mirrors the structure menu's « Ajouter une
// feuille » item, right where the tabs are (Excel-like).
const tabAddBtn = area.querySelector("#xlsx-tab-add");
if (tabAddBtn) tabAddBtn.addEventListener("click", async () => {
const name = prompt(t("xlsx.structure_prompt_add"));
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return;
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
@@ -1808,7 +2042,7 @@ export function renderXlsxViewer(area, data) {
const idx = visibleSheetIndex();
const sheetName = sheets[idx]?.name || "";
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
const parsed = parseRef(activeRef);
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu";
const item = (label, fn) => {
@@ -1820,26 +2054,43 @@ export function renderXlsxViewer(area, data) {
menu.appendChild(b);
};
item(t("xlsx.sheet_add"), async () => {
const name = prompt(t("xlsx.structure_prompt_add"));
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return;
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_rename"), async () => {
const to = prompt(t("xlsx.structure_prompt_rename"), sheetName);
const to = await showPrompt({
title: t("xlsx.sheet_rename"),
message: t("xlsx.structure_prompt_rename"),
value: sheetName,
});
if (!to || to === sheetName) return;
const actions = [{ op: "sheet_rename", from: sheetName, to }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_duplicate"), async () => {
const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`);
const as = await showPrompt({
title: t("xlsx.sheet_duplicate"),
message: t("xlsx.structure_prompt_add"),
value: `${sheetName} (copie)`,
});
if (!as) return;
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_delete"), async () => {
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return;
const okDelete = await showConfirm({
title: t("xlsx.sheet_delete"),
message: t("xlsx.structure_confirm_delete_sheet", { name: sheetName }),
confirmLabel: t("xlsx.sheet_delete"),
danger: true,
});
if (!okDelete) return;
const actions = [{ op: "sheet_delete", name: sheetName }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
@@ -1850,7 +2101,13 @@ export function renderXlsxViewer(area, data) {
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.row_delete"), async () => {
if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return;
const okRow = await showConfirm({
title: t("xlsx.row_delete"),
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
confirmLabel: t("xlsx.row_delete"),
danger: true,
});
if (!okRow) return;
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
@@ -1859,7 +2116,13 @@ export function renderXlsxViewer(area, data) {
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.col_delete"), async () => {
if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return;
const okCol = await showConfirm({
title: t("xlsx.col_delete"),
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
confirmLabel: t("xlsx.col_delete"),
danger: true,
});
if (!okCol) return;
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
@@ -1894,6 +2157,173 @@ export function renderXlsxViewer(area, data) {
URL.revokeObjectURL(a.href);
});
// ══ #155 — Excel-like selection & context menu ═════════════════════════
const viewerEl = area.querySelector(".xlsx-viewer");
const contextTargetFrom = (target) => {
if (!target || !target.closest) return null;
const panel = target.closest(".xlsx-panel");
if (!panel) return null;
const rownum = target.closest("th.xlsx-rownum");
if (rownum) return { panel, kind: "row", row: Number(rownum.textContent) };
const header = target.closest(".xlsx-table thead th:not(.xlsx-corner)");
if (header) return { panel, kind: "col", col: [...header.parentElement.children].indexOf(header) };
const td = target.closest("td[data-cell]");
if (td) return { panel, kind: "cell", td };
return null;
};
const selectForTarget = (target) => {
if (target.kind === "row") selectRow(target.panel, target.row);
else if (target.kind === "col") selectColumn(target.panel, target.col);
else if (target.kind === "cell"
&& (!selection || selection.panel !== target.panel || !inSelection(target.td))) {
setActiveCell(target.td);
}
};
// One structure write (insert/delete) with the usual lossy/error handling;
// destructive actions ask for confirmation first.
const structureAction = (action, confirmMsg) => {
const run = async () => {
try { await putStructure([action]); } catch (err) { structureError(err, [action]); }
};
if (confirmMsg) {
showConfirm({
title: t("xlsx.structure_title"),
message: confirmMsg,
confirmLabel: t("common.confirm"),
danger: true,
}).then((ok) => { if (ok) run(); });
} else {
run();
}
};
const clearContents = (panel, b) => {
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(panel, r, c);
if (td && td.textContent !== "") { td.textContent = ""; td.classList.add("xlsx-dirty"); }
}
}
refreshSaveState();
};
const buildContextItems = (target) => {
const panel = target.panel;
const sheetName = sheets[Number(panel.dataset.sheet)]?.name || "";
const b = selectionBounds();
if (!b) return [];
const rowCount = b.r2 - b.r1 + 1;
const colCount = b.c2 - b.c1 + 1;
const items = [];
if (editable) {
items.push({ label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ separator: true });
items.push({ label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ separator: true });
}
if (!isCsv) {
items.push({ label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
}
if (editable) {
items.push({ separator: true });
items.push({ label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
}
return items;
};
const openGridMenu = (x, y, target) => {
selectForTarget(target);
openContextMenu(x, y, buildContextItems(target));
};
viewerEl.addEventListener("contextmenu", (e) => {
const target = contextTargetFrom(e.target);
if (!target) return;
e.preventDefault();
openGridMenu(e.clientX, e.clientY, target);
});
// Long-press (touch) — the mobile equivalent of a right-click.
let longPressTimer = null;
const cancelLongPress = () => {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
};
viewerEl.addEventListener("touchstart", (e) => {
const target = contextTargetFrom(e.target);
if (!target || !e.touches || !e.touches[0]) return;
const t0 = e.touches[0];
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
openGridMenu(t0.clientX, t0.clientY, target);
}, 500);
}, { passive: true });
viewerEl.addEventListener("touchmove", cancelLongPress, { passive: true });
viewerEl.addEventListener("touchend", cancelLongPress);
viewerEl.addEventListener("touchcancel", cancelLongPress);
// Drag-to-select a rectangular range + Shift+click extension.
let dragAnchorTd = null;
let dragActive = false;
let dragExtended = false;
viewerEl.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
const td = e.target.closest && e.target.closest("td[data-cell]");
if (!td) return;
if (e.shiftKey && selection && selection.panel === td.closest(".xlsx-panel")) {
e.preventDefault();
selection.focus = parseRef(cellName(td)) || selection.focus;
applySelection();
activeTd = td;
formulaInput.disabled = false;
formulaInput.value = td.textContent;
return;
}
dragAnchorTd = td;
dragActive = true;
dragExtended = false;
});
viewerEl.addEventListener("mouseover", (e) => {
if (!dragActive || !dragAnchorTd) return;
const td = e.target.closest && e.target.closest("td[data-cell]");
if (!td || td === dragAnchorTd) return;
const panel = dragAnchorTd.closest(".xlsx-panel");
if (td.closest(".xlsx-panel") !== panel) return;
const a = parseRef(cellName(dragAnchorTd));
const f = parseRef(cellName(td));
if (!a || !f) return;
if (!dragExtended) {
dragExtended = true;
// Stop editing the anchor cell and drop any in-cell text selection.
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
if (document.getSelection) { const s = document.getSelection(); if (s && s.removeAllRanges) s.removeAllRanges(); }
viewerEl.classList.add("xlsx-selecting");
}
selection = { panel, anchor: a, focus: f };
applySelection();
});
const endDrag = () => {
if (dragActive && dragExtended && dragAnchorTd) {
activeTd = dragAnchorTd;
formulaInput.disabled = false;
formulaInput.value = dragAnchorTd.textContent;
}
dragActive = false;
dragExtended = false;
dragAnchorTd = null;
viewerEl.classList.remove("xlsx-selecting");
};
viewerEl.addEventListener("mouseup", endDrag);
viewerEl.addEventListener("mouseleave", endDrag);
safeCreateIcons();
}
+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 };
}
+85
View File
@@ -0,0 +1,85 @@
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
*
* Builds a small menu anchored at a screen position, closes it on outside
* click / Escape / window blur. Labels and callbacks come from the caller, so
* no i18n lives here.
*/
let _menu = null;
let _openedAt = 0;
let _dismissBound = false;
export function closeContextMenu() {
if (_menu) {
_menu.remove();
_menu = null;
}
}
export function isContextMenuOpen() {
return _menu !== null;
}
function bindDismiss() {
if (_dismissBound) return;
_dismissBound = true;
document.addEventListener("click", (e) => {
if (!_menu) return;
// Ignore the very gesture that opened the menu (touch long-press can emit
// a click right after).
if (Date.now() - _openedAt < 250) return;
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
closeContextMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeContextMenu();
});
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
}
/**
* @param {number} x - viewport X of the pointer
* @param {number} y - viewport Y of the pointer
* @param {Array<{label?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @returns {HTMLElement} the menu element
*/
export function openContextMenu(x, y, items) {
bindDismiss();
closeContextMenu();
const menu = document.createElement("div");
menu.className = "xlsx-context-menu";
menu.setAttribute("role", "menu");
for (const item of items) {
if (item.separator) {
const sep = document.createElement("div");
sep.className = "xlsx-context-sep";
menu.appendChild(sep);
continue;
}
const btn = document.createElement("button");
btn.type = "button";
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
btn.disabled = Boolean(item.disabled);
btn.addEventListener("click", () => {
closeContextMenu();
if (!item.disabled && item.onClick) item.onClick();
});
menu.appendChild(btn);
}
document.body.appendChild(menu);
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
// which simply leaves the requested position).
const rect = menu.getBoundingClientRect();
const vw = (typeof window !== "undefined" && window.innerWidth) || 1024;
const vh = (typeof window !== "undefined" && window.innerHeight) || 768;
const left = Math.max(4, Math.min(x, vw - rect.width - 4));
const top = Math.max(4, Math.min(y, vh - rect.height - 4));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
_menu = menu;
_openedAt = Date.now();
return menu;
}
+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;
}
+21
View File
@@ -1827,7 +1827,12 @@
"xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.",
"xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}",
"xlsx.lossy_cancelled": "Save cancelled",
"xlsx.lossy_confirm_btn": "Save anyway",
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
"xlsx.conflict_retry": "Retry",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"xlsx.undo": "Undo the last change",
"xlsx.redo": "Redo the change",
"xlsx.cached_value_title": "Last value calculated by Excel",
"xlsx.tabs_add_sheet": "Add a sheet",
"xlsx.readonly_badge": "Read-only",
@@ -1870,10 +1875,26 @@
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
"xlsx.select_column": "Select the column",
"xlsx.select_row": "Select the row",
"xlsx.insert_row_above": "Insert a row above",
"xlsx.insert_row_below": "Insert a row below",
"xlsx.insert_col_left": "Insert a column to the left",
"xlsx.insert_col_right": "Insert a column to the right",
"xlsx.delete_rows": "Delete {n} row(s)",
"xlsx.delete_cols": "Delete {n} column(s)",
"xlsx.clear_contents": "Clear contents",
"xlsx.confirm_delete_rows": "Delete {n} row(s)? This changes the file (a backup is created).",
"xlsx.confirm_delete_cols": "Delete {n} column(s)? This changes the file (a backup is created).",
"xlsx.structure_saved": "Structure updated",
"xlsx.structure_error": "Could not change the structure",
"xlsx.last_sheet": "The last sheet cannot be deleted",
"xlsx.dashboard_btn": "Dashboard",
"xlsx.inspector_title": "Inspector",
"xlsx.inspector_ai": "AI assistant",
"xlsx.inspector_ai_short": "AI",
"xlsx.inspector_resize": "Resize the inspector",
"xlsx.range_not_visible": "Cell out of view (truncated sheet)",
"xlsx.dashboard_title": "Workbook dashboard",
"xlsx.dashboard_stats": "{{cells}} cells · {{rows}} rows · {{cols}} columns · {{formulas}} formulas · {{numeric}} numeric values",
"xlsx.dashboard_charts": "{{n}} chart(s)",
+21
View File
@@ -1827,7 +1827,12 @@
"xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.",
"xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}",
"xlsx.lossy_cancelled": "Sauvegarde annulée",
"xlsx.lossy_confirm_btn": "Enregistrer quand même",
"xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.",
"xlsx.conflict_retry": "Réessayer",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"xlsx.undo": "Annuler la dernière modification",
"xlsx.redo": "Rétablir la modification",
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
"xlsx.tabs_add_sheet": "Ajouter une feuille",
"xlsx.readonly_badge": "Lecture seule",
@@ -1870,10 +1875,26 @@
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
"xlsx.select_column": "Sélectionner la colonne",
"xlsx.select_row": "Sélectionner la ligne",
"xlsx.insert_row_above": "Insérer une ligne au-dessus",
"xlsx.insert_row_below": "Insérer une ligne en dessous",
"xlsx.insert_col_left": "Insérer une colonne à gauche",
"xlsx.insert_col_right": "Insérer une colonne à droite",
"xlsx.delete_rows": "Supprimer {n} ligne(s)",
"xlsx.delete_cols": "Supprimer {n} colonne(s)",
"xlsx.clear_contents": "Effacer le contenu",
"xlsx.confirm_delete_rows": "Supprimer {n} ligne(s) ? Cette action modifie le fichier (un backup est créé).",
"xlsx.confirm_delete_cols": "Supprimer {n} colonne(s) ? Cette action modifie le fichier (un backup est créé).",
"xlsx.structure_saved": "Structure mise à jour",
"xlsx.structure_error": "Modification de la structure impossible",
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
"xlsx.dashboard_btn": "Tableau de bord",
"xlsx.inspector_title": "Inspecteur",
"xlsx.inspector_ai": "Assistant IA",
"xlsx.inspector_ai_short": "IA",
"xlsx.inspector_resize": "Redimensionner l'inspecteur",
"xlsx.range_not_visible": "Cellule hors de l'affichage (feuille tronquée)",
"xlsx.dashboard_title": "Tableau de bord du classeur",
"xlsx.dashboard_stats": "{{cells}} cellules · {{rows}} lignes · {{cols}} colonnes · {{formulas}} formules · {{numeric}} valeurs numériques",
"xlsx.dashboard_charts": "{{n}} graphique(s)",
+185 -1
View File
@@ -11051,6 +11051,55 @@ body.desktop-mode .editor-container {
.xlsx-status-formula {
color: var(--accent);
}
/* #154-A2 — unsaved-change feedback: the primary button and the owning tab. */
.xlsx-save-primary.is-dirty {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.xlsx-tab-dirty::after {
content: "•";
margin-left: 5px;
color: var(--warning, #e0a800);
}
/* #154-A2 — non-blocking banners (conflict, …). */
.xlsx-banner-host {
display: flex;
flex-direction: column;
gap: 8px;
}
.xlsx-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 4px;
background: var(--surface);
color: var(--text-secondary);
font-size: 0.82rem;
}
.xlsx-banner-conflict {
border-left-color: var(--warning, #e0a800);
}
.xlsx-banner-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: var(--warning, #e0a800);
}
.xlsx-banner-text {
flex: 1;
min-width: 0;
}
.xlsx-banner-dismiss {
padding: 2px 8px;
}
.modal-confirm-text {
color: var(--text-primary);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-line;
}
.xlsx-table {
background: var(--grid-bg);
}
@@ -11099,7 +11148,8 @@ body.desktop-mode .editor-container {
border-bottom: 2px solid var(--grid-border);
}
.xlsx-table td[contenteditable] {
cursor: text;
/* #155 — Excel-style cross cursor over the cells. */
cursor: cell;
min-width: 40px;
white-space: pre-wrap;
}
@@ -11240,6 +11290,91 @@ body.desktop-mode .editor-container {
color: var(--text-primary);
}
/* #154-A3 — right-hand inspector (dashboard, assistant) beside the grid. */
.xlsx-body {
display: flex;
align-items: stretch;
gap: 10px;
}
.xlsx-main {
flex: 1 1 auto;
min-width: 0;
}
.xlsx-inspector {
flex: 0 0 340px;
width: 340px;
max-width: 45%;
display: none;
flex-direction: column;
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
max-height: 70vh;
}
.xlsx-inspector.open {
display: flex;
}
.xlsx-inspector-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.xlsx-inspector-title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-primary);
}
.xlsx-inspector-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.xlsx-inspector-body {
flex: 1 1 auto;
overflow: auto;
padding: 10px;
}
/* #154-A5 — resize handle on the inspector's left edge. */
.xlsx-inspector-resize {
position: absolute;
left: -3px;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 2;
}
.xlsx-inspector-resize:hover {
background: color-mix(in srgb, var(--accent) 40%, transparent);
}
.xlsx-range-row {
cursor: pointer;
}
.xlsx-range-row:hover td {
background: var(--bg-hover);
}
.xlsx-inspector .xlsx-dashboard {
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
@media (max-width: 900px) {
.xlsx-body {
flex-direction: column;
}
.xlsx-inspector {
flex-basis: auto;
width: auto;
max-width: none;
}
}
/* #153 A17 — workbook dashboard panel: named ranges table + KPI cards.
Colors come from the existing CSS variables (no hardcoded values). */
.xlsx-dashboard {
@@ -11339,6 +11474,55 @@ body.desktop-mode .editor-container {
background: var(--border);
}
/* #155 — Excel-like range selection + right-click / long-press context menu. */
.xlsx-table tbody td.xlsx-selected {
background: var(--accent-bg);
}
.xlsx-viewer.xlsx-selecting,
.xlsx-viewer.xlsx-selecting td {
user-select: none;
}
.xlsx-viewer.xlsx-selecting td[contenteditable] {
cursor: crosshair;
}
.xlsx-context-menu {
position: fixed;
z-index: 10050;
display: flex;
flex-direction: column;
min-width: 220px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
}
.xlsx-context-item {
text-align: left;
border: none;
background: transparent;
color: var(--text-primary);
border-radius: 4px;
padding: 6px 10px;
font-size: 0.82rem;
cursor: pointer;
}
.xlsx-context-item:hover:not(:disabled) {
background: var(--bg-hover);
}
.xlsx-context-item:disabled {
opacity: 0.5;
cursor: default;
}
.xlsx-context-danger {
color: var(--danger);
}
.xlsx-context-sep {
height: 1px;
margin: 4px 0;
background: var(--border);
}
/* JSDOM shims for the tests that click anchors */
mark {
font: inherit;
+5 -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 = 'v29';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
@@ -30,6 +30,10 @@ 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/xlsx/context-menu.js',
'/static/js/search.js',
'/static/js/config.js',
'/static/js/utils.js',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.40.0",
"version": "2.44.1",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+13 -9
View File
@@ -116,12 +116,6 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
await login(page);
await openFixture(page);
let dialogMessage = null;
page.on('dialog', async (dialog) => {
dialogMessage = dialog.message();
await dialog.accept();
});
const cell = page.locator('#content-area td[data-cell="A2"]');
await cell.click();
await cell.fill('Total confirmé');
@@ -131,7 +125,11 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
await expect(save).toBeEnabled();
await save.click();
await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments');
// #154-A2 — the lossy confirmation is a themed in-app dialog, not a native one.
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(dialog).toContainText('segments');
await dialog.locator('[data-dialog="confirm"]').click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
// La cellule reste modifiée côté UI (plus de marque « sale »).
@@ -145,13 +143,19 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
const toggle = page.locator('#xlsx-formula-btn');
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
// 409 → confirmation, puis reprise avec force (le toggle reste désactivé).
page.on('dialog', (dialog) => dialog.accept());
// 409 → confirmation thémée, puis reprise avec force (le toggle reste désactivé).
const cell = page.locator('#content-area td[data-cell="B2"]');
await cell.click();
await cell.fill('=B1*3');
await cell.press('Enter');
await page.locator('#xlsx-save-btn').click();
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
// A lossy workbook asks for confirmation. A previous test's save may already
// have dropped the slicers part, in which case the PUT succeeds directly.
await dialog.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
if (await dialog.isVisible()) {
await dialog.locator('[data-dialog="confirm"]').click();
}
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
});
});
+289 -32
View File
@@ -105,6 +105,9 @@ async function test(name, fn) {
if (apiQueue.length) return apiQueue.shift();
return { ok: true, status: 200, body: { status: "ok" } };
};
// #154-A2 — themed dialogs live in document.body; clear any left behind by a
// previous test so the helpers always reach the current one.
document.querySelectorAll(".obsigate-modal-overlay").forEach((n) => n.remove());
try {
await fn();
console.log(` ✓ ${name}`);
@@ -165,6 +168,27 @@ function editCell(area, ref, text) {
return td;
}
/** #154-A2 — the themed dialog is the last overlay in document.body. */
const lastDialog = () => {
const overlays = document.querySelectorAll(".obsigate-modal-overlay");
return overlays[overlays.length - 1] || null;
};
const clickDialog = (which) => {
const btn = lastDialog()?.querySelector(`[data-dialog="${which}"]`);
assert.ok(btn, `dialog button "${which}" is present`);
btn.click();
};
/** #155 — open the grid context menu on a cell (right-click). */
const openMenuOn = (area, ref) => {
const td = area.querySelector(`td[data-cell="${ref}"]`);
assert.ok(td, `cell ${ref} exists`);
td.dispatchEvent(new w.MouseEvent("contextmenu", { bubbles: true, clientX: 12, clientY: 12 }));
return document.querySelector(".xlsx-context-menu");
};
const menuItem = (label) =>
[...document.querySelectorAll(".xlsx-context-item")].find((b) => b.textContent === label);
const lossyError = {
ok: false,
status: 409,
@@ -248,12 +272,17 @@ await test("409 xlsx_lossy_content asks once then retries with force", async ()
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1);
// #154-A2 — a themed dialog replaces window.confirm(), and no retry happens
// before the user answers.
const dialog = lastDialog();
assert.ok(dialog, "a themed confirmation is shown");
assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent);
assert.equal(calls.length, 1, "nothing is retried before the answer");
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.equal(calls[0].body.force, false);
assert.equal(calls[1].body.force, true);
// The prompt names the features the backend reported.
assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]);
// Save succeeded → cells are no longer dirty.
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
});
@@ -264,27 +293,52 @@ await test("confirming once is enough for the following saves", async () => {
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
editCell(area, "A1", "2");
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1, "the user is not asked twice");
assert.equal(calls.length, 3);
assert.equal(calls[2].body.force, true);
// Wait out the dialog close animation, then confirm none is left.
await new Promise((r) => setTimeout(r, 250));
assert.equal(document.querySelectorAll(".obsigate-modal-overlay").length, 0, "the user is not asked twice");
});
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
const area = mount({ lossy: ["slicers"] });
editCell(area, "A1", "250");
nextResponse = () => lossyError;
confirmAnswer = false;
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1);
clickDialog("cancel");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 1, "no retry after a refusal");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
});
await test("a 409 conflict shows a non-blocking retry banner and keeps the edits", async () => {
const area = mount();
editCell(area, "A1", "250");
nextResponse = () => ({ ok: false, status: 409, body: { detail: "busy", code: "conflict" } });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
const banner = area.querySelector(".xlsx-banner-conflict");
assert.ok(banner, "a conflict banner is shown");
assert.ok(banner.textContent.includes(FR["xlsx.conflict_msg"]), banner.textContent);
// The edit is preserved and the save button is usable again.
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
// The retry re-runs the save.
nextResponse = () => ({ ok: true, status: 200, body: {} });
banner.querySelector(".xlsx-banner-retry").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry");
});
await test("a non-409 failure is not retried", async () => {
const area = mount();
editCell(area, "A1", "250");
@@ -515,14 +569,16 @@ const mountGrid = () => {
return area;
};
await test("clicking a header sorts the rows numerically or lexically", () => {
await test("the context menu sorts the rows numerically or lexically", () => {
const area = mountGrid();
// Sort by column B (numbers) ascending: 2, 3, 10.
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
openMenuOn(area, "B1");
menuItem(FR["xlsx.sort_asc"]).click();
let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
assert.deepEqual(cells, ["2", "3", "10"]);
// Second click: descending.
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
// Re-open and sort descending.
openMenuOn(area, "B1");
menuItem(FR["xlsx.sort_desc"]).click();
cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
assert.deepEqual(cells, ["10", "3", "2"]);
});
@@ -530,7 +586,8 @@ await test("clicking a header sorts the rows numerically or lexically", () => {
await test("a dirty cell travels with its row during a sort", () => {
const area = mountGrid();
editCell(area, "A3", "Cerise modifiée");
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
openMenuOn(area, "B1");
menuItem(FR["xlsx.sort_asc"]).click();
const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent);
assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|"));
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
@@ -582,29 +639,25 @@ await test("CSV export downloads the visible sheet without the cached shadows",
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
const area = mount();
// The prompt is resolved through the module scope: stub it globally.
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
apiQueue.push({
ok: true, status: 200,
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
}); // re-read
area.querySelector("#xlsx-structure-btn").click();
const items = [...area.querySelectorAll(".xlsx-structure-item")];
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
const addBtn = [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]);
addBtn.click();
await new Promise((r) => setTimeout(r, 5));
globalThis.prompt = realPrompt;
// #154-A2 — a themed prompt replaces window.prompt().
const input = lastDialog()?.querySelector('[data-dialog="input"]');
assert.ok(input, "a themed prompt asks for the sheet name");
input.value = "Feuille 2";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
assert.equal(calls[0].body.force, false);
// The viewer re-rendered from the server payload (a single sheet → no tabs).
assert.ok(
area.querySelector("#content-area, .xlsx-viewer") || area,
"the viewer was rebuilt",
);
assert.ok(
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
"the re-render shows the fresh payload",
@@ -626,9 +679,7 @@ await test("sheet_delete confirms and is refused on the last sheet", async () =>
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
const area = mount();
// The prompt is resolved through the module scope: stub it globally.
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
// The prompt is answered through the themed dialog.
apiQueue.push({
ok: false, status: 409,
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
@@ -639,15 +690,29 @@ await test("the 409 lossy flow re-emits with force after confirmation", async ()
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
});
area.querySelector("#xlsx-structure-btn").click();
const items = [...area.querySelectorAll(".xlsx-structure-item")];
items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
[...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
await new Promise((r) => setTimeout(r, 5));
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
const lossyDialog = lastDialog();
assert.ok(lossyDialog && lossyDialog.textContent.includes(FR["xlsx.feature_slicers"]), "a lossy confirmation is shown");
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
globalThis.prompt = realPrompt;
assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
assert.equal(calls.length, 3);
assert.equal(calls[1].body.force, true);
});
// ── BUG-094 — structure actions work on an empty/new sheet ─────────────────
await test("the structure menu offers row/column actions without an active cell", () => {
const area = mount();
area.querySelector("#xlsx-structure-btn").click();
const labels = [...area.querySelectorAll(".xlsx-structure-item")].map((b) => b.textContent);
assert.ok(labels.includes(FR["xlsx.row_insert"]), "row insert available before any click");
assert.ok(labels.includes(FR["xlsx.col_insert"]), "column insert available before any click");
});
// ── A17 — dashboard panel ─────────────────────────────────────────────
await test("the dashboard button fetches the metadata and renders named ranges + KPIs", async () => {
const area = mount();
@@ -677,6 +742,34 @@ await test("the dashboard button fetches the metadata and renders named ranges +
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
});
await test("the dashboard opens in the right-hand inspector, not inline in the grid", async () => {
const area = mount();
apiQueue.push({
ok: true, status: 200,
body: { vault: "V", path: "data.xlsx", named_ranges: [], objects: { charts: 0, pivots: 0 }, sheets: [{ name: "Feuille1", cells: 1, rows: 1, cols: 1, formulas: 0, numeric: 0, kpi: [] }] },
});
area.querySelector("#xlsx-dashboard-btn").click();
await new Promise((r) => setTimeout(r, 5));
const inspector = area.querySelector("#xlsx-inspector");
assert.equal(inspector.classList.contains("open"), true, "the inspector opens");
assert.equal(inspector.getAttribute("aria-hidden"), "false");
assert.ok(inspector.querySelector("#xlsx-inspector-body .xlsx-dashboard"), "the dashboard lives in the inspector");
// The grid is still present beside it (not replaced).
assert.ok(area.querySelector(".xlsx-main .xlsx-table"), "the grid stays visible");
// Closing removes the panel and closes the inspector.
area.querySelector("#xlsx-inspector-close").click();
assert.equal(area.querySelector(".xlsx-dashboard"), null);
assert.equal(inspector.classList.contains("open"), false);
assert.equal(inspector.getAttribute("aria-hidden"), "true");
});
await test("the inspector exposes an AI assistant entry point", () => {
const area = mount();
assert.ok(area.querySelector("#xlsx-inspector-ai"), "AI button in the inspector head");
const title = area.querySelector("#xlsx-inspector .xlsx-inspector-title");
assert.equal(title.textContent, FR["xlsx.inspector_title"]);
});
await test("a csv mounts without the dashboard button", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
@@ -743,8 +836,6 @@ await test("a csv shows no tab strip and no + button", () => {
await test("the + button adds a sheet through the structure endpoint", async () => {
const area = mount();
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
apiQueue.push({
ok: true, status: 200,
@@ -752,7 +843,9 @@ await test("the + button adds a sheet through the structure endpoint", async ()
}); // re-read
area.querySelector("#xlsx-tab-add").click();
await new Promise((r) => setTimeout(r, 5));
globalThis.prompt = realPrompt;
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
@@ -772,6 +865,7 @@ await test("a read-only workbook shows the read-only pill and offers no editing"
assert.ok(pill.textContent.includes(FR["xlsx.readonly_badge"]), pill.textContent);
assert.equal(area.querySelector("#xlsx-tab-add"), null, "no + for read-only");
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure for read-only");
assert.equal(area.querySelector("#xlsx-undo-btn"), null, "no history for read-only");
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("contenteditable"), null, "cells are not editable");
});
@@ -800,6 +894,169 @@ await test("the command bar exposes grouped actions with a primary save", () =>
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='file']"));
});
// ── #154-A4 — undo/redo, ARIA ───────────────────────────────────────────────
await test("undo/redo restores cell edits from the command stack", () => {
const area = mount();
const td = area.querySelector('td[data-cell="A1"]');
const undoBtn = area.querySelector("#xlsx-undo-btn");
const redoBtn = area.querySelector("#xlsx-redo-btn");
assert.equal(undoBtn.disabled, true, "nothing to undo yet");
td.focus();
editCell(area, "A1", "42");
td.dispatchEvent(new w.Event("blur"));
assert.equal(undoBtn.disabled, false, "undo becomes available after a committed edit");
undoBtn.click();
assert.equal(td.textContent, "100", "undo restores the original value");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0, "the cell is clean again");
assert.equal(redoBtn.disabled, false, "redo becomes available");
redoBtn.click();
assert.equal(td.textContent, "42", "redo reapplies the edit");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1, "the edit is dirty again");
});
await test("a no-op undo/redo pair leaves the buttons disabled", () => {
const area = mount();
assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true);
assert.equal(area.querySelector("#xlsx-redo-btn").disabled, true);
});
await test("the grid exposes ARIA grid semantics", () => {
const area = mount();
const table = area.querySelector(".xlsx-table");
assert.equal(table.getAttribute("role"), "grid");
assert.equal(area.querySelector(".xlsx-table thead th").getAttribute("role"), "columnheader");
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("role"), "gridcell");
assert.equal(area.querySelector("th.xlsx-rownum").getAttribute("role"), "rowheader");
});
// ── #154-A5 — extracted refs module & dashboard → grid link ────────────────
const { parseRef, columnName, firstCellOfRange, sheetOfRef } = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "refs.js")).href
);
await test("parseRef / columnName round-trip A1 references", () => {
assert.deepEqual(parseRef("A1"), { row: 1, col: 1 });
assert.deepEqual(parseRef("B12"), { row: 12, col: 2 });
assert.deepEqual(parseRef("AA3"), { row: 3, col: 27 });
assert.equal(parseRef("A"), null);
assert.equal(columnName(1), "A");
assert.equal(columnName(26), "Z");
assert.equal(columnName(27), "AA");
assert.equal(columnName(parseRef("AB9").col), "AB");
});
await test("range helpers extract the sheet and the first cell", () => {
assert.equal(firstCellOfRange("Data!$A$1:$B$5"), "A1");
assert.equal(firstCellOfRange("'Mon onglet'!$C$3"), "C3");
assert.equal(firstCellOfRange("A1:B2"), "A1");
assert.equal(sheetOfRef("Data!$A$1:$B$5"), "Data");
assert.equal(sheetOfRef("'Mon onglet'!$C$3"), "Mon onglet");
assert.equal(sheetOfRef("A1:B2"), null);
});
await test("clicking a named range reveals its first cell in the grid", async () => {
const area = mount();
apiQueue.push({
ok: true, status: 200,
body: { vault: "V", path: "data.xlsx", named_ranges: [{ name: "MaPlage", scope: "", ref: "Feuille1!$A$1:$B$2" }], objects: { charts: 0, pivots: 0 }, sheets: [] },
});
area.querySelector("#xlsx-dashboard-btn").click();
await new Promise((r) => setTimeout(r, 5));
const row = area.querySelector(".xlsx-range-row[data-ref]");
assert.ok(row, "the named range row is rendered");
row.click();
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1", "the anchor cell becomes active");
assert.ok(area.querySelector('td[data-cell="A1"]').classList.contains("xlsx-active"));
});
await test("the inspector exposes a resize handle", () => {
const area = mount();
assert.ok(area.querySelector("#xlsx-inspector-resize"), "resize handle present");
});
// ── #155 — selection & context menu ─────────────────────────────────────────
await test("a right-click on a cell opens a themed context menu", () => {
const area = mount();
const menu = openMenuOn(area, "A1");
assert.ok(menu, "menu rendered");
assert.ok(menuItem(FR["xlsx.insert_row_above"]), "insert row item");
assert.ok(menuItem(FR["xlsx.sort_asc"]), "sort item");
assert.ok(menuItem(FR["xlsx.clear_contents"]), "clear item");
});
await test("the context menu inserts a row through the structure endpoint", async () => {
const area = mount();
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
apiQueue.push({
ok: true, status: 200,
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [] },
}); // re-read
openMenuOn(area, "A2");
menuItem(FR["xlsx.insert_row_above"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [{ op: "row_insert", sheet: "Feuille1", at: 2, count: 1 }]);
});
await test("clear contents empties the selected cells (local edit)", () => {
const area = mount();
openMenuOn(area, "A1");
menuItem(FR["xlsx.clear_contents"]).click();
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
});
await test("clicking a column header selects the whole column", () => {
const area = mount();
area.querySelector(".xlsx-table thead th:nth-child(2)").click(); // column A
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:A2");
const selected = [...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort();
assert.deepEqual(selected, ["A1", "A2"]);
});
await test("clicking a row header selects the whole row", () => {
const area = mount();
area.querySelector("th.xlsx-rownum").click(); // row 1
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
});
await test("dragging from a cell to another selects the range", () => {
const area = mount();
const viewer = area.querySelector(".xlsx-viewer");
const a1 = area.querySelector('td[data-cell="A1"]');
const b2 = area.querySelector('td[data-cell="B2"]');
a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
b2.dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 }));
viewer.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B2");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4);
});
await test("Shift+Arrow extends the selection from the anchor", () => {
const area = mount();
const a1 = area.querySelector('td[data-cell="A1"]');
a1.focus();
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true, bubbles: true }));
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 2);
});
await test("the context menu module exposes open/close helpers", async () => {
const mod = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")).href
);
const menu = mod.openContextMenu(5, 5, [{ label: "Only" }]);
assert.equal(menu.querySelectorAll(".xlsx-context-item").length, 1);
assert.equal(mod.isContextMenuOpen(), true);
mod.closeContextMenu();
assert.equal(mod.isContextMenuOpen(), false);
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+2 -1
View File
@@ -252,7 +252,8 @@ class TestDependencySecurityFloors:
#: (paquet, plancher minimal, advisories corrigées au-dessus)
FLOORS = {
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
"pyjwt": (2, 13, 0), # PYSEC-2026-178 (fix 2.13.0)
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
}
def test_security_floors_are_declared(self):
+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) ────────────────────────────────────────