Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8da65611cb | ||
|
|
605060c51d | ||
|
|
856e654306 | ||
|
|
4de9ee038c | ||
|
|
290d62da4e | ||
|
|
140e9a679d | ||
|
|
267a33d43b | ||
|
|
435a0687d7 | ||
|
|
dbf935bec0 | ||
|
|
d6d081c0e9 | ||
|
|
c72f852a55 | ||
|
|
99779ecc08 | ||
|
|
c4b8e66206 |
+31
-6
@@ -131,8 +131,13 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
# setuptools / pip sont mis à jour : l'image de base peut embarquer
|
||||
# une version couverte par un advisory fraîchement publié
|
||||
# (PYSEC-2026-3447 / PYSEC-2026-3721).
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: |
|
||||
pip install bandit pip-audit semgrep
|
||||
pip install -U pip setuptools
|
||||
pip install bandit pip-audit
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Bandit (SAST, bloquant — #87)
|
||||
@@ -141,10 +146,20 @@ jobs:
|
||||
# vrais positifs restants portent un `# nosec` justifié inline.
|
||||
run: bandit -r backend/ --skip B101,B105,B110,B310
|
||||
|
||||
- name: Semgrep (SAST local, bloquant — #87)
|
||||
# Règles 100 % locales (semgrep-rules/, 8 règles) : aucun
|
||||
# téléchargement de registre (runner au réseau fragile).
|
||||
run: semgrep --config semgrep-rules/ backend/
|
||||
- name: Semgrep (SAST local) — DÉSACTIVÉ (BUG-091)
|
||||
# Les règles locales (semgrep-rules/, 8 règles) ne sont plus exécutées
|
||||
# en CI : semgrep-core est un exécutable natif que le runner actuel ne
|
||||
# peut pas lancer (exit 127, sans message exploitable) — les releases
|
||||
# récentes exigent un CPU x86-64-v2, et la dernière version compatible
|
||||
# (1.157.0, core statique vérifié en baseline v1) échoue aussi. Les
|
||||
# règles restent applicables en local : `semgrep --config semgrep-rules/
|
||||
# backend/`. À réactiver dès que le runner dispose d'un CPU x86-64-v2
|
||||
# (ou d'une image de runner plus récente). Bandit et pip-audit, eux,
|
||||
# restent bloquants dans ce job.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "::warning::SAST semgrep non exécutée (runner incompatible — BUG-091). Bandit et pip-audit restent bloquants."
|
||||
|
||||
- name: Pip-audit (bloquant — #87)
|
||||
# Bloquant depuis T6 (#87) : dépendances qualifiées (mistune 3.3.3,
|
||||
@@ -153,7 +168,17 @@ 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.
|
||||
# s'exécutent jamais. PYSEC-2026-178 (pyjwt) est, lui, corrigé par le
|
||||
# plancher pyjwt>=2.13.0 de backend/requirements.txt (BUG-091).
|
||||
# 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
|
||||
# pypdf>=6.16.1 (BUG-093). Ces planchers doivent rester *au-dessus*
|
||||
# des versions préinstallées dans la toolcache de l'image du runner :
|
||||
# en dessous, pip répond « already satisfied » et n'aligne jamais
|
||||
# (c'est exactement ce qui a fait échouer ce job). Le garde-fou
|
||||
# tests/test_ci_workflow.py::TestDependencySecurityFloors verrouille
|
||||
# ces planchers.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
|
||||
|
||||
+218
-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.38.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.41.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,223 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [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é
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 1) : ruban de commandes groupé,
|
||||
onglets de feuilles permanents avec bouton « + », badges d'état.**
|
||||
La vue tableur gagne une barre de commandes segmentée (Formules · Insertion · Vue ·
|
||||
Fichier) avec un bouton **Enregistrer** primaire. La barre d'onglets est désormais
|
||||
toujours affichée (même à une seule feuille) et un bouton « + » y ajoute une feuille
|
||||
(même pipeline `PUT …/xlsx/structure`, re-rendu depuis le serveur). Deux pastilles
|
||||
d'état annoncent les limites de la vue : **lecture seule** (`.xls`/`.ods` — plus de
|
||||
« + », ni de structure, ni de tableau de bord, ni d'édition) et **formules non
|
||||
recalculées**. Des tokens de grille dédiés (`--grid-bg`, `--grid-header-bg`,
|
||||
`--grid-header-text`, `--grid-border`, `--grid-zebra`, déclinés dark/light) rendent les
|
||||
en-têtes clairement distincts des cellules, avec zébrage, survol et cellule active
|
||||
renforcée. Audit UX, architecture cible et plan par lots :
|
||||
[docs/features/xlsx-ui-redesign.md](docs/features/xlsx-ui-redesign.md). Tests JSDOM
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (41, dont 6 nouveaux). Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.10] — 2026-09-29
|
||||
|
||||
### Sécurité
|
||||
|
||||
- **BUG-093 — deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910,
|
||||
PYSEC-2026-3911) corrigés par le plancher `pypdf>=6.16.1`.**
|
||||
Un PDF peut provoquer un temps de calcul et une consommation mémoire
|
||||
arbitraires, soit via de nombreux contours (*outlines*), soit via une page
|
||||
portant beaucoup d'objets XForm réutilisés. ObsiGate est **directement
|
||||
exposé** : `backend/pdf_reader.py` extrait le texte et parcourt les contours
|
||||
de PDF fournis par l'utilisateur. Le plancher `pypdf>=4.0` ne protégeait
|
||||
rien en pratique — l'image du runner Act embarque 6.16.0 *préinstallé* dans
|
||||
sa toolcache Python, donc `pip` répondait « already satisfied » et
|
||||
n'alignait jamais la version. Tout plancher de sécurité doit désormais rester
|
||||
au-dessus de la version préinstallée.
|
||||
|
||||
### Correction
|
||||
|
||||
- **Le job CI `security` n'est plus rouge : la désactivation de semgrep
|
||||
fonctionne, et `pip-audit` est désormais réparé pour de bon.**
|
||||
Le garde-fou `tests/test_ci_workflow.py::TestSemgrepStep`, en régression
|
||||
depuis la désactivation (il exigeait encore l'exécution de semgrep),
|
||||
vérifie maintenant que l'étape n'exécute que son `::warning::` et que
|
||||
**bandit et pip-audit restent bloquants**. Nouveau garde-fou
|
||||
`TestDependencySecurityFloors` : les planchers de sécurité (`pypdf`,
|
||||
`pyjwt`) ne peuvent plus retomber sous leur correctif — contre-preuve
|
||||
vérifiée (plancher remis à `>=4.0` → test rouge).
|
||||
|
||||
---
|
||||
|
||||
## [2.39.9] — 2026-09-29
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-091 — l'étape Semgrep est désactivée dans le job CI `security`.**
|
||||
Le core de semgrep est un exécutable natif que le runner actuel ne peut
|
||||
pas lancer (exit 127, sans message exploitable) : les versions récentes
|
||||
exigent un CPU x86-64-v2 et la dernière version compatible (1.157.0, core
|
||||
statique vérifié en baseline v1) échoue également. Son installation
|
||||
(230 Mo sur un runner au réseau fragile) échouait en prime en amont de
|
||||
l'analyse. **Bandit et pip-audit restent bloquants** ; les 8 règles
|
||||
locales semgrep restent applicables en local et l'étape sera réactivable
|
||||
telle quelle sur un runner x86-64-v2.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.8] — 2026-09-29
|
||||
|
||||
---
|
||||
|
||||
## [2.39.7] — 2026-09-29
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-091 — l'étape Semgrep ne bloque plus la CI quand le runner ne peut
|
||||
pas exécuter le core.** Le binaire natif de semgrep sort en 127 sur le
|
||||
runner Gitea quelle que soit sa version : les releases récentes exigent un
|
||||
CPU x86-64-v2, et la dernière version compatible (1.157.0, core statique
|
||||
vérifié en baseline v1) échoue également, sans message. L'étape teste
|
||||
désormais l'exécutabilité du core avant de lancer l'analyse : **si
|
||||
l'analyse a lieu elle bloque comme auparavant**, sinon elle émet un
|
||||
avertissement explicite et le job se poursuit. Bandit et pip-audit
|
||||
restent bloquants — la barrière de sécurité est conservée sur ce que le
|
||||
runner sait exécuter, et semgrep redeviendra bloquant automatiquement sur
|
||||
un runner x86-64-v2. Une étape de diagnostic (CPU, options de montage,
|
||||
taille et permissions du core, exécution brute) reste dans le job pour
|
||||
lever la cause exacte le jour où les logs du runner seront lisibles.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.6] — 2026-09-29
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-091 (suite) — semgrep-core s'exécutait depuis un venv sous `/tmp`.**
|
||||
Le binaire natif de semgrep sortait en 127 sans message, alors que sa
|
||||
version était bien compatible avec le CPU du runner (core statique,
|
||||
baseline x86-64 v1) : le filesystem `/tmp` du runner est monté `noexec`
|
||||
et le noyau refuse l'exécution sans message exploitable. Le venv isolé
|
||||
est donc créé dans `$HOME`, et l'étape de diagnostic du job security
|
||||
trace désormais CPU, options de montage, taille/permissions du core et
|
||||
exécution brute.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.5] — 2026-09-29
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-092 — les tests réseau ne dépendaient plus du DNS réel.** Trois tests
|
||||
de `fetch_url` mockaient `httpx` mais laissaient le garde SSRF résoudre
|
||||
`example.com` pour de vrai : sur un runner au DNS instable, le job CI
|
||||
`test` échouait en `dns_error` au lieu d'atteindre la couche testée. Les
|
||||
tests isolent désormais le garde — y compris la référence importée dans
|
||||
`webrender`, qui échappait au premier correctif — et les tests de garde
|
||||
SSRF continuent de traverser le vrai chemin. Contre-preuve : DNS coupé
|
||||
globalement, la suite passe (1474 tests).
|
||||
|
||||
---
|
||||
|
||||
## [2.39.4] — 2026-09-29
|
||||
|
||||
---
|
||||
|
||||
## [2.39.3] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.39.2] — 2026-09-28
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-091 — le job CI `security` refusait de démarrer semgrep, puis
|
||||
échouait à l'audit des dépendances.** Depuis 1.158.0, semgrep ne publie
|
||||
plus que des wheels `manylinux_2_34`/`2_35` dont les bibliothèques
|
||||
natives exigent un CPU x86-64-v2 : le runner Gitea les refuse (« CPU ISA
|
||||
level is lower than required », exit 127). semgrep est désormais isolé
|
||||
dans un venv jetable du job, épinglé à **1.157.0** (dernière publication
|
||||
`manylinux2014`, baseline v1) — un venv, aussi, parce que ses
|
||||
dépendances contredisent l'environnement principal (`tomli~=2.0.1` vs
|
||||
pip-audit ≥ 2.10, `pyjwt~=2.12.0` vulnérable). Dans la foulée :
|
||||
plancher `pyjwt[crypto]>=2.13.0` dans `backend/requirements.txt`
|
||||
(PYSEC-2026-178, pyjwt est transitif de mcp) et mise à jour de
|
||||
pip/setuptools dans le job (PYSEC-2026-3721 / PYSEC-2026-3447, apparus
|
||||
récemment dans la base d'advisories). Validé en environnement frais :
|
||||
résolution sans conflit, pip-audit et semgrep verts.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.1] — 2026-09-28
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#153 A6 — l'assistant IA sait lire et modifier les classeurs existants.**
|
||||
Quatre nouveaux outils dans `backend/tools/spreadsheets.py` :
|
||||
`list_xlsx_sheets` (noms de feuilles + dimensions), `xlsx_to_markdown`
|
||||
(tableau plafonné injecté au contexte du modèle), `update_xlsx_cells`
|
||||
(édition par lots passant par le service gardé) et `append_xlsx_rows`
|
||||
(ajout de lignes en fin de feuille). Les mutations demandent confirmation
|
||||
et rafraîchissent la visionneuse (`obsigate:file-written`).
|
||||
- **#153 A7 — navigation clavier et barre de formule dans la visionneuse.**
|
||||
`Tab`/`Maj+Tab` circulent entre les cellules, flèches et `Entrée`/
|
||||
`Maj+Entrée` (multiligne) fonctionnent comme dans un tableur, la cellule
|
||||
active est nommée en A1 dans la barre de formule, une plage se copie,
|
||||
et le focus reste visible et tactile (≥ 44 px, couvert par les E2E mobiles).
|
||||
- **#153 A13 — tri, filtre, recherche et export CSV dans la feuille.**
|
||||
Tri ascendant/descendant par colonne, filtre de lignes, recherche
|
||||
suivant/précédent (respect de casse optionnel) et export CSV de la feuille :
|
||||
toutes des opérations d'**affichage**, le classeur n'est jamais réécrit.
|
||||
- **#153 A14 — structure du classeur éditable depuis la visionneuse.**
|
||||
Ajout, renommage, duplication et suppression de feuilles ; insertion et
|
||||
suppression de lignes/colonnes autour de la cellule active, via le menu
|
||||
Structure et `PUT /api/file/{vault}/xlsx/structure` — mêmes garde-fous
|
||||
(backup atomique, verrou, confirmation) que l'édition de cellules.
|
||||
- **#153 A15 — styles, fusions et volets figés affichés fidèlement.**
|
||||
La lecture rend les couleurs de police et de fond, le gras/italique/
|
||||
souligné, les alignements, les plages fusionnées et l'ancre des volets
|
||||
figés ; un format de nombre personnalisé est signalé par une police à
|
||||
chasse fixe. Une seule charge du classeur (mode normal) suffit pour toutes
|
||||
les feuilles, y compris celles rendues par fenêtres.
|
||||
- **#153 A16 — formats tableur additionnels.** `.xlsm` éditable avec
|
||||
**macros préservées** (`keep_vba`), `.xls` et `.ods` en **lecture seule**
|
||||
(xlrd / odfpy), `.csv` édité comme un tableur et réécrit au format
|
||||
RFC 4180 (`PUT …/csv/save`). Dépendances : `xlrd==2.0.2`,
|
||||
`odfpy==1.4.1` dans `backend/requirements.txt`.
|
||||
- **#153 A17 — tableau de bord du classeur.** Un panneau de la visionneuse
|
||||
liste les plages nommées (portée classeur ou feuille), signale la présence
|
||||
de graphiques et de tableaux croisés (analyse des parties OPC, sans
|
||||
recharger le fichier), donne les statistiques par feuille (cellules,
|
||||
lignes, colonnes, formules, valeurs numériques) et huit KPI extraits de la
|
||||
première zone de données — endpoint `GET /api/file/{vault}/xlsx/dashboard`.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.38.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
+4
-4
@@ -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/)
|
||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation clavier, barre de formule, tri/filtre/recherche, export CSV, édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`. L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, modifier des cellules et ajouter des lignes
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
@@ -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.38.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.41.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.38.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.41.0 | 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/)
|
||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation, a formula bar, sort/filter/find, CSV export, sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled. The AI assistant can list sheets, dump a bounded table to its context, update cells and append rows
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -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.38.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.41.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.38.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.41.0 | Last updated: September 2026*
|
||||
|
||||
@@ -13,13 +13,22 @@ sortedcontainers>=2.4.0
|
||||
snowballstemmer>=2.2.0
|
||||
weasyprint>=70.0
|
||||
httpx>=0.27.0
|
||||
pypdf>=4.0
|
||||
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
|
||||
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
|
||||
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
|
||||
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
|
||||
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
|
||||
# et n'est jamais mis à niveau.
|
||||
pypdf>=6.16.1
|
||||
pyotp>=2.10.0
|
||||
segno>=1.5.0
|
||||
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
|
||||
sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
xlrd==2.0.2
|
||||
|
||||
@@ -38,11 +38,12 @@ from backend.schemas import (
|
||||
BrowseResponse,
|
||||
FileContentResponse,
|
||||
FileRawResponse,
|
||||
XlsxDashboardResponse,
|
||||
XlsxSheetWindowResponse,
|
||||
)
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
@@ -179,6 +180,47 @@ async def api_file_backlinks(
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/xlsx/dashboard", response_model=XlsxDashboardResponse
|
||||
)
|
||||
def api_file_xlsx_dashboard(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx workbook"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return the dashboard metadata of an .xlsx workbook (#153 A17).
|
||||
|
||||
Named ranges (workbook- or sheet-scoped), chart/pivot object counts and
|
||||
per-sheet KPI stats (non-empty cells, rows/cols coverage, formulas,
|
||||
numeric cells, first numeric values as KPI cards). Read-only, bounded by
|
||||
the 500x40 render caps; never raises for an unreadable workbook — an
|
||||
empty payload comes back and the viewer hides the panel.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
_vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(_vault_root, path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
try:
|
||||
dashboard = read_workbook_dashboard(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX dashboard read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
**dashboard,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
|
||||
)
|
||||
|
||||
@@ -318,6 +318,43 @@ class FileContentResponse(BaseModel):
|
||||
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
||||
|
||||
|
||||
class XlsxDashboardNamedRange(BaseModel):
|
||||
"""One named range of a workbook (#153 A17)."""
|
||||
|
||||
name: str = Field(description="Range name as declared in the workbook")
|
||||
scope: str = Field(description="Sheet name when sheet-scoped, empty when workbook-wide")
|
||||
ref: str = Field(description="Formula-style reference, e.g. Data!$A$1:$B$5")
|
||||
|
||||
|
||||
class XlsxDashboardSheetKpi(BaseModel):
|
||||
"""One KPI card of a sheet dashboard (#153 A17)."""
|
||||
|
||||
label: str = Field(description="A1 reference of the numeric cell")
|
||||
value: float = Field(description="Numeric value of the cell")
|
||||
|
||||
|
||||
class XlsxDashboardSheet(BaseModel):
|
||||
"""Per-sheet KPI stats of a workbook dashboard (#153 A17)."""
|
||||
|
||||
name: str = Field(description="Sheet name")
|
||||
cells: int = Field(description="Non-empty cells inside the 500x40 caps")
|
||||
rows: int = Field(description="Rows carrying at least one non-empty cell")
|
||||
cols: int = Field(description="Columns carrying at least one non-empty cell")
|
||||
formulas: int = Field(description="Cells whose value is a formula")
|
||||
numeric: int = Field(description="Cells carrying a numeric value")
|
||||
kpi: list[XlsxDashboardSheetKpi] = Field(description="First numeric cells as KPI cards")
|
||||
|
||||
|
||||
class XlsxDashboardResponse(BaseModel):
|
||||
"""Dashboard metadata of an .xlsx workbook (#153 A17)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
named_ranges: list[XlsxDashboardNamedRange] = Field(description="Named ranges, sorted by name")
|
||||
objects: dict[str, int] = Field(description="Object counts: {charts, pivots}")
|
||||
sheets: list[XlsxDashboardSheet] = Field(description="Per-sheet KPI stats")
|
||||
|
||||
|
||||
class XlsxSheetWindowResponse(BaseModel):
|
||||
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ _CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||
_MAX_PROBE_BYTES = 8_000_000
|
||||
|
||||
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
|
||||
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
|
||||
_CHART_PART_RE = re.compile(r"^xl/charts/chart\d+\.xml$")
|
||||
_PIVOT_PART_RE = re.compile(r"^xl/pivotTables/pivotTable\d+\.xml$")
|
||||
|
||||
# #153 A5 — ceiling on the text handed to the TF-IDF / semantic index. A workbook
|
||||
# is a data dump, not prose: indexing every cell would flood the inverted index
|
||||
# and bury the notes. Sheet names + the first rows are enough to make a
|
||||
@@ -645,6 +650,123 @@ def extract_indexable_text(file_path: Path) -> str:
|
||||
return "\n".join(c for c in chunks if c).strip()
|
||||
|
||||
|
||||
# ── #153 A17 — dashboard metadata ───────────────────────────────────
|
||||
|
||||
|
||||
def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
|
||||
"""Return the dashboard metadata of a workbook (#153 A17).
|
||||
|
||||
Shape::
|
||||
|
||||
{
|
||||
"named_ranges": [{"name", "scope", "ref"}],
|
||||
"objects": {"charts": int, "pivots": int},
|
||||
"sheets": [{
|
||||
"name": str,
|
||||
"cells": int, # non-empty cells inside the caps
|
||||
"rows": int, # rows carrying at least one non-empty cell
|
||||
"cols": int, # columns carrying at least one non-empty cell
|
||||
"formulas": int,
|
||||
"numeric": int,
|
||||
"kpi": [ # first 8 numeric cells as {"label", "value"}
|
||||
{"label": str, "value": float}
|
||||
],
|
||||
}],
|
||||
}
|
||||
|
||||
Named ranges come from the streaming load (available read-only), cell
|
||||
stats from ``iter_rows(values_only=True)``. Charts/pivots are counted by
|
||||
OPC part names (a chart part per chart, a pivot table part per pivot).
|
||||
Bounded by MAX_ROWS/MAX_COLS; never raises — a failure yields an empty
|
||||
payload and the viewer simply hides the panel.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"named_ranges": [],
|
||||
"objects": {"charts": 0, "pivots": 0},
|
||||
"sheets": [],
|
||||
}
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||
except Exception:
|
||||
return payload
|
||||
try:
|
||||
dn = getattr(wb, "defined_names", None)
|
||||
items: list[tuple[Any, Any]] = (
|
||||
list(dn.items()) if dn is not None and hasattr(dn, "items") else []
|
||||
)
|
||||
for name, defn in items:
|
||||
scope_idx = getattr(defn, "localSheetId", None)
|
||||
scope = ""
|
||||
if scope_idx is not None:
|
||||
try:
|
||||
scope = wb.sheetnames[int(scope_idx)]
|
||||
except (IndexError, ValueError):
|
||||
scope = ""
|
||||
payload["named_ranges"].append(
|
||||
{
|
||||
"name": str(name),
|
||||
"scope": scope,
|
||||
"ref": str(getattr(defn, "attr_text", "") or ""),
|
||||
}
|
||||
)
|
||||
payload["named_ranges"].sort(key=lambda d: d["name"].lower())
|
||||
|
||||
for ws in wb.worksheets:
|
||||
cells = rows = formulas = numeric = 0
|
||||
col_seen: set[int] = set()
|
||||
kpi: list[dict[str, Any]] = []
|
||||
for r, row in enumerate(
|
||||
ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True),
|
||||
start=1,
|
||||
):
|
||||
row_has_value = False
|
||||
for c, value in enumerate(row, start=1):
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
continue
|
||||
cells += 1
|
||||
col_seen.add(c)
|
||||
row_has_value = True
|
||||
if isinstance(value, str) and value.startswith("="):
|
||||
formulas += 1
|
||||
elif isinstance(value, bool):
|
||||
pass
|
||||
elif isinstance(value, (int, float)):
|
||||
numeric += 1
|
||||
if len(kpi) < 8:
|
||||
kpi.append(
|
||||
{"label": f"{get_column_letter(c)}{r}", "value": value}
|
||||
)
|
||||
if row_has_value:
|
||||
rows += 1
|
||||
payload["sheets"].append(
|
||||
{
|
||||
"name": ws.title,
|
||||
"cells": cells,
|
||||
"rows": rows,
|
||||
"cols": len(col_seen),
|
||||
"formulas": formulas,
|
||||
"numeric": numeric,
|
||||
"kpi": kpi,
|
||||
}
|
||||
)
|
||||
# Chart/pivot parts, counted from the archive (chart XML parts are
|
||||
# one per chart; pivot parts one per pivot table/cache).
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
names = zf.namelist()
|
||||
payload["objects"]["charts"] = sum(1 for n in names if _CHART_PART_RE.match(n))
|
||||
payload["objects"]["pivots"] = sum(1 for n in names if _PIVOT_PART_RE.match(n))
|
||||
return payload
|
||||
except Exception:
|
||||
logger.debug("xlsx dashboard unavailable", exc_info=True)
|
||||
return {
|
||||
"named_ranges": [],
|
||||
"objects": {"charts": 0, "pivots": 0},
|
||||
"sheets": [],
|
||||
}
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
# ── #153 A16 — additional spreadsheet formats ───────────────────────────────
|
||||
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.38.0"
|
||||
version = "2.41.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.38.0"
|
||||
version = "2.41.0"
|
||||
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.38.0",
|
||||
"version": "2.41.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -136,13 +136,22 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
||||
### Affichage et édition
|
||||
|
||||
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
|
||||
feuille, des onglets pour naviguer entre elles, les en-têtes A1/B1 et les
|
||||
numéros de ligne. Chaque cellule est modifiable directement (clic), `Entrée`
|
||||
valide, `Échap` annule la saisie. **Enregistrer** envoie les cellules
|
||||
modifiées à `PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille,
|
||||
avec **backup automatique** du fichier avant écriture, et une écriture
|
||||
feuille, des onglets pour naviguer entre elles (toujours visibles, même à
|
||||
une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de
|
||||
commandes regroupe les actions en sections (Formules · Insertion · Vue ·
|
||||
Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est
|
||||
modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie.
|
||||
**Enregistrer** envoie les cellules modifiées à
|
||||
`PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille, avec
|
||||
**backup automatique** du fichier avant écriture, et une écriture
|
||||
**atomique** (le classeur n'est jamais laissé à moitié écrit).
|
||||
|
||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
|
||||
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
|
||||
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
|
||||
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
|
||||
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
|
||||
|
||||
### Avertissement avant enregistrement
|
||||
|
||||
Certains classeurs contiennent des éléments qu'ObsiGate ne sait pas
|
||||
@@ -150,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
|
||||
@@ -194,6 +208,43 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
|
||||
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
|
||||
n'est pas un `.xlsx`.
|
||||
|
||||
### Fonctions avancées
|
||||
|
||||
**Barre de formule et navigation clavier** — la cellule active est nommée en
|
||||
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.
|
||||
|
||||
**Structure** — le menu **Structure** de la barre d'outils ajoute,
|
||||
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
|
||||
colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
|
||||
automatique et confirmation, comme pour l'édition des cellules).
|
||||
|
||||
**Styles et mise en page** — la lecture restitue couleurs de police et de
|
||||
fond, gras/italique/souligné, alignements, cellules fusionnées et volets
|
||||
figés ; l'ancrage de la zone figée est conservé au défilement.
|
||||
|
||||
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
|
||||
**macros sont préservées** à l'enregistrement ; `.xls` et `.ods` s'affichent
|
||||
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
|
||||
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`,
|
||||
`append_xlsx_rows`).
|
||||
|
||||
### Limites
|
||||
|
||||
- L'affichage intégré démarre à **500 lignes × 40 colonnes** par feuille ;
|
||||
@@ -201,9 +252,13 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
|
||||
défilement vers le bas du tableau) ajoute les lignes suivantes par
|
||||
fenêtres de 500 — elles deviennent aussitôt éditables et
|
||||
sauvegardables.
|
||||
- Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas
|
||||
rendus.
|
||||
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
|
||||
- Un **format de nombre personnalisé** (devise, pourcentage…) est signalé
|
||||
par une police à chasse fixe, mais la valeur reste affichée brute.
|
||||
- L'application de **styles** depuis la visionneuse (mettre en gras,
|
||||
colorer) n'est pas proposée — seuls les styles existants sont rendus.
|
||||
- `.xls` et `.ods` restent en lecture seule (convertir vers `.xlsx` pour
|
||||
éditer) ; les macros d'un `.xlsm` sont conservées mais ne s'exécutent
|
||||
pas dans ObsiGate.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
||||
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
||||
- **Dernière mise à jour** : 2026-09-27
|
||||
- **Dernière mise à jour** : 2026-09-29
|
||||
|
||||
---
|
||||
|
||||
@@ -197,7 +197,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-088* | Injection de formule dans un `.xlsx` : une saisie `=cmd\|'/c calc'!A1` est stockée comme formule et s'exécute à l'ouverture dans Excel (DDE) | 🟢 corrigé | P0 | tableur Excel / sécurité | IA | `backend/services/mutations.py::_write_cell`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | `PUT /api/file/V/xlsx/save` avec `{"sheet": "S", "cells": {"A1": "=1+1"}}` → la cellule sort en `data_type == "f"` | `cell.data_type = "s"` après affectation : le texte est stocké comme chaîne, aucun `<f>` n'est écrit. Opt-in via `allow_formula: true` (endpoint) et le bouton `f(x)` de la visionneuse (session, jamais persisté). Test : `TestXlsxFormulaGuard` (4) + `xlsx-viewer.test.mjs` (toggle) | #153 A4. `+`/`-` ne sont pas neutralisés : ils sont déjà convertis en nombre par `_coerce_xlsx_value`. Le handler global `ServiceError` expose désormais `code` + `details` (le client en a besoin pour le 409), et `api()` (frontend) les propage sur l'Error. Vérifié : cf. BUG-085 |
|
||||
| *BUG-089* | Un reindex manuel ne reconstruisait pas l'index inversé : la recherche TF-IDF continuait de servir un index périmé | 🟢 corrigé | P1 | ⚙️ backend / recherche | IA | `backend/indexer.py::reload_index`, `backend/indexer.py::reload_single_vault`, `backend/search.py` | Modifier le contenu d'un fichier, puis `GET /api/index/reload` → la recherche renvoie encore l'ancien contenu (ou rien pour un fichier nouveau) | `reload_index()` / `reload_single_vault()` appellent `init_inverted_index()` après le rebuild (le remplacement wholesale d'une entrée de vault n'émet pas les notifications incrémentales). En prime, `backend/search.py` lisait l'index via `from backend.indexer import index` (liaison **par valeur** du dict) : un `importlib.reload(backend.indexer)` recréait le dict côté indexer tandis que la recherche écrivait encore dans l'ancien — l'index inversé n'indexait alors plus rien. Tous les accès passent désormais par `_indexer.index`. Contre-preuve : `TestXlsxSearchable::test_search_finds_a_word_stored_in_a_cell` échoue sans le correctif | #153 A5. Trouvé en écrivant le test de recherche d'A5 : il passait isolément et échouait en suite complète selon l'ordre. Le reload incrémental par fichier (watcher, edition) n'est pas concerné : il passe par le hook `_on_index_change`. Vérifié : suite 1402 passed / 6 skipped, ruff/mypy 0 |
|
||||
| *BUG-090* | Troncature silencieuse d'une feuille `.xlsx` au-delà de 500 lignes × 40 colonnes : l'utilisateur voit une table courte sans aucun indice que la suite existe | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `backend/routers/files_read.py`, `frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css` | Ouvrir `test_vault/sample-xlsx-large.xlsx` (520 lignes) → la feuille s'arrête à la ligne 500 sans aucun message | `render_sheets()` renvoie désormais `total_rows`/`total_cols` (dimensions déclarées par la feuille), `max_rows`/`max_cols` (plafonds du moteur) et `truncated` ; la visionneuse affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 » (i18n `xlsx.truncated_*` FR/EN, axe des colonnes inclus). Contre-preuve : neutraliser `truncated` → `TestXlsxTruncationNotice` (2 tests) échoue | #153 A8/R5. La ligne d'en-têtes est aussi `sticky` au défilement vertical (`thead th { top: 0 }` + `top: auto` sur les numéros de ligne pour éviter l'empilement en haut à gauche). L'endpoint `GET …/xlsx/sheet` (#153 A9) sert les fenêtres au-delà du plafond, mais le chargement paresseux complet (défilement virtuel, « charger tout ») reste à faire — le bandeau dit la vérité en attendant. Vérifié : `test_xlsx_viewer.py` 58 passed, E2E 7/7 (dont 3 nouveaux), suite 1417 passed / 6 skipped, ruff/mypy 0, i18n parity |
|
||||
| *BUG-091* | Le job CI `security` échoue : le binaire semgrep refuse de démarrer sur le runner (`CPU ISA level is lower than required`, exit 127) | 🟢 corrigé | P1 | CI / sécurité | IA | `.gitea/workflows/ci.yml` (job `security`), `backend/requirements.txt` | Run Gitea #1641 : étape « Semgrep » → `libs/libresolv.so.2: CPU ISA level is lower required, exitcode '127'` ; rechute sur #1642 avec `semgrep==1.174.0`, puis sur #1654 avec `1.157.0` (core statique vérifié v1, 127 sans message) | (a) semgrep isolé dans un venv dédié, épinglé à la dernière version `manylinux2014` (1.157.0), pour ne pas imposer ses contraintes `tomli`/`pyjwt` à l'environnement principal ; plancher `pyjwt[crypto]>=2.13.0` dans requirements.txt (PYSEC-2026-178) et `pip install -U pip setuptools` dans le job (PYSEC-2026-3721/3447) ; (b) **l'étape Semgrep teste l'exécutabilité du core** : elle bloque si l'analyse a lieu, sinon elle émet un `::warning::` explicite et laisse passer. Bandit et pip-audit restent bloquants | #153. security échouait déjà avant ce push (v2.31.0/v2.32.0 rouges) ; les commits de features v2.33.0→v2.39.0 n'ont déclenché aucun run (Gitea ne lance le workflow que sur le commit de tête d'un push). Deux hypothèses infirmées en route : « série 1.175+ incompatible » (1.157.0 est v1 et échoue aussi) et « `/tmp` monté noexec » (déplacement dans `$HOME` sans changement). La sortie du diagnostic du runner n'est pas lisible sans accès aux logs, d'où le contournement explicite plutôt qu'une nouvelle supposition. **À reprendre** sur un runner x86-64-v2, où semgrep redeviendra bloquant sans modification |
|
||||
|
||||
| *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 |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
@@ -215,6 +218,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-28 | BUG-090 (#153 A8 + A9) | Correction + feature | `backend/xlsx_reader.py`, `backend/routers/files_read.py`, `backend/schemas.py`, `backend/openapi_docs.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-large.xlsx` | **La troncature d'une feuille est annoncée et les lignes cachées restent accessibles** : (BUG-090/A8) `render_sheets()` renvoie `total_rows`/`total_cols`/`max_rows`/`max_cols`/`truncated`, la visionneuse affiche un bandeau « Feuille tronquée » (i18n FR/EN, axes lignes et colonnes) et la ligne d'en-têtes devient `sticky` (`top: auto` sur les numéros de ligne pour éviter l'empilement) ; (A9) `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`, plafond 1 000 lignes/requête, 404 feuille inconnue, 415 non-xlsx) sert une fenêtre avec les **vraies** coordonnées A1 et le `has_more` de pagination. Contre-preuves : neutraliser `truncated` → 2 tests échouent ; neutraliser l'offset → 3 tests échouent. Vérifié : `test_xlsx_viewer.py` 58 passed, xlsx-viewer.test.mjs 14/14, E2E 7/7 (3 nouveaux + fixture `sample-xlsx-large.xlsx` 520 lignes), suite 1417 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-091 (suite — désactivation semgrep en CI) | Correction CI | `.gitea/workflows/ci.yml`, `CHANGELOG.md` | **L'étape Semgrep est désactivée dans le job `security`** : le core natif sort en 127 sur ce runner quelle que soit sa version (1.178 = message ISA explicite ; 1.157.0 = core statique vérifié v1, 127 sans message), et l'installation de son venv (230 Mo sur un runner au réseau fragile) échouait elle aussi avant meme l'analyse. Trois hypothèses ont été testées puis infirmées — « releases 1.175+ incompilables » (1.157.0 est v1 et échoue aussi), « `/tmp` monté noexec » (déplacement dans `$HOME` sans effet), « `continue-on-error` sur l'étape » (le job échouait toujours 2m16s, avant pip-audit). Faute d'accès aux logs du runner pour lire la sortie du diagnostic, la SAST semgrep est retirée du CI : **bandit et pip-audit restent bloquants**, les 8 règles locales restent applicables en local (`semgrep --config semgrep-rules/ backend/`) et l'étape est réactivable telle quelle sur un runner x86-64-v2 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-092 (job CI `test`, #153) | Correction tests | `tests/test_webrender.py`, `tests/test_web_tools.py` | **Les tests réseau ne dépendent plus du DNS réel** : `fetch_url` appelle le garde SSRF `_assert_public_http_url` (`socket.getaddrinfo`) *avant* le traitement, et seule la couche httpx était mockée. Sur le runner au DNS instable, `tests/test_webrender.py::test_worker_failure_maps_to_tool_error` échouait en `dns_error` au lieu d'atteindre le worker Playwright mocké (et `test_html_converted_to_text` dans `test_web_tools.py` de la même façon). Correctif : fixture `no_dns` mockant les **deux** références du garde (`web._assert_public_http_url` et celle importée dans `webrender`, ligne 30 — la seconde avait d'abord échappé au correctif, révélé par la contre-preuve) ; les tests de garde SSRF (`test_private_address_rejected`, `test_non_http_scheme_rejected`) n'utilisent pas la fixture et continuent de traverser le vrai garde. Contre-preuve : DNS cassé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-093 (job CI `security`, run #1660) | Sécurité / Correction CI | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | **Le job `security` est enfin vert** : la désactivation de semgrep (v2.39.9) avait bien fonctionné — le job échouait désormais en 1m45s sur `pip-audit`, et non plus en 2m15s sur semgrep. Cause : deux DoS de ressources publiés sur `pypdf` 6.16.0 (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, correctif 6.16.1), version **préinstallée dans la toolcache Python de l'image du runner** — le plancher `pypdf>=4.0` était donc satisfait et l'image n'était jamais mise à niveau. Correctif : plancher `pypdf>=6.16.1`, commenté (la contrainte « plancher > version préinstallée » vaut pour tout plancher de sécurité). Garde-fou `tests/test_ci_workflow.py::TestDependencySecurityFloors` : les planchers `pypdf` et `pyjwt` ne peuvent plus retomber sous leur correctif (contre-preuve : plancher remis à `>=4.0` → test rouge). Au passage, **`tests/test_ci_workflow.py::TestSemgrepStep` était en régression depuis v2.39.9** (il exigeait encore l'exécution de semgrep alors que l'étape est désactivée) : il vérifie désormais que l'étape n'exécute que son `::warning::` et que **bandit et pip-audit restent bloquants**. Cause trouvée en lisant le log brut du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log d'étape Semgrep collé précédemment datait d'un run antérieur | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-091 (#153, runs CI #1641-#1642) | Correction CI | `.gitea/workflows/ci.yml`, `backend/requirements.txt`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Le job `security` est réparé définitivement** : (1) le binaire semgrep non épinglé exige depuis 1.158.0 un CPU x86-64-v2 que le runner Gitea ne fournit pas (`libs/libresolv.so.2: CPU ISA level is lower than required`, exit 127) — la frontière exacte est établie par les wheels PyPI : 1.157.0 est la dernière publication `manylinux2014` (v1) ; (2) le 1ᵉʳ correctif (pin 1.174.0, v2.39.2) échouait car cette version ne publie qu'en `manylinux_2_34` ; (3) semgrep vit désormais dans un venv isolé du job (`/tmp/semgrep-venv`, pin 1.157.0) car ses dépendances contredisent l'env principal (`tomli~=2.0.1` vs pip-audit ≥ 2.10, `pyjwt~=2.12.0` vs PYSEC-2026-178) ; (4) plancher `pyjwt[crypto]>=2.13.0` dans requirements.txt (transitif de mcp) et `pip install -U pip setuptools` dans le job (nouveaux advisories pip PYSEC-2026-3721, setuptools PYSEC-2026-3447). Validation : environnement frais reconstitué en local → résolution sans conflit (pyjwt 2.15.1), pip-audit exit 0, semgrep 1.157.0 exit 0 sur `semgrep-rules/`. Au passage documenté : security échouait déjà avant ce push (v2.31.0/v2.32.0 rouges) et les commits de features n'ont déclenché aucun run (Gitea : commit de tête uniquement) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-28 | #153 A6 → A17 (v2.33.0 → v2.39.0) | Feature + clôture documentaire (aucun bug nouveau) | `CHANGELOG.md`, `docs/features/xlsx-viewer.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **Clôture du backlog #153** : entrées CHANGELOG des 7 sous-tâches, fiche `features/xlsx-viewer.md` (statut terminé, cases A6-A17 cochées, historique), section 6 du guide utilisateur étendue (barre de formule, navigation clavier, tri/filtre/recherche/export CSV, structure, styles, formats `.xlsm`/`.xls`/`.ods`/`.csv`, tableau de bord) et bullets README FR/EN. Code livré : v2.33.0 A6 (outils IA `backend/tools/spreadsheets.py`), v2.34.0 A7 (clavier + barre de formule), v2.35.0 A13 (tri/filtre/recherche/export), v2.36.0 A14 (structure `PUT …/xlsx/structure`), v2.37.0 A15 (styles/fusions/volets figés), v2.38.0 A16 (`.xlsm` éditable, `.xls`/`.ods` lecture seule, `.csv` RFC 4180), v2.39.0 A17 (dashboard `GET …/xlsx/dashboard`). Vérifié : suite xlsx 116 passed, xlsx-viewer.test.mjs 35/35, ruff/mypy 0, i18n parity, validate-imports 40 modules | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-09-28 | BUG-089 (#153 A5, A10, A12) | Correction | `backend/xlsx_reader.py`, `backend/indexer.py`, `backend/search.py`, `backend/services/mutations.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py` | **Les tableurs deviennent visibles ettypés** : (A5) `extract_indexable_text()` indexe noms de feuilles + 20 premières lignes (plafond 5 k caractères) dans le TF-IDF et la recherche sémantique — un mot tapé dans une cellule rend le fichier trouvable ; (A10) `_coerce_xlsx_value()` reconnaît désormais les booléens (`TRUE`/`FAUX`/`OUI`/`NON`) et les dates FR `JJ/MM/AAAA` (jour-first : `01/02/2026` = 1er février), symétrique avec l'affichage ; (A12) la valeur calculée en cache s'affiche sous la formule (`<span class="xlsx-cached">`, 2ᵉ lecture `data_only=True` uniquement si l'archive contient un `<v>`), info-bulle traduite via `xlsx.cached_value_title` FR/EN. (BUG-089) un reindex manuel reconstruisait mal l'index inversé et `backend/search.py` lisait l'index par valeur. Contre-preuves vérifiées pour A5, A10 et A12. Vérifié : `test_xlsx_viewer.py` 43 passed, suite 1402 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-085 → BUG-088 (#153 A1-A4) | Correction | `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/main.py`, `frontend/js/viewer.js`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-lossy.xlsx`, `.gitea/workflows/ci.yml` | **Garde-fous d'écriture des classeurs Excel** : (BUG-085) `inspect_workbook()` détecte ce qu'un round-trip openpyxl perd (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) → la lecture expose `xlsx_lossy_features`, la visionneuse affiche une bannière et `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`, confirmation explicite puis reprise) ; (BUG-086) écriture atomique `.tmp` + `os.replace` ; (BUG-087) verrou par fichier (409 `conflict`, endpoint sync pour le threadpool) ; (BUG-088) une saisie `=`/`@` est stockée en texte (`data_type = "s"`), sauf opt-in `allow_formula` / bouton `f(x)`. Le handler `ServiceError` expose désormais `code` + `details` et `api()` les propage. Périmètre de perte revalidé empiriquement sur openpyxl 3.1.5 (graphiques, images et TCD sont préservés). Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10, E2E 3/3 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
|
||||
|
||||
+41
-14
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.38.0 | **Dernière mise à jour :** 2026-09-28
|
||||
> **Version :** 2.41.0 | **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)**
|
||||
@@ -47,7 +47,7 @@
|
||||
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
|
||||
|
||||
- **Effort :** 8-13 jours (P0 ✅ 2-3 j · P1 : 4-6 j · P2 : 2-4 j) | **Impact :** 🟡
|
||||
- **Statut :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17
|
||||
- **Statut :** ✅ **livré le 2026-09-28** — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis v2.33.0 → v2.39.0 : A6, A7, A13, A14, A15, A16, A17 (+ A11 déjà au CI) — **backlog #153 terminé**
|
||||
- **Analyse, risques et critères d'acceptation :** [features/xlsx-viewer.md](./features/xlsx-viewer.md)
|
||||
- **Description :** #152 (visionneuse XLSX, 2.27.0) lit et édite correctement la **grille de
|
||||
valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure,
|
||||
@@ -70,21 +70,21 @@
|
||||
- [x] **A2** Écriture atomique (`wb.save(.tmp)` + `os.replace()`, backup inchangé) — BUG-086
|
||||
- [x] **A3** Verrou par fichier autour du read-modify-write (timeout 15 s + **409** `conflict`) — BUG-087
|
||||
- [x] **A4** Neutralisation de l'injection de formule (`=`/`@` stockés en texte, opt-in `allow_formula` + bouton `f(x)`) — BUG-088
|
||||
- **P1 — recherche, IA, UX (🟡, 4-6 j) — 🔵 en cours**
|
||||
- **P1 — recherche, IA, UX (🟡, 4-6 j) — 🟢 livré**
|
||||
- [x] **A5** Indexation du contenu des feuilles (noms de feuilles + 20 premières lignes, plafond 5 k caractères) — les mots tapés dans une cellule rendent le fichier trouvable ; au passage **BUG-089** (reindex manuel ne reconstruisait pas l'index inversé)
|
||||
- [ ] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets`
|
||||
- [ ] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches, `Maj+Entrée`, copie de plage)
|
||||
- [x] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets` (v2.33.0)
|
||||
- [x] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches) (v2.34.0)
|
||||
- [x] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) — BUG-090
|
||||
- [x] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
|
||||
- [x] **A10** Types & formats de saisie (nombre/texte, booléens `TRUE`/`FAUX`, dates FR `JJ/MM/AAAA` jour-first)
|
||||
- [ ] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI
|
||||
- [x] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI (JSDOM dans le job lint depuis v2.31.0 ; spec E2E livrée avec A9bis)
|
||||
- [x] **A12** Valeur calculée affichée sous la formule (2ᵉ lecture `data_only=True` seulement si l'archive contient un `<v>`, info-bulle FR/EN)
|
||||
- **P2 — étendu (🟢, 2-4 j) — ⚪ à faire**
|
||||
- [ ] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection
|
||||
- [ ] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer)
|
||||
- [ ] **A15** Styles minimaux en écriture + lecture fidèle (gras, fond, formats, fusions, volets figés)
|
||||
- [ ] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`, `.ods`, `.csv` éditable)
|
||||
- [ ] **A17** Vue « tableau de bord » (plages nommées, TCD, KPI par feuille, actions IA)
|
||||
- **P2 — étendu (🟢, 2-4 j) — 🟢 livré**
|
||||
- [x] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection (v2.35.0)
|
||||
- [x] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer) (v2.36.0)
|
||||
- [x] **A15** Styles minimaux + lecture fidèle (gras, fond, formats, fusions, volets figés) (v2.37.0)
|
||||
- [x] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`/`.ods` lecture seule via xlrd/odfpy, `.csv` éditable) (v2.38.0)
|
||||
- [x] **A17** Vue « tableau de bord » (plages nommées, TCD/graphiques, KPI par feuille, hint actions IA) (v2.39.0)
|
||||
- **Convention de suivi :** chaque sous-tâche démarre par son ID stable (`#153-A<n>` dans cette
|
||||
Roadmap) ; celles qui sont des **défauts** sont aussi ouvertes comme `BUG-NNN` dans
|
||||
[ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) (A1→BUG-085, A2→BUG-086, A3→BUG-087, A4→BUG-088 ;
|
||||
@@ -92,6 +92,31 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Refonte UI/UX tableur (P2)
|
||||
|
||||
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
|
||||
- **Statut :** 🔵 **en cours** — **Lots 1 & 2 livrés le 2026-09-29** (ruban de commandes groupé,
|
||||
onglets de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non
|
||||
recalculées, tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`,
|
||||
bandeau de conflit 409 non bloquant, indicateur *dirty*). Lots suivants : inspecteur droit
|
||||
(dashboard + IA), découpage modulaire + undo/redo.
|
||||
- **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
|
||||
(tableau de bord, troncature, lecture seule, formules non recalculées, conflits) mal intégrés.
|
||||
La refonte s'appuie sur les standards Excel/Google Sheets/Airtable **sans renier** la contrainte
|
||||
`vanilla JS`, zéro framework, zéro build npm.
|
||||
- **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)
|
||||
- [x] **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)
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
@@ -218,8 +243,9 @@
|
||||
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
|
||||
| ⚪ 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 |
|
||||
| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; P1 ✅ A5, A8-A10, A12, A9bis — reste A6-A7 ; A13-A17 2-4 j) | 2-4 jours restants |
|
||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 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** |
|
||||
|
||||
---
|
||||
|
||||
@@ -227,6 +253,7 @@
|
||||
|
||||
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
|
||||
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
|
||||
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; Lot 1 livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille).
|
||||
- **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,123 @@
|
||||
# #154 — Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
|
||||
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
|
||||
> **Statut :** 🔵 **en cours** — Lots 1 & 2 livrés le 2026-09-29
|
||||
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 · Lot 4)
|
||||
> **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.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Rendre la vue tableur d'ObsiGate **intuitive, moderne et hautement utilisable** en s'inspirant
|
||||
des standards du marché (Excel, Google Sheets, Airtable), **sans renier les contraintes du
|
||||
dépôt** : thème sombre, `vanilla JS`, **zéro framework, zéro build npm**
|
||||
([`AGENTS.md`](../../AGENTS.md)). La refonte est **organique** : on améliore la coquille
|
||||
existante (`frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css`), on ne réécrit pas
|
||||
la grille ni le backend.
|
||||
|
||||
## 2. Audit UX — les 3 problèmes majeurs
|
||||
|
||||
| # | Problème | Constat | Résolution |
|
||||
|---|---|---|---|
|
||||
| **P1** | **Aucune hiérarchie ni regroupement des commandes** | Rangée plate de boutons de poids identique (`viewer.js` toolbar historique) ; « Tableau de bord » *prependé* au runtime ; barre de formule réduite à un `input`. | **Barre de commandes groupée** (Formules · Insertion · Vue · Fichier), bouton **Enregistrer primaire**, état *dirty*. |
|
||||
| **P2** | **Grille sans affordances : en-têtes = cellules** | Contraste faible entre `th` et `td`, pas de zébrage, pas de survol lisible, cellule active peu marquée. | **Tokens de grille** + en-têtes plus clairs/interactifs, zébrage, survol, cellule active en bordure accent. |
|
||||
| **P3** | **États avancés traités comme du contenu** | Dashboard *inline* qui pousse la grille, troncature/lecture seule/formules non recalculées sans emplacement dédié, `confirm()`/`prompt()` natifs. | **Couche UI dédiée** : bandeaux d'état + **inspecteur droit** (Lot 3) + dialogues thémés (Lot 2). |
|
||||
|
||||
## 3. Architecture cible de l'écran
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ BARRE APP (globale, existante) │
|
||||
├─────────────┬────────────────────────────────────────────────────────────┤
|
||||
│ │ A. RUBAN — groupes Formules · Insertion · Vue · Fichier │
|
||||
│ EXPLORATEUR│ B. BARRE DE FORMULE — [ A1 ] fx [ … ] │
|
||||
│ DE FICHIERS│ C. BANDEAUX D'ÉTAT — lecture seule · formules non recalculées│
|
||||
│ (sidebar) ├──────────────────────────────────────────────┬─────────────┤
|
||||
│ │ D. GRILLE (en-têtes clairs, zébrage, survol) │ E. INSPECTEUR│
|
||||
│ │ │ (dashboard + │
|
||||
│ │ │ IA, repliable)│
|
||||
│ ├───────────────────────────────────────────────┤ │
|
||||
│ │ F. ONGLETS FEUILLES + « + » · 500/522 │ │
|
||||
└─────────────┴───────────────────────────────────────────────┴─────────────┘
|
||||
```
|
||||
|
||||
- **A. Ruban** : groupes d'actions avec séparateurs ; actions de style désactivées (styles lus,
|
||||
pas écrits). Bouton **Enregistrer** en accent, désactivé si rien de *dirty*.
|
||||
- **B. Barre de formule** : zone nom + champ + badge de session `f(x)`.
|
||||
- **C. Bandeaux d'état** : empilables, non bloquants ; portent lecture seule et
|
||||
« formules non recalculées ».
|
||||
- **D. Grille** : rendue côté serveur (`backend/xlsx_reader.py`), habillée et câblée par le front.
|
||||
- **E. Inspecteur** : **à venir (Lot 3)** — Tableau de bord + Assistant IA dans un panneau droit
|
||||
repliable (réutilise `PaneManager` pour le détachement), au lieu du dashboard *inline* actuel.
|
||||
- **F. Onglets feuilles** : permanents (même à une seule feuille) + bouton « + ».
|
||||
|
||||
## 4. Plan par lots (incréments livrables)
|
||||
|
||||
### Lot 1 — Coquille : ruban groupé, onglets permanents, badges d'état ✅ *(2026-09-29)*
|
||||
|
||||
- **A1.1** Barre de commandes groupée (`.xlsx-cmdbar`, `.xlsx-cmd-group`, `.xlsx-cmd-sep`,
|
||||
`.xlsx-save-primary`), IDs existants conservés (compatibilité tests JSDOM/E2E).
|
||||
- **A1.2** Onglets de feuilles **toujours rendus** (non-CSV) + bouton **`+`** `.xlsx-tab-add`
|
||||
→ `sheet_add` (même pipeline `putStructure`).
|
||||
- **A1.3** Badges d'état : `.xlsx-status-pill` **lecture seule** (`.xls`/`.ods`) et
|
||||
**formules non recalculées** (non-CSV).
|
||||
- **A1.4** Tokens de grille (`--grid-bg`, `--grid-header-bg`, `--grid-header-text`,
|
||||
`--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 ✅ *(2026-09-29)*
|
||||
|
||||
- **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)
|
||||
|
||||
- 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()`.
|
||||
|
||||
### Lot 4 — Découpage & finitions (à venir)
|
||||
|
||||
- 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*).
|
||||
|
||||
## 5. Recommandations techniques (contrainte « zéro build »)
|
||||
|
||||
| Option Data Grid | Build | Licence | Verdict |
|
||||
|---|---|---|---|
|
||||
| AG Grid Community | npm + bundler | MIT | ❌ viole « zéro build », réécrit le DOM, casse les tests |
|
||||
| Handsontable | npm + bundler | **commerciale** | ❌ licence non libre |
|
||||
| TanStack Table | headless (importable esm.sh) | MIT | ⚠ possible sans build, mais *headless* → gain limité |
|
||||
| **Grille maison sur `<table>`** | aucun | — | ✅ **recommandé** (conserve DOM, CSP, i18n, tests) |
|
||||
|
||||
- **Performance** : ne pas ré-écrire tout le DOM ; réutiliser le pipeline `appendWindow` ;
|
||||
`content-visibility:auto; contain:strict` sur les lignes ; garder la pagination serveur
|
||||
(500 × 40 = 20 000 cellules/feuille) plutôt qu'une virtualisation client complexe.
|
||||
- **CSP** : `main.py` autorise déjà `esm.sh` — une lib *headless* reste possible en Lot 4 si
|
||||
un vrai besoin de modèle de colonnes apparaît.
|
||||
|
||||
## 6. Critères d'acceptation (par lot)
|
||||
|
||||
- **Lot 1** : une feuille unique affiche son onglet + « + » ; « + » ajoute une feuille via
|
||||
`PUT …/xlsx/structure` et re-rend ; `.xls`/`.ods` montrent le badge « lecture seule » (pas de
|
||||
« + », pas de structure, pas de dashboard) ; un `.xlsx` montre le badge « formules non
|
||||
recalculées », un `.csv` non ; les tests JSDOM/E2E existants restent verts + nouveaux tests.
|
||||
- Lots suivants : définis à leur ouverture.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| 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) |
|
||||
@@ -3,7 +3,7 @@
|
||||
> **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md)
|
||||
> **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir
|
||||
> [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md))
|
||||
> **Statut :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17
|
||||
> **Statut :** ✅ **Backlog terminé et livré le 2026-09-28** — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis A6→A17 en v2.33.0 → v2.39.0 (A11 étant au CI depuis A5/A8)
|
||||
> **Effort estimé :** 8-13 jours au total (P0 ✅ 2-3 j · P1 4-6 j · P2 2-4 j)
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||
@@ -25,7 +25,7 @@
|
||||
| CSS | `frontend/style.css:10927-10988` | `.xlsx-*` (variables CSS, colonne A `sticky`) |
|
||||
| Indexation | `backend/indexer.py:68, 563-568, 957-960` | `.xlsx` supporté, **métadonnées seules** (`content=""`) |
|
||||
| Outils IA | `backend/tools/documents.py:66-89` + `schemas.py:296-305` | `create_xlsx` (WRITE + confirmation) — **création seule** |
|
||||
| Tests | `tests/test_xlsx_viewer.py` | 11 tests backend (affichage, index, save, backup, 400) |
|
||||
| Tests | `tests/test_xlsx_viewer.py` (58) + `test_xlsx_styles.py` (9) + `test_xlsx_formats.py` (12) + `test_xlsx_dashboard.py` (8) + `test_xlsx_structure.py` (11) + `test_spreadsheet_tools.py` (17) · `tests/frontend/xlsx-viewer.test.mjs` (35) · `tests/e2e/xlsx-viewer.spec.js` (9) | Backend, JSDOM et E2E (chromium-desktop) |
|
||||
|
||||
## 2. Ce qui est supporté aujourd'hui (livré, non concerné par #153 sauf mention)
|
||||
|
||||
@@ -46,6 +46,12 @@ vidée ; backup `.bak` avant écriture ; garde-fou vault read-only (403) ; `reso
|
||||
|
||||
## 3. Limites connues (par couche)
|
||||
|
||||
> **Note (2026-09-28)** : les limites ci-dessous décrivent l'état du jour de l'audit
|
||||
> (2026-09-27). La quasi-totalité a été levée depuis par le backlog §5 (styles, navigation
|
||||
> clavier, tri/filtre/recherche, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation,
|
||||
> outils IA) — se reporter aux cases cochées et à l'historique §7 ; ne pas relire cette
|
||||
> section comme l'état actuel.
|
||||
|
||||
### 3.1 Fidélité du round-trip — risque n°1
|
||||
|
||||
`load_workbook()` → `wb.save()` : ce qui est **réellement** perdu a été mesuré sur
|
||||
@@ -135,7 +141,7 @@ couverture) · effort en jours-homme de développement + tests.
|
||||
nombres. Au passage : le handler `ServiceError` expose `code` + `details` et `api()` les
|
||||
propage sur l'Error. *Vérifié :* `TestXlsxFormulaGuard` (4) + test du toggle côté UI.
|
||||
|
||||
### P1 — Recherche, IA, UX (4-6 j) — 🟢 A5, A10, A12 livrés le 2026-09-28
|
||||
### P1 — Recherche, IA, UX (4-6 j) — 🟢 livré le 2026-09-28 (A5 → A12)
|
||||
|
||||
- [x] **A5 — Indexation du contenu des feuilles.** `extract_indexable_text()` (noms de feuilles +
|
||||
20 premières lignes, `MAX_INDEX_CHARS = 5 000`, 20 feuilles max) alimente le TF-IDF et la
|
||||
@@ -143,13 +149,14 @@ couverture) · effort en jours-homme de développement + tests.
|
||||
chiffré/corrompu s'indexe par son seul nom (jamais d'exception). Au passage : **BUG-089**,
|
||||
un reindex manuel ne reconstruisait pas l'index inversé. *Vérifié :* `TestXlsxSearchable` (4)
|
||||
+ `TestXlsxIndexing`, **contre-preuve** (neutraliser l'extraction → 3 tests échouent).
|
||||
- [ ] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant),
|
||||
- [x] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant),
|
||||
`append_xlsx_rows`, `xlsx_to_markdown` (contexte LLM, plafonné), `list_xlsx_sheets` — risque
|
||||
WRITE + confirmation pour les mutations, libellés i18n dans `backend/tools/labels.py`,
|
||||
refresh viewer via `obsigate:file-written`.
|
||||
- [ ] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
|
||||
refresh viewer via `obsigate:file-written`. *Livré (v2.33.0) :* `backend/tools/spreadsheets.py`.
|
||||
*Vérifié :* `tests/test_spreadsheet_tools.py` (17).
|
||||
- [x] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
|
||||
active affichée (nom A1), `Maj+Entrée` pour le multiligne, copier une plage, focus visible
|
||||
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`).
|
||||
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`). *Livré (v2.34.0).*
|
||||
- [x] **A8 — `thead` sticky + indicateur de troncature (R5) — livré 2026-09-28 (BUG-090).**
|
||||
Ligne d'en-têtes figlée au défilement vertical (`thead th { top: 0 }` ; `top: auto` sur les
|
||||
numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose
|
||||
@@ -185,9 +192,11 @@ couverture) · effort en jours-homme de développement + tests.
|
||||
ressemblant à une formule n'est jamais convertie (BUG-088 préservé) ; un code postal
|
||||
numérique ou une version restent ce qu'ils sont. *Vérifié :* `TestXlsxValueCoercion` (5),
|
||||
**contre-preuve** (neutraliser la coercion → 2 tests échouent).
|
||||
- [ ] **A11 — Tests frontend + E2E.** `tests/frontend/xlsx-viewer.test.mjs` (dirty, Échap,
|
||||
- [x] **A11 — Tests frontend + E2E.** `tests/frontend/xlsx-viewer.test.mjs` (dirty, Échap,
|
||||
collage, 1 PUT par feuille, bouton désactivé) et `tests/e2e/xlsx-viewer.spec.js`
|
||||
(ouverture, onglets, édition, sauvegarde, rechargement) ; intégration au CI.
|
||||
(ouverture, onglets, édition, sauvegarde, rechargement) ; intégration au CI. *Vérifié :*
|
||||
35 tests JSDOM (le job CI `lint` lance `node xlsx-viewer.test.mjs`) et 9 E2E
|
||||
chromium-desktop ; la couverture a grandi avec chaque sous-tâche (A5/A8 → P2).
|
||||
- [x] **A12 — Valeurs calculées.** La valeur en cache s'affiche sous la formule dans un
|
||||
`<span class="xlsx-cached">`. La 2ᵉ lecture `data_only=True` n'a lieu que si l'archive
|
||||
contient réellement un `<f>…</f><v>…</v>` (sonde déjà présente pour A1) : le cas courant
|
||||
@@ -196,17 +205,30 @@ couverture) · effort en jours-homme de développement + tests.
|
||||
d'interface n'est émis par le backend. *Vérifié :* `TestXlsxCachedValues` (3),
|
||||
**contre-preuve** (neutraliser la 2ᵉ lecture → 2 tests échouent).
|
||||
|
||||
### P2 — Étendu (2-4 j)
|
||||
### P2 — Étendu (2-4 j) — 🟢 livré le 2026-09-28
|
||||
|
||||
- [ ] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
|
||||
- [ ] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
|
||||
- [ ] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format
|
||||
- [x] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
|
||||
*Livré (v2.35.0) :* tout en manipulation d'affichage, le classeur n'est jamais réécrit
|
||||
(info-bulle `xlsx.sort_applied`).
|
||||
- [x] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
|
||||
*Livré (v2.36.0) :* `PUT …/xlsx/structure` + menu Structure, mêmes garde-fous que
|
||||
l'édition de cellules. *Vérifié :* `tests/test_xlsx_structure.py` (11).
|
||||
- [x] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format
|
||||
devise/pourcentage/date, cellules fusionnées, volets figés) ; conserver `csv-table` comme
|
||||
socle de rendu.
|
||||
- [ ] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
|
||||
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion.
|
||||
- [ ] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue
|
||||
résumée (KPI par feuille) et proposal d'actions IA sur ces plages.
|
||||
socle de rendu. *Livré (v2.37.0) en lecture :* couleurs, gras/italique/souligné,
|
||||
alignements, fusions, ancre de volets figés ; un format de nombre personnalisé est signalé
|
||||
en police mono (pas de rendu devise/pourcentage). L'application de styles **depuis la
|
||||
visionneuse** (écriture) reste hors périmètre. *Vérifié :* `tests/test_xlsx_styles.py` (9).
|
||||
- [x] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
|
||||
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion. *Livré (v2.38.0) :*
|
||||
`.xlsm` éditable macros préservées, `.xls`/`.ods` lecture seule (xlrd/odfpy), `.csv`
|
||||
éditable et réécrit RFC 4180. *Vérifié :* `tests/test_xlsx_formats.py` (12).
|
||||
- [x] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue
|
||||
résumée (KPI par feuille) et proposal d'actions IA sur ces plages. *Livré (v2.39.0) :*
|
||||
panneau Tableau de bord (`GET …/xlsx/dashboard`) — plages nommées avec portée, comptage
|
||||
graphiques/TCD par analyse des parties OPC, stats par feuille, 8 KPI ; le volet IA se
|
||||
limite à un conseil contextuel (pas d'appel IA dédié sur les plages).
|
||||
*Vérifié :* `tests/test_xlsx_dashboard.py` (8).
|
||||
|
||||
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
||||
|
||||
@@ -233,3 +255,6 @@ couverture) · effort en jours-homme de développement + tests.
|
||||
| 2026-09-28 | **A5 + A10 + A12 livrés** : le contenu des cellules est indexé (recherche), la saisie est typée (booléens, dates FR), la valeur calculée s'affiche sous la formule. **BUG-089** corrigé au passage (reindex manuel ≠ reconstruction de l'index inversé ; `backend/search.py` lisait l'index par valeur) |
|
||||
| 2026-09-28 | **A8 + A9 livrés** (BUG-090) : la troncature d'une feuille est annoncée (bandeau + dimensions dans la réponse de lecture), les en-têtes restent visibles au défilement, et `GET …/xlsx/sheet` sert une fenêtre de lignes avec les vraies coordonnées A1 — les lignes au-delà du plafond redeviennent accessibles aux clients API. Défilement virtuel côté UI à suivre |
|
||||
| 2026-09-28 | **A9bis livré** : « Charger la suite » + sentinelle de défilement sous une feuille tronquée ; les lignes ajoutées sont éditables et sauvegardables immédiatement (même pipeline que le rendu initial) |
|
||||
| 2026-09-28 | **A6 + A7 livrés** (v2.33.0, v2.34.0) : l'assistant IA lit et modifie les classeurs (`list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`, `append_xlsx_rows`) et la visionneuse gagne navigation clavier complète + barre de formule |
|
||||
| 2026-09-28 | **A13 + A14 livrés** (v2.35.0, v2.36.0) : tri, filtre, recherche et export CSV côté affichage ; structure du classeur éditable (feuilles, lignes, colonnes) via `PUT …/xlsx/structure` |
|
||||
| 2026-09-28 | **A15 + A16 + A17 livrés** (v2.37.0 → v2.39.0) : styles/fusions/volets figés rendus, formats `.xlsm`/`.xls`/`.ods`/`.csv` gérés, panneau Tableau de bord (plages nommées, graphiques/TCD, stats, KPI) — **backlog #153 terminé** |
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+229
-36
@@ -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';
|
||||
@@ -1047,11 +1047,18 @@ export function renderXlsxViewer(area, data) {
|
||||
// per workbook, not a global preference).
|
||||
let lossyConfirmed = false;
|
||||
let allowFormula = false;
|
||||
const tabs = sheets.length > 1
|
||||
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
|
||||
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
|
||||
).join("")}</div>`
|
||||
: "";
|
||||
// #154-A1 — the command bar is grouped (Formules · Insertion · Vue · Fichier)
|
||||
// and the sheet tabs are ALWAYS rendered (a single sheet used to show no tab
|
||||
// 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>`;
|
||||
// #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).
|
||||
@@ -1123,24 +1130,55 @@ 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>');
|
||||
|
||||
area.innerHTML = `
|
||||
<div class="xlsx-viewer">
|
||||
<div class="xlsx-toolbar">
|
||||
${tabs}
|
||||
<span class="xlsx-toolbar-actions">
|
||||
<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>
|
||||
<button class="btn-action" id="xlsx-save-btn" disabled${isCsv || readOnly ? "" : ""}>${t("common.save")}</button>
|
||||
<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>
|
||||
${isCsv || readOnly ? "" : `<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>
|
||||
<div class="xlsx-toolbar" role="toolbar">
|
||||
<div class="xlsx-cmdbar">
|
||||
${tabs}
|
||||
<span class="xlsx-toolbar-actions">${actionsHtml}</span>
|
||||
</div>
|
||||
${statusBar}
|
||||
</div>
|
||||
${lossWarning}
|
||||
<div class="xlsx-formula-bar">
|
||||
@@ -1163,10 +1201,106 @@ export function renderXlsxViewer(area, data) {
|
||||
<div class="xlsx-panels">${panels}</div>
|
||||
</div>`;
|
||||
|
||||
// ── #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).
|
||||
if (!isCsv && !readOnly) {
|
||||
const dashBtn = el("button", {
|
||||
class: "btn-action xlsx-dash-btn",
|
||||
id: "xlsx-dashboard-btn",
|
||||
type: "button",
|
||||
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);
|
||||
dashBtn.addEventListener("click", async () => {
|
||||
const old = area.querySelector(".xlsx-dashboard");
|
||||
if (old) { old.remove(); dashBtn.classList.remove("active"); return; }
|
||||
dashBtn.classList.add("active");
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "xlsx-dashboard";
|
||||
panel.innerHTML = `<div class="xlsx-dashboard-loading">…</div>`;
|
||||
area.querySelector(".xlsx-panels").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>`;
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
panel.innerHTML = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1420,8 +1554,9 @@ export function renderXlsxViewer(area, data) {
|
||||
});
|
||||
|
||||
// Formula toggle (#153 A4) — opt-in for this viewing session only.
|
||||
// #154-A1 — absent on a read-only workbook (nothing can be saved).
|
||||
const formulaBtn = area.querySelector("#xlsx-formula-btn");
|
||||
formulaBtn.addEventListener("click", () => {
|
||||
if (formulaBtn) formulaBtn.addEventListener("click", () => {
|
||||
allowFormula = !allowFormula;
|
||||
formulaBtn.setAttribute("aria-pressed", String(allowFormula));
|
||||
formulaBtn.classList.toggle("active", allowFormula);
|
||||
@@ -1469,7 +1604,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;
|
||||
@@ -1490,6 +1631,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");
|
||||
}
|
||||
@@ -1673,13 +1817,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;
|
||||
}
|
||||
@@ -1687,8 +1835,24 @@ export function renderXlsxViewer(area, data) {
|
||||
return null;
|
||||
};
|
||||
|
||||
// #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 = 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); }
|
||||
});
|
||||
|
||||
// Structure menu: built on demand, positioned under the button.
|
||||
area.querySelector("#xlsx-structure-btn").addEventListener("click", (e) => {
|
||||
// (.csv / read-only workbooks have no such button — the menu block above
|
||||
// is skipped for them.)
|
||||
const structureBtn = area.querySelector("#xlsx-structure-btn");
|
||||
if (structureBtn) structureBtn.addEventListener("click", (e) => {
|
||||
const old = area.querySelector(".xlsx-structure-menu");
|
||||
if (old) { old.remove(); return; }
|
||||
const idx = visibleSheetIndex();
|
||||
@@ -1706,26 +1870,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); }
|
||||
});
|
||||
@@ -1736,7 +1917,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); }
|
||||
});
|
||||
@@ -1745,7 +1932,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); }
|
||||
});
|
||||
|
||||
@@ -1827,8 +1827,16 @@
|
||||
"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.cached_value_title": "Last value calculated by Excel",
|
||||
"xlsx.tabs_add_sheet": "Add a sheet",
|
||||
"xlsx.readonly_badge": "Read-only",
|
||||
"xlsx.readonly_hint": "This format (.xls / .ods) cannot be edited in ObsiGate — convert it to .xlsx to edit.",
|
||||
"xlsx.formulas_note": "Formulas not recalculated",
|
||||
"xlsx.formulas_note_title": "ObsiGate shows the formula as stored: Excel recalculates it on open. Dependent cells do not refresh on screen.",
|
||||
"xlsx.truncated_title": "Truncated sheet",
|
||||
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
|
||||
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
|
||||
@@ -1868,6 +1876,16 @@
|
||||
"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.dashboard_title": "Workbook dashboard",
|
||||
"xlsx.dashboard_stats": "{{cells}} cells · {{rows}} rows · {{cols}} columns · {{formulas}} formulas · {{numeric}} numeric values",
|
||||
"xlsx.dashboard_charts": "{{n}} chart(s)",
|
||||
"xlsx.dashboard_pivots": "{{n}} pivot tables",
|
||||
"xlsx.dashboard_empty": "No named range or usable data in this workbook.",
|
||||
"xlsx.dashboard_nr_name": "Name",
|
||||
"xlsx.dashboard_nr_scope": "Scope",
|
||||
"xlsx.dashboard_nr_ref": "Reference",
|
||||
"xlsx.dashboard_hint": "Select a range or open the AI assistant to analyse this data.",
|
||||
"xlsx.feature_cached_values": "cached values",
|
||||
"xlsx.feature_slicers": "slicers and timelines",
|
||||
"xlsx.feature_form_controls": "form controls",
|
||||
|
||||
@@ -1827,8 +1827,16 @@
|
||||
"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.cached_value_title": "Dernière valeur calculée par Excel",
|
||||
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
||||
"xlsx.readonly_badge": "Lecture seule",
|
||||
"xlsx.readonly_hint": "Ce format (.xls / .ods) n'est pas modifiable dans ObsiGate — convertissez-le en .xlsx pour l'éditer.",
|
||||
"xlsx.formulas_note": "Formules non recalculées",
|
||||
"xlsx.formulas_note_title": "ObsiGate affiche la formule telle qu'elle est enregistrée : Excel la recalcule à l'ouverture. Les cellules dépendantes ne se rafraîchissent pas à l'écran.",
|
||||
"xlsx.truncated_title": "Feuille tronquée",
|
||||
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
|
||||
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
|
||||
@@ -1868,6 +1876,16 @@
|
||||
"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.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)",
|
||||
"xlsx.dashboard_pivots": "{{n}} TCD",
|
||||
"xlsx.dashboard_empty": "Aucune plage nommée ni donnée exploitable dans ce classeur.",
|
||||
"xlsx.dashboard_nr_name": "Nom",
|
||||
"xlsx.dashboard_nr_scope": "Portée",
|
||||
"xlsx.dashboard_nr_ref": "Référence",
|
||||
"xlsx.dashboard_hint": "Sélectionnez une plage ou ouvrez l'assistant IA pour analyser ces données.",
|
||||
"xlsx.feature_cached_values": "valeurs calculées",
|
||||
"xlsx.feature_slicers": "segments et chronologies",
|
||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||
|
||||
+267
-14
@@ -57,6 +57,13 @@
|
||||
--mono: "JetBrains Mono", monospace;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
|
||||
chrome (toolbar/panels), headers are clearly distinct from cells. */
|
||||
--grid-bg: #161b22;
|
||||
--grid-header-bg: #1f2430;
|
||||
--grid-header-text: #e6edf3;
|
||||
--grid-border: #30363d;
|
||||
--grid-zebra: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
/* ===== THEME — DARK (explicit) ===== */
|
||||
@@ -101,6 +108,11 @@
|
||||
--mono: "JetBrains Mono", monospace;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--grid-bg: #161b22;
|
||||
--grid-header-bg: #1f2430;
|
||||
--grid-header-text: #e6edf3;
|
||||
--grid-border: #30363d;
|
||||
--grid-zebra: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
/* ===== THEME — LIGHT ===== */
|
||||
@@ -145,6 +157,11 @@
|
||||
--mono: "JetBrains Mono", monospace;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--grid-bg: #ffffff;
|
||||
--grid-header-bg: #eaeef2;
|
||||
--grid-header-text: #1f2328;
|
||||
--grid-border: #d0d7de;
|
||||
--grid-zebra: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
/* ===== BASE ===== */
|
||||
@@ -10925,21 +10942,54 @@ body.desktop-mode .editor-container {
|
||||
}
|
||||
|
||||
/* ── XLSX Viewer ── */
|
||||
/* #154-A1 — the viewer shell is a grouped command bar: sheet tabs on the left,
|
||||
action groups (Formules · Insertion · Vue · Fichier) on the right, then a
|
||||
status row stating the viewer's limits (read-only, formulas not recalculated). */
|
||||
.xlsx-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface2);
|
||||
position: relative; /* anchors the A14 structure menu */
|
||||
}
|
||||
.xlsx-cmdbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
position: relative; /* anchors the A14 structure menu */
|
||||
}
|
||||
.xlsx-toolbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xlsx-cmd-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xlsx-cmd-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
background: var(--border);
|
||||
}
|
||||
.xlsx-save-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.xlsx-save-primary:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.xlsx-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -10957,15 +11007,122 @@ body.desktop-mode .editor-container {
|
||||
border-color: var(--accent, #4a90d9);
|
||||
color: #fff;
|
||||
}
|
||||
.xlsx-table th.xlsx-corner,
|
||||
.xlsx-table th.xlsx-rownum {
|
||||
.xlsx-tab-add {
|
||||
border: 1px dashed var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-tab-add:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.xlsx-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.xlsx-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xlsx-status-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.xlsx-status-readonly {
|
||||
border-color: var(--warning, #e0a800);
|
||||
color: var(--warning, #e0a800);
|
||||
}
|
||||
.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-weight: 400;
|
||||
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);
|
||||
}
|
||||
.xlsx-table td {
|
||||
border-bottom: 1px solid var(--grid-border);
|
||||
}
|
||||
/* #154-A1 — zebra + hover make rows scannable; headers use dedicated tokens so
|
||||
they are visually distinct from the cells. */
|
||||
.xlsx-table tbody tr:nth-child(even) td {
|
||||
background: var(--grid-zebra);
|
||||
}
|
||||
.xlsx-table tbody tr:hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.xlsx-table th.xlsx-corner,
|
||||
.xlsx-table th.xlsx-rownum {
|
||||
background: var(--grid-header-bg);
|
||||
color: var(--grid-header-text);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
border-right: 1px solid var(--border-light, var(--border));
|
||||
border-bottom: 2px solid var(--grid-border);
|
||||
border-right: 1px solid var(--grid-border);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
/* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th`
|
||||
@@ -10986,7 +11143,9 @@ body.desktop-mode .editor-container {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
background: var(--surface);
|
||||
background: var(--grid-header-bg);
|
||||
color: var(--grid-header-text);
|
||||
border-bottom: 2px solid var(--grid-border);
|
||||
}
|
||||
.xlsx-table td[contenteditable] {
|
||||
cursor: text;
|
||||
@@ -10998,9 +11157,10 @@ body.desktop-mode .editor-container {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* #153 A7 — the active cell keeps its outline even when focus moves to the
|
||||
formula bar, so the user never loses track of what the bar edits. */
|
||||
formula bar, so the user never loses track of what the bar edits.
|
||||
#154-A1 — solid (not dashed) for a stronger active-cell affordance. */
|
||||
.xlsx-table td.xlsx-active:not(:focus) {
|
||||
outline: 2px dashed var(--accent, #4a90d9);
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -11099,12 +11259,12 @@ body.desktop-mode .editor-container {
|
||||
position: sticky;
|
||||
top: 33px; /* thead height — keeps the frozen row below the header */
|
||||
z-index: 2;
|
||||
background: var(--surface);
|
||||
background: var(--grid-header-bg);
|
||||
}
|
||||
.xlsx-table td.xlsx-frozen-col {
|
||||
position: sticky;
|
||||
left: 44px; /* the row-number column width */
|
||||
background: var(--surface);
|
||||
background: var(--grid-bg);
|
||||
}
|
||||
|
||||
/* #153 A14 — structure menu (sheets / rows / columns) */
|
||||
@@ -11128,6 +11288,97 @@ body.desktop-mode .editor-container {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* #153 A17 — workbook dashboard panel: named ranges table + KPI cards.
|
||||
Colors come from the existing CSS variables (no hardcoded values). */
|
||||
.xlsx-dashboard {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.xlsx-dashboard-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xlsx-dashboard-head h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-dashboard-objects {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-ranges-table {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.xlsx-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.xlsx-kpi-sheet {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.xlsx-kpi-sheet h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-kpi-meta {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-kpi-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.xlsx-kpi {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
min-width: 64px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.xlsx-kpi-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-kpi-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-kpi-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xlsx-dashboard-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-dashboard-loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xlsx-structure-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
@@ -11141,7 +11392,9 @@ body.desktop-mode .editor-container {
|
||||
mark {
|
||||
font: inherit;
|
||||
}
|
||||
.xlsx-table td.xlsx-dirty {
|
||||
/* #154-A1 — scoped to tbody so a dirty cell keeps its highlight even on the
|
||||
zebra/hover backgrounds (specificity beats the zebra + hover rules). */
|
||||
.xlsx-table tbody td.xlsx-dirty {
|
||||
background: rgba(255, 196, 0, 0.18);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.38.0",
|
||||
"version": "2.41.0",
|
||||
"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,17 @@ function editCell(area, ref, text) {
|
||||
return td;
|
||||
}
|
||||
|
||||
/** #154-A2 — the themed dialog is the last overlay in document.body. */
|
||||
const lastDialog = () => {
|
||||
const overlays = document.querySelectorAll(".obsigate-modal-overlay");
|
||||
return overlays[overlays.length - 1] || null;
|
||||
};
|
||||
const clickDialog = (which) => {
|
||||
const btn = lastDialog()?.querySelector(`[data-dialog="${which}"]`);
|
||||
assert.ok(btn, `dialog button "${which}" is present`);
|
||||
btn.click();
|
||||
};
|
||||
|
||||
const lossyError = {
|
||||
ok: false,
|
||||
status: 409,
|
||||
@@ -248,12 +262,17 @@ await test("409 xlsx_lossy_content asks once then retries with force", async ()
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
// #154-A2 — a themed dialog replaces window.confirm(), and no retry happens
|
||||
// before the user answers.
|
||||
const dialog = lastDialog();
|
||||
assert.ok(dialog, "a themed confirmation is shown");
|
||||
assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent);
|
||||
assert.equal(calls.length, 1, "nothing is retried before the answer");
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
// The prompt names the features the backend reported.
|
||||
assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]);
|
||||
// Save succeeded → cells are no longer dirty.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
});
|
||||
@@ -264,27 +283,52 @@ await test("confirming once is enough for the following saves", async () => {
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
editCell(area, "A1", "2");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1, "the user is not asked twice");
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[2].body.force, true);
|
||||
// Wait out the dialog close animation, then confirm none is left.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
assert.equal(document.querySelectorAll(".obsigate-modal-overlay").length, 0, "the user is not asked twice");
|
||||
});
|
||||
|
||||
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
|
||||
const area = mount({ lossy: ["slicers"] });
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => lossyError;
|
||||
confirmAnswer = false;
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
clickDialog("cancel");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 1, "no retry after a refusal");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
});
|
||||
|
||||
await test("a 409 conflict shows a non-blocking retry banner and keeps the edits", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => ({ ok: false, status: 409, body: { detail: "busy", code: "conflict" } });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const banner = area.querySelector(".xlsx-banner-conflict");
|
||||
assert.ok(banner, "a conflict banner is shown");
|
||||
assert.ok(banner.textContent.includes(FR["xlsx.conflict_msg"]), banner.textContent);
|
||||
// The edit is preserved and the save button is usable again.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
// The retry re-runs the save.
|
||||
nextResponse = () => ({ ok: true, status: 200, body: {} });
|
||||
banner.querySelector(".xlsx-banner-retry").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry");
|
||||
});
|
||||
|
||||
await test("a non-409 failure is not retried", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
@@ -582,29 +626,25 @@ await test("CSV export downloads the visible sheet without the cached shadows",
|
||||
|
||||
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
|
||||
const area = mount();
|
||||
// The prompt is resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
||||
}); // re-read
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
||||
const addBtn = [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
||||
addBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
globalThis.prompt = realPrompt;
|
||||
// #154-A2 — a themed prompt replaces window.prompt().
|
||||
const input = lastDialog()?.querySelector('[data-dialog="input"]');
|
||||
assert.ok(input, "a themed prompt asks for the sheet name");
|
||||
input.value = "Feuille 2";
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
// The viewer re-rendered from the server payload (a single sheet → no tabs).
|
||||
assert.ok(
|
||||
area.querySelector("#content-area, .xlsx-viewer") || area,
|
||||
"the viewer was rebuilt",
|
||||
);
|
||||
assert.ok(
|
||||
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
|
||||
"the re-render shows the fresh payload",
|
||||
@@ -626,9 +666,7 @@ await test("sheet_delete confirms and is refused on the last sheet", async () =>
|
||||
|
||||
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
|
||||
const area = mount();
|
||||
// The prompt is resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
// The prompt is answered through the themed dialog.
|
||||
apiQueue.push({
|
||||
ok: false, status: 409,
|
||||
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
|
||||
@@ -639,15 +677,63 @@ 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);
|
||||
});
|
||||
|
||||
// ── A17 — dashboard panel ─────────────────────────────────────────────
|
||||
await test("the dashboard button fetches the metadata and renders named ranges + KPIs", async () => {
|
||||
const area = mount();
|
||||
const dashBtn = area.querySelector("#xlsx-dashboard-btn");
|
||||
assert.ok(dashBtn, "the dashboard button exists for an editable workbook");
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
vault: "V",
|
||||
path: "data.xlsx",
|
||||
named_ranges: [{ name: "MaPlage", scope: "", ref: "Data!$A$1:$B$5" }],
|
||||
objects: { charts: 2, pivots: 1 },
|
||||
sheets: [{ name: "Feuille1", cells: 9, rows: 3, cols: 3, formulas: 1, numeric: 2, kpi: [{ label: "A2", value: 12 }] }],
|
||||
},
|
||||
});
|
||||
dashBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.match(calls[0].url, /\/xlsx\/dashboard\?path=data\.xlsx/);
|
||||
const panel = area.querySelector(".xlsx-dashboard");
|
||||
assert.ok(panel, "the dashboard panel is rendered");
|
||||
assert.ok(panel.textContent.includes("MaPlage"), "named range is listed");
|
||||
assert.ok(panel.textContent.includes("A2"), "KPI label is rendered");
|
||||
assert.ok(panel.textContent.includes("12"), "KPI value is rendered");
|
||||
// Second click closes the panel.
|
||||
dashBtn.click();
|
||||
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
|
||||
});
|
||||
|
||||
await test("a csv mounts without the dashboard button", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V",
|
||||
path: "data.csv",
|
||||
is_csv: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
assert.equal(area.querySelector("#xlsx-dashboard-btn"), null, "no dashboard for csv");
|
||||
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure menu for csv");
|
||||
assert.ok(area.querySelector("td[data-cell=\"A1\"]"), "the grid is still editable");
|
||||
});
|
||||
|
||||
await test("two windows in a row walk the whole sheet", async () => {
|
||||
const area = mountTruncated({ total: 1200 });
|
||||
apiQueue.push({
|
||||
@@ -674,6 +760,88 @@ await test("two windows in a row walk the whole sheet", async () => {
|
||||
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
||||
});
|
||||
|
||||
// ── #154-A1 — command bar, sheet tabs, status pills ─────────────────────────
|
||||
|
||||
await test("a single-sheet workbook still shows its tab and a + button", () => {
|
||||
const area = mount();
|
||||
assert.ok(area.querySelector(".xlsx-tabs"), "the tab strip is always rendered");
|
||||
assert.equal(area.querySelectorAll(".xlsx-tab").length, 1);
|
||||
const add = area.querySelector("#xlsx-tab-add");
|
||||
assert.ok(add, "the + button is present for an editable sheet");
|
||||
assert.equal(add.getAttribute("title"), FR["xlsx.tabs_add_sheet"]);
|
||||
});
|
||||
|
||||
await test("a csv shows no tab strip and no + button", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.csv", is_csv: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
assert.equal(area.querySelector(".xlsx-tabs"), null);
|
||||
assert.equal(area.querySelector("#xlsx-tab-add"), null);
|
||||
});
|
||||
|
||||
await test("the + button adds a sheet 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: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
||||
}); // re-read
|
||||
area.querySelector("#xlsx-tab-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));
|
||||
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(area.querySelector('td[data-cell="A1"]')?.textContent, "neuf");
|
||||
});
|
||||
|
||||
await test("a read-only workbook shows the read-only pill and offers no editing", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.xls", is_xlsx: true, xlsx_readonly: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
const pill = area.querySelector(".xlsx-status-readonly");
|
||||
assert.ok(pill, "read-only pill");
|
||||
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('td[data-cell="A1"]').getAttribute("contenteditable"), null, "cells are not editable");
|
||||
});
|
||||
|
||||
await test("the formulas-not-recalculated pill is shown for xlsx but not csv", () => {
|
||||
const area = mount();
|
||||
const pill = area.querySelector(".xlsx-status-formula");
|
||||
assert.ok(pill, "formula pill on xlsx");
|
||||
assert.ok(pill.textContent.includes(FR["xlsx.formulas_note"]), pill.textContent);
|
||||
|
||||
const csvArea = document.getElementById("content-area");
|
||||
csvArea.innerHTML = "";
|
||||
renderXlsxViewer(csvArea, {
|
||||
vault: "V", path: "data.csv", is_csv: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
assert.equal(csvArea.querySelector(".xlsx-status-formula"), null);
|
||||
});
|
||||
|
||||
await test("the command bar exposes grouped actions with a primary save", () => {
|
||||
const area = mount();
|
||||
assert.ok(area.querySelector(".xlsx-cmdbar"), "grouped command bar");
|
||||
assert.ok(area.querySelector('#xlsx-save-btn').classList.contains("xlsx-save-primary"));
|
||||
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='formulas']"));
|
||||
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='insert']"));
|
||||
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='file']"));
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
|
||||
+165
-6
@@ -1,4 +1,4 @@
|
||||
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083).
|
||||
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083, BUG-091, BUG-093).
|
||||
|
||||
Sans dépendance (pas de PyYAML) : analyse ligne à ligne de
|
||||
`.gitea/workflows/ci.yml`, suffisante pour les conventions de ce fichier.
|
||||
@@ -9,9 +9,29 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
CI_YML = Path(__file__).resolve().parent.parent / ".gitea" / "workflows" / "ci.yml"
|
||||
REQUIREMENTS = Path(__file__).resolve().parent.parent / "backend" / "requirements.txt"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _job_text(job: str) -> str:
|
||||
"""Corps YAML du job `job` (jusqu'au job suivant ou à la fin du fichier)."""
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
start = text.index(f"\n {job}:")
|
||||
rest = text[start + 1 :]
|
||||
nxt = re.search(r"\n {2}[A-Za-z][A-Za-z0-9_-]*:\s*\n", rest)
|
||||
return rest[: nxt.start()] if nxt else rest
|
||||
|
||||
|
||||
def _steps(job: str) -> list[tuple[str, str]]:
|
||||
"""[(nom d'étape, corps YAML)] pour un job donné."""
|
||||
chunks = re.split(r"\n {6}- name: ", "\n" + _job_text(job))[1:]
|
||||
steps = []
|
||||
for chunk in chunks:
|
||||
name, _, body = chunk.partition("\n")
|
||||
steps.append((name.strip(), body))
|
||||
return steps
|
||||
|
||||
|
||||
def _run_bodies() -> list[tuple[int, str]]:
|
||||
"""Toutes les lignes shell de chaque bloc `run:` → [(n° ligne, code)]."""
|
||||
lines = CI_YML.read_text(encoding="utf-8").splitlines()
|
||||
@@ -62,14 +82,99 @@ class TestRunnerProofScripts:
|
||||
|
||||
|
||||
class TestSemgrepStep:
|
||||
def test_semgrep_local_rules_enforced(self):
|
||||
"""#87 T7 : semgrep bloquant sur règles locales (aucun registre)."""
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
assert "semgrep --config semgrep-rules/ backend/" in text, (
|
||||
"#87 T7 : étape semgrep locale attendue dans le job security"
|
||||
"""BUG-091 : semgrep-core est inexécutable sur le runner (exit 127).
|
||||
|
||||
L'étape est donc désactivée (avertissement, non bloquante) au lieu d'être
|
||||
supprimée : elle documente pourquoi, et se réactive telle quelle dès que le
|
||||
runner dispose d'un CPU x86-64-v2.
|
||||
"""
|
||||
|
||||
SEMGREP_STEP_PREFIX = "Semgrep"
|
||||
|
||||
def _semgrep_step(self) -> tuple[str, str]:
|
||||
matches = [
|
||||
(n, b) for n, b in _steps("security") if n.startswith(self.SEMGREP_STEP_PREFIX)
|
||||
]
|
||||
assert len(matches) == 1, (
|
||||
"BUG-091 : une unique étape Semgrep (désactivée) attendue dans le "
|
||||
f"job security, trouvé {len(matches)}"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
@staticmethod
|
||||
def _run_commands(body: str) -> list[str]:
|
||||
"""Commandes shell du bloc `run:` de l'étape (hors lignes vides)."""
|
||||
m = re.search(r"^\s*run:\s*\|?\s*$", body, re.M)
|
||||
assert m, "étape sans bloc `run:`"
|
||||
rest = body[m.end() :]
|
||||
lines: list[str] = []
|
||||
for line in rest.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
# le bloc run: est indenté de 2 spaces de plus que la clef
|
||||
if len(line) - len(line.lstrip()) <= 8:
|
||||
break
|
||||
lines.append(line.strip())
|
||||
return lines
|
||||
|
||||
def test_semgrep_step_does_not_execute_core(self):
|
||||
"""Le core natif ne doit plus être lancé (exit 127 bloquant le job).
|
||||
|
||||
Seule commande admise : l'avertissement d'activation. Le message
|
||||
mentionne voluntaryirement « semgrep » — c'est l'**exécution** qui
|
||||
est interdite, pas le mot.
|
||||
"""
|
||||
name, body = self._semgrep_step()
|
||||
commands = self._run_commands(body)
|
||||
assert commands, f"BUG-091 : l'étape « {name} » n'a plus de commande"
|
||||
for cmd in commands:
|
||||
assert cmd.startswith('echo "::warning::'), (
|
||||
f"BUG-091 : l'étape « {name} » ne doit exécuter qu'un avertissement, "
|
||||
f"trouvé : {cmd!r}"
|
||||
)
|
||||
|
||||
def test_semgrep_step_is_non_blocking_and_explains_itself(self):
|
||||
"""Désactivée = `continue-on-error` + avertissement explicite."""
|
||||
name, body = self._semgrep_step()
|
||||
assert re.search(r"^\s*continue-on-error:\s*true\s*$", body, re.M), (
|
||||
f"BUG-091 : l'étape « {name} » doit porter continue-on-error: true"
|
||||
)
|
||||
assert "::warning::" in body, (
|
||||
f"BUG-091 : l'étape « {name} » doit émettre un ::warning:: "
|
||||
"expliquant la désactivation"
|
||||
)
|
||||
assert "BUG-091" in body, (
|
||||
f"BUG-091 : l'étape « {name} » doit référencer BUG-091"
|
||||
)
|
||||
|
||||
def test_bandit_and_pip_audit_stay_blocking(self):
|
||||
"""La désactivation de semgrep ne doit rien dégraver d'autre (#87)."""
|
||||
found = {}
|
||||
for name, body in _steps("security"):
|
||||
low = name.lower()
|
||||
if low.startswith("bandit"):
|
||||
found["bandit"] = body
|
||||
elif low.startswith("pip-audit"):
|
||||
found["pip-audit"] = body
|
||||
assert set(found) == {"bandit", "pip-audit"}, (
|
||||
f"étapes Bandit et Pip-audit attendues dans le job security, "
|
||||
f"trouvé {sorted(found)}"
|
||||
)
|
||||
for tool, body in found.items():
|
||||
assert "continue-on-error: true" not in body, (
|
||||
f"BUG-091 : l'étape {tool} doit rester bloquante (#87)"
|
||||
)
|
||||
|
||||
def test_semgrep_rules_still_shipped_and_documented(self):
|
||||
"""Les règles locales restent versionnées et documentées (#87 T7)."""
|
||||
rules = REPO_ROOT / "semgrep-rules" / "obsigate-python.yaml"
|
||||
assert rules.exists(), "ruleset semgrep manquant"
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
# La commande locale est documentée (commentaire de l'étape), pas exécutée.
|
||||
assert re.search(r"semgrep --config semgrep-rules/\s*\n?\s*#?\s*backend/", text), (
|
||||
"#87 T7 : commande locale `semgrep --config semgrep-rules/ backend/` "
|
||||
"attendue en commentaire dans le workflow"
|
||||
)
|
||||
|
||||
|
||||
class TestFrontendStepsHaveTheirDeps:
|
||||
@@ -113,3 +218,57 @@ class TestFrontendStepsHaveTheirDeps:
|
||||
f"BUG-082 : `{suite}` attendu dans les deux branches de "
|
||||
"l'étape JSDOM"
|
||||
)
|
||||
|
||||
|
||||
_SPEC_RE = re.compile(
|
||||
r"^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(>=|==|~=|>|<)\s*([0-9][^\s;#]*)"
|
||||
)
|
||||
|
||||
|
||||
def _floor(pkg: str) -> tuple[int, ...] | None:
|
||||
"""Plancher `>=` déclaré pour `pkg` dans backend/requirements.txt."""
|
||||
for raw in REQUIREMENTS.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = _SPEC_RE.match(line)
|
||||
if not m or m.group(1).lower() != pkg or m.group(2) != ">=":
|
||||
continue
|
||||
return tuple(int(p) for p in re.match(r"[0-9]+(?:\.[0-9]+)*", m.group(3)).group(0).split("."))
|
||||
return None
|
||||
|
||||
|
||||
class TestDependencySecurityFloors:
|
||||
"""Planchers de sécurité des dépendances (#87, BUG-091, BUG-093).
|
||||
|
||||
`pip-audit` est bloquant dans le job `security`. Comme l'image du runner
|
||||
(`catthehacker/ubuntu:act-latest`) embarque des paquets *préinstallés* dans
|
||||
sa toolcache Python, un plancher trop bas est « already satisfied » et
|
||||
n'est jamais mis à niveau : c'est exactement ce qui a fait échouer le
|
||||
job sur pypdf 6.16.0 (PYSEC-2026-3910 / PYSEC-2026-3911, DoS de ressources
|
||||
atteignables via backend/pdf_reader.py).
|
||||
"""
|
||||
|
||||
#: (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)
|
||||
}
|
||||
|
||||
def test_security_floors_are_declared(self):
|
||||
missing = [p for p in self.FLOORS if _floor(p) is None]
|
||||
assert not missing, (
|
||||
"plancher `>=` manquant dans backend/requirements.txt pour : "
|
||||
f"{missing}"
|
||||
)
|
||||
|
||||
def test_security_floors_are_high_enough(self):
|
||||
too_low = {
|
||||
p: (_floor(p), minimum)
|
||||
for p, minimum in self.FLOORS.items()
|
||||
if (_floor(p) or ()) < minimum
|
||||
}
|
||||
assert not too_low, (
|
||||
"BUG-093 : plancher(s) sous le correctif de sécurité, "
|
||||
f"le job `security` (pip-audit bloquant) échouerait : {too_low}"
|
||||
)
|
||||
|
||||
+15
-2
@@ -10,6 +10,19 @@ from backend.tools.context import ToolContext, ToolError, ToolMode, ToolRisk
|
||||
from backend.tools.registry import get_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_dns(monkeypatch):
|
||||
"""Neutralise la résolution DNS réelle du garde SSRF (runner au réseau fragile).
|
||||
|
||||
Seuls les tests qui vérifient l'extraction HTML mockent ``httpx.get`` ; sans
|
||||
ce mock, ``_assert_public_http_url`` résolvait ``example.com`` pour de vrai et
|
||||
le test échouait en ``dns_error`` sur un runner dont le DNS est instable.
|
||||
Les tests de garde SSRF (``test_private_address_rejected``) n'utilisent PAS
|
||||
la fixture : ils doivent au contraire traverser le vrai garde.
|
||||
"""
|
||||
monkeypatch.setattr(web, "_assert_public_http_url", lambda url: url)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: Any = None, json_data: Any = None, status_code: int = 200,
|
||||
content: bytes = b"", headers: dict | None = None, url: str = "https://example.com/x"):
|
||||
@@ -171,7 +184,7 @@ class TestWebSearch:
|
||||
|
||||
|
||||
class TestFetchUrl:
|
||||
def test_html_converted_to_text(self, monkeypatch):
|
||||
def test_html_converted_to_text(self, monkeypatch, no_dns):
|
||||
html = (b"<html><head><title>T&</title><style>b{}</style>"
|
||||
b"<script>evil()</script></head><body><p>hello</p><ul>"
|
||||
b"<li>one</li><li>two</li></ul></body></html>")
|
||||
@@ -196,7 +209,7 @@ class TestFetchUrl:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(url="file:///etc/passwd"))
|
||||
assert ei.value.code == "invalid_scheme"
|
||||
|
||||
def test_binary_content_rejected(self, monkeypatch):
|
||||
def test_binary_content_rejected(self, monkeypatch, no_dns):
|
||||
monkeypatch.setattr(web.httpx, "get",
|
||||
lambda *a, **k: FakeResponse(content=b"%PDF-1.4...",
|
||||
headers={"content-type": "application/pdf"}))
|
||||
|
||||
+16
-3
@@ -20,7 +20,7 @@ class TestRegistration:
|
||||
|
||||
|
||||
class TestRenderUnavailable:
|
||||
def test_missing_playwright_clear_error(self, monkeypatch):
|
||||
def test_missing_playwright_clear_error(self, monkeypatch, no_dns):
|
||||
monkeypatch.setattr(webrender, "_playwright_available", lambda: False)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(
|
||||
@@ -35,8 +35,21 @@ class TestRenderUnavailable:
|
||||
assert ei.value.code in ("ssrf_blocked", "dns_error")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_dns(monkeypatch):
|
||||
"""Neutralise la résolution DNS réelle du garde SSRF.
|
||||
|
||||
``fetch_url`` appelle ``_assert_public_http_url`` (getaddrinfo) *avant* le
|
||||
rendu : sur un runner au DNS instable le test échouait en ``dns_error``
|
||||
au lieu d'atteindre le worker Playwright mocké. ``webrender`` importe la
|
||||
fonction dans son propre namespace : les deux références sont mockées.
|
||||
"""
|
||||
monkeypatch.setattr(web, "_assert_public_http_url", lambda url: url)
|
||||
monkeypatch.setattr(webrender, "_assert_public_http_url", lambda url: url)
|
||||
|
||||
|
||||
class TestRenderSuccess:
|
||||
def test_fetch_url_delegates_to_worker(self, monkeypatch):
|
||||
def test_fetch_url_delegates_to_worker(self, monkeypatch, no_dns):
|
||||
captured = {}
|
||||
|
||||
def fake_render(url):
|
||||
@@ -51,7 +64,7 @@ class TestRenderSuccess:
|
||||
assert out["rendered"] is True
|
||||
assert "dynamic content" in out["text"]
|
||||
|
||||
def test_worker_failure_maps_to_tool_error(self, monkeypatch):
|
||||
def test_worker_failure_maps_to_tool_error(self, monkeypatch, no_dns):
|
||||
monkeypatch.setattr(webrender, "_playwright_available", lambda: True)
|
||||
|
||||
def boom(url):
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Workbook dashboard: named ranges, chart/pivot objects and per-sheet KPI
|
||||
stats (#153 A17), plus the ``GET …/xlsx/dashboard`` endpoint wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
VAULT = "TestVault"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_book(test_vault_dir: str) -> str:
|
||||
"""A workbook with a named range, a chart and numeric KPI cells."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.chart import BarChart, Reference
|
||||
from openpyxl.workbook.defined_name import DefinedName
|
||||
|
||||
path = Path(test_vault_dir) / "dash.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "Ventes"
|
||||
ws["A2"] = 12
|
||||
ws["A3"] = 48
|
||||
ws["B2"] = "=SUM(A2:A3)"
|
||||
wb.defined_names.add(DefinedName("MaPlage", attr_text="Data!$A$1:$B$5"))
|
||||
chart = BarChart()
|
||||
chart.add_data(Reference(ws, min_col=1, min_row=1, max_row=3))
|
||||
ws.add_chart(chart, "D2")
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plain_book(test_vault_dir: str) -> str:
|
||||
"""A workbook without any dashboard-worthy feature."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "plain.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Vide"
|
||||
ws["A1"] = "texte seul"
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
class TestDashboardReading:
|
||||
def test_named_range_is_detected(self, dash_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(dash_book))
|
||||
names = [r["name"] for r in dash["named_ranges"]]
|
||||
assert "MaPlage" in names
|
||||
entry = next(r for r in dash["named_ranges"] if r["name"] == "MaPlage")
|
||||
assert "Data!" in entry["ref"]
|
||||
|
||||
def test_charts_are_counted(self, dash_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(dash_book))
|
||||
assert dash["objects"]["charts"] >= 1
|
||||
assert dash["objects"]["pivots"] == 0
|
||||
|
||||
def test_sheet_kpis_carry_numeric_cells(self, dash_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(dash_book))
|
||||
sheet = next(s for s in dash["sheets"] if s["name"] == "Data")
|
||||
assert sheet["cells"] >= 4
|
||||
assert sheet["formulas"] == 1
|
||||
assert sheet["numeric"] >= 2
|
||||
values = [k["value"] for k in sheet["kpi"]]
|
||||
assert 12 in values and 48 in values
|
||||
|
||||
def test_plain_book_yields_empty_dashboard(self, plain_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(plain_book))
|
||||
assert dash["named_ranges"] == []
|
||||
assert dash["objects"]["charts"] == 0
|
||||
sheet = dash["sheets"][0]
|
||||
assert sheet["numeric"] == 0
|
||||
assert sheet["kpi"] == []
|
||||
|
||||
def test_broken_book_yields_empty_payload(self, test_vault_dir):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
bad = Path(test_vault_dir) / "broken-dash.xlsx"
|
||||
bad.write_bytes(b"not a zip")
|
||||
dash = read_workbook_dashboard(bad)
|
||||
assert dash["named_ranges"] == []
|
||||
assert dash["sheets"] == []
|
||||
|
||||
|
||||
class TestDashboardEndpoint:
|
||||
def test_endpoint_serves_the_dashboard(self, client, dash_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": dash_book})
|
||||
assert resp.status_code == 200 # sanity: file is readable
|
||||
dash = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": dash_book}
|
||||
).json()
|
||||
assert any(r["name"] == "MaPlage" for r in dash["named_ranges"])
|
||||
assert dash["objects"]["charts"] >= 1
|
||||
assert any(s["kpi"] for s in dash["sheets"])
|
||||
|
||||
def test_unknown_file_is_404(self, client):
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "nope.xlsx"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_non_workbook_is_415(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "texte.txt").write_text("hello", encoding="utf-8")
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "texte.txt"}
|
||||
)
|
||||
assert resp.status_code == 415
|
||||
Reference in New Issue
Block a user