Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bacfd69d9 | ||
|
|
383ffa6a65 | ||
|
|
69927176df | ||
|
|
5c2ae26a74 | ||
|
|
3f52b56251 | ||
|
|
72da123a51 | ||
|
|
e94af0369b | ||
|
|
8da65611cb |
@@ -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
@@ -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
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.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*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.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*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,13 @@ logger = logging.getLogger("obsigate.xlsx_reader")
|
||||
MAX_ROWS = 500
|
||||
MAX_COLS = 40
|
||||
|
||||
# BUG-094 — an empty sheet used to render as a bare "Feuille vide" paragraph
|
||||
# with no cell at all, so a freshly added sheet had nothing to click and no way
|
||||
# to insert a row/column. Render a small blank grid instead (Excel-like), with
|
||||
# real A1 coordinates, so the cells are editable and the structure actions work.
|
||||
EMPTY_SHEET_ROWS = 20
|
||||
EMPTY_SHEET_COLS = 8
|
||||
|
||||
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
|
||||
# loading). The endpoint is bounded so a single request can never ask for the
|
||||
# whole workbook back in one JSON payload; the UI pages through the rest.
|
||||
@@ -424,6 +431,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||
sheets = []
|
||||
for i, title in enumerate(titles):
|
||||
grid = _trim(formulas[i])
|
||||
# BUG-094 — a blank sheet still needs an editable grid (see constants):
|
||||
# the viewer's cell editing and structure actions all hang off a cell.
|
||||
if not grid:
|
||||
grid = [[""] * EMPTY_SHEET_COLS for _ in range(EMPTY_SHEET_ROWS)]
|
||||
# The shadow grid is NOT trimmed independently: _trim drops the
|
||||
# trailing empty columns of each grid on its own width, which would
|
||||
# shift every cached value left of its formula. Indexing it
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.40.0"
|
||||
version = "2.44.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
@@ -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é) |
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/* ObsiGate — XLSX command bar builder (#154-A5).
|
||||
*
|
||||
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
|
||||
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
|
||||
* is testable and the viewer only wires behaviour.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {Array<{name: string}>} ctx.sheets
|
||||
* @param {boolean} ctx.isCsv
|
||||
* @param {boolean} ctx.readOnly
|
||||
* @param {boolean} ctx.editable - not a CSV and not read-only
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string }}
|
||||
*/
|
||||
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
|
||||
// as a real affordance; an editable workbook gets an explicit “+” button.
|
||||
const tabs = isCsv
|
||||
? ""
|
||||
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
|
||||
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
|
||||
).join("")}${editable
|
||||
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
|
||||
: ""}</div>`;
|
||||
|
||||
// Status pills make the viewer's limits visible up front.
|
||||
const statusPills = [
|
||||
readOnly
|
||||
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
|
||||
: "",
|
||||
!isCsv
|
||||
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
|
||||
: "",
|
||||
].filter(Boolean).join("");
|
||||
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
|
||||
|
||||
// Command groups, separated by thin rules.
|
||||
const actionGroups = [];
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
|
||||
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
|
||||
<i data-lucide="undo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-redo-btn" type="button" title="${escapeHtml(t("xlsx.redo"))}" disabled>
|
||||
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
|
||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||
</span>`);
|
||||
}
|
||||
if (editable) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||||
</span>`);
|
||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
|
||||
|
||||
return { tabs, actionsHtml, statusBar };
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* ObsiGate — XLSX dashboard renderer (#154-A5).
|
||||
*
|
||||
* Pure string builders for the workbook dashboard inspector (named ranges, KPI
|
||||
* cards). Extracted from frontend/js/viewer.js; the viewer handles the fetch,
|
||||
* the inspector host and the range → grid link.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
export function renderDashboardLoading() {
|
||||
return `<div class="xlsx-dashboard-loading">…</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} dash - payload of GET …/xlsx/dashboard
|
||||
* @returns {string} the inspector body markup
|
||||
*/
|
||||
export function renderDashboardHtml(dash) {
|
||||
const namedRanges = dash.named_ranges || [];
|
||||
const sheets = dash.sheets || [];
|
||||
const rangeRows = namedRanges.map((r) =>
|
||||
`<tr class="xlsx-range-row" data-ref="${escapeHtml(r.ref || "")}"><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
|
||||
).join("");
|
||||
const kpiCards = sheets.map((s) => {
|
||||
const cards = (s.kpi || []).map((k) =>
|
||||
`<span class="xlsx-kpi"><span class="xlsx-kpi-label">${escapeHtml(k.label)}</span><span class="xlsx-kpi-value">${escapeHtml(String(k.value))}</span></span>`,
|
||||
).join("");
|
||||
return `<div class="xlsx-kpi-sheet">
|
||||
<h4>${escapeHtml(s.name)}</h4>
|
||||
<p class="xlsx-kpi-meta">${escapeHtml(t("xlsx.dashboard_stats", {
|
||||
cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric,
|
||||
}))}</p>
|
||||
<div class="xlsx-kpi-cards">${cards || "<span class=\"xlsx-kpi-empty\">—</span>"}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
return `
|
||||
<div class="xlsx-dashboard-head">
|
||||
<h3><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.dashboard_title"))}</h3>
|
||||
<span class="xlsx-dashboard-objects">
|
||||
${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))}
|
||||
· ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))}
|
||||
</span>
|
||||
</div>
|
||||
${namedRanges.length || sheets.length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
|
||||
${namedRanges.length ? `
|
||||
<table class="csv-table xlsx-ranges-table"><thead><tr>
|
||||
<th>${escapeHtml(t("xlsx.dashboard_nr_name"))}</th>
|
||||
<th>${escapeHtml(t("xlsx.dashboard_nr_scope"))}</th>
|
||||
<th>${escapeHtml(t("xlsx.dashboard_nr_ref"))}</th>
|
||||
</tr></thead><tbody>${rangeRows}</tbody></table>` : ""}
|
||||
<div class="xlsx-kpi-grid">${kpiCards}</div>
|
||||
<p class="xlsx-dashboard-hint">${escapeHtml(t("xlsx.dashboard_hint"))}</p>`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* ObsiGate — XLSX A1 reference helpers (#154-A5).
|
||||
*
|
||||
* Pure functions (no DOM, no i18n): safe to unit-test in Node and shared by the
|
||||
* viewer. Extracted from frontend/js/viewer.js without behaviour change.
|
||||
*/
|
||||
|
||||
/** Parse an A1 reference ("B12") into its 1-based (row, col) parts. */
|
||||
export function parseRef(ref) {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
|
||||
if (!m) return null;
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { row: Number(m[2]), col };
|
||||
}
|
||||
|
||||
/** Column number (1-based) → letters ("A", "Z", "AA"). */
|
||||
export function columnName(col) {
|
||||
let name = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
name = String.fromCharCode(65 + rem) + name;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Find a rendered cell inside a sheet panel by (row, col). */
|
||||
export function findTd(panel, row, col) {
|
||||
return panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||||
}
|
||||
|
||||
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
|
||||
export function sheetOfRef(ref) {
|
||||
const s = String(ref || "");
|
||||
if (!s.includes("!")) return null;
|
||||
return s.split("!")[0].replace(/^'|'$/g, "");
|
||||
}
|
||||
|
||||
/** First cell of a range ref ("Data!$A$1:$B$5" → "A1"), or null. */
|
||||
export function firstCellOfRange(ref) {
|
||||
const s = String(ref || "");
|
||||
const body = s.includes("!") ? s.split("!").pop() : s;
|
||||
const m = /([A-Za-z]+)\$?(\d+)/.exec(body);
|
||||
return m ? `${m[1].toUpperCase()}${m[2]}` : null;
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -96,6 +96,29 @@ class TestXlsxDisplay:
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "corrupt.xlsx"})
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_empty_sheet_renders_an_editable_blank_grid(self, client, test_vault_dir):
|
||||
"""BUG-094 — a blank sheet used to render as a bare « Feuille vide »
|
||||
paragraph with no cell, so a freshly added sheet could not be filled
|
||||
and had no way to insert a row/column. It now exposes a small editable
|
||||
grid with real A1 coordinates."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "blank.xlsx"
|
||||
wb = Workbook()
|
||||
wb.active.title = "Vide"
|
||||
wb.create_sheet("Vide2")
|
||||
wb.save(str(path))
|
||||
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "blank.xlsx"})
|
||||
assert resp.status_code == 200
|
||||
vide = next(s for s in resp.json()["xlsx_sheets"] if s["name"] == "Vide")
|
||||
assert "Feuille vide" not in vide["html"]
|
||||
assert 'data-cell="A1"' in vide["html"]
|
||||
assert 'data-cell="H20"' in vide["html"] # last cell of the blank grid
|
||||
assert vide["rows"] == 20
|
||||
assert vide["cols"] == 8
|
||||
assert vide["truncated"] is False
|
||||
|
||||
|
||||
# ── Index parity (tree visibility) ────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user