From 31d4616baf90e46c58b5d8e49cf2ef851bfed731 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 27 Sep 2026 20:39:08 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20garde-fous=20d'=C3=A9criture=20des=20cl?= =?UTF-8?q?asseurs=20Excel=20#153=20(P0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'édition d'un .xlsx pouvait détruire une partie du classeur, le concurrencer en silence, ou diffuser une injection de formule. - BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd (valeurs calculées en cache, slicers, contrôles, connexions, custom XML, signature, commentaires enrichis, macros) → xlsx_lossy_features exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans `force` (confirmation explicite puis reprise). Périmètre réel revalidé : graphiques, images et TCD survivent au round-trip. - BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage ne peut plus tronquer le classeur, le backup reste intact. - BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s, 409 conflict) ; endpoint xlsx/save devenu synchrone pour que l'attente s'exécute dans le threadpool. - BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in `allow_formula` ou le bouton f(x) de la visionneuse. Le handler ServiceError expose désormais code + details, que api() propage. - BUG-084 : la suppression d'une vault purge enfin l'index inversé (documents fantômes qui continuaient de matcher) et is_stale() devient is_ready(), le nom étant trompeur (la staleness n'existe plus). Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI), 3 E2E Playwright, suite E2E complète verte, ruff/mypy 0. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .gitea/workflows/ci.yml | 4 +- .gitignore | 11 + CHANGELOG.md | 114 +++++++++- README.fr.md | 12 +- README.md | 12 +- VERSION | 2 +- backend/indexer.py | 6 + backend/main.py | 16 +- backend/openapi_docs.py | 2 +- backend/routers/config.py | 2 +- backend/routers/files_read.py | 5 +- backend/routers/files_write.py | 36 +++- backend/schemas.py | 8 + backend/search.py | 18 +- backend/semantic_search.py | 4 - backend/services/mutations.py | 140 +++++++++--- backend/xlsx_reader.py | 73 +++++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/GUIDES/PRISE_EN_MAIN.md | 6 +- docs/GUIDES/README.md | 2 +- docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md | 59 ++++- docs/ISSUES_TODOLIST.md | 8 +- docs/ROADMAP.md | 54 ++++- docs/features/xlsx-viewer.md | 201 +++++++++++++++++ frontend/js/auth.js | 13 +- frontend/js/config.js | 2 +- frontend/js/viewer.js | 79 ++++++- frontend/locales/en.json | 13 ++ frontend/locales/fr.json | 13 ++ frontend/style.css | 60 ++++++ frontend/sw.js | 2 +- package.json | 2 +- plan.md | 70 +++++- test_vault/sample-xlsx-lossy.xlsx | Bin 0 -> 5002 bytes tests/e2e/xlsx-viewer.spec.js | 120 +++++++++++ tests/frontend/xlsx-viewer.test.mjs | 273 ++++++++++++++++++++++++ tests/test_search_advanced.py | 56 +++++ tests/test_xlsx_viewer.py | 211 +++++++++++++++++- 40 files changed, 1628 insertions(+), 87 deletions(-) create mode 100644 docs/features/xlsx-viewer.md create mode 100644 test_vault/sample-xlsx-lossy.xlsx create mode 100644 tests/e2e/xlsx-viewer.spec.js create mode 100644 tests/frontend/xlsx-viewer.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f0f1768..bd1ee02 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: node tests/frontend/media-viewer.test.mjs node tests/frontend/mfa-settings.test.mjs - - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload) + - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload + XLSX) run: | cd tests/frontend if [ -d node_modules ]; then @@ -68,6 +68,7 @@ jobs: node ai-quick-actions.test.mjs node upload.test.mjs node config-ai-keys.test.mjs + node xlsx-viewer.test.mjs else echo "tests/frontend/node_modules missing - installing jsdom" npm install --no-audit --no-fund --silent @@ -87,6 +88,7 @@ jobs: node ai-quick-actions.test.mjs node upload.test.mjs node config-ai-keys.test.mjs + node xlsx-viewer.test.mjs fi # ── Tests ───────────────────────────────────────────────────────── diff --git a/.gitignore b/.gitignore index 064bd72..077f19f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,17 @@ backend/VERSION # Artefacts générés par les runs E2E (excalidraw crée ces diagrammes) test_vault/IT/e2e-diagram-*.excalidraw +# Fixtures de test locales non versionnées (~200 Mo, pas de fixture CI). +# Aucun test/CI ne les référence : les tests unitaires génèrent leurs fixtures +# dans tmp_path (tests/conftest.py), et l'E2E n'utilise que les fixtures +# committées (test_vault/sample-*.{mp3,png,svg,webm,pdf}, test_dir/*.md). +# → à committer volontairement : `git add -f `. +test_dir/music/ +test_dir/video/ +test_vault/images/ +test_vault/markdown/ +test_vault/budget.xlsx + # Tauri updater signing keys (private key — never commit) desktop/*.key desktop/*.key.pub diff --git a/CHANGELOG.md b/CHANGELOG.md index af54b0f..7d89465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.28.16**. +> [Unreleased](#unreleased). La dernière version livrée est **2.29.0**. --- @@ -14,6 +14,118 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.29.0] — 2026-09-27 + +### Correction + +- **BUG-084 — l'index inversé conservait des documents fantômes après la + suppression d'une vault.** `remove_vault_from_index()` + (`backend/indexer.py`) ne notifiait pas le hook incrémental : après + suppression d'une vault, ses documents restaient dans l'index inversé + (`postings`, `doc_info`, `doc_vault`, `vault_docs`) et continuaient de + correspondre aux recherches pour une vault inexistante — seul un reindex + manuel les effaçait. Le correctif déclenche + `_on_index_change('remove', …)` pour chaque fichier de la vault, et + `_remove_doc_internals()` supprime désormais la clé `vault_docs` dont le set + devient vide (c'est un `defaultdict` : une lecture la recréait). + Test : `TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le + correctif). + +### Maintenance + +- **Index inversé — `is_stale()` renommé `is_ready()`.** La relecture de + `plan.md` a établi que les étapes 6 et 7 (suppression du cooldown et du hack + de coalescence) étaient **déjà livrées** : ni `_last_rebuild`, ni + `_rebuild_cooldown`, ni `_source_generation`, ni `_on_vault_change` ne + subsistent. `is_stale()` ne mesurait donc plus aucune staleness — il + indiquait seulement si l'index initial était construit, sous un nom + trompeur. Renommé `is_ready()`, cohérent avec le `is_ready()` de + `SemanticIndex` ; l'alias `is_stale()` de `SemanticIndex`, sans appelant, est + supprimé. `/api/diagnostics` expose désormais `is_ready` (libellé « Index + prêt » côté `frontend/js/config.js`). Tests : + `test_is_ready_tracks_initial_build`, `test_is_ready_survives_incremental_updates`. + +- **`plan.md` recalibré.** Le fichier est désormais marqué « livré » et + suivi d'une section « État réel » : le code a divergé du plan sur quatre + points (pas de repli `_needs_rebuild`, `_ready` au lieu de `doc_count == 0`, + `rebuild()` conservé au démarrage, `is_stale()` repurposé). Les extraits de + code du plan sont explicitement signalés comme ne décrivant pas le code + actuel. + +- **Fixtures de test locales exclues du suivi Git.** `test_dir/music/`, + `test_dir/video/`, `test_vault/images/`, `test_vault/markdown/` et + `test_vault/budget.xlsx` (~200 Mo) sont ajoutés au `.gitignore` : aucun test + ni job CI ne les référence — les tests unitaires génèrent leurs fixtures dans + `tmp_path` et l'E2E n'utilise que les fixtures committées + (`test_vault/sample-*.{mp3,png,svg,webm,pdf}`, `test_dir/*.md`). Ils + restaient non suivis et polluaient `git status`. + +### Sécurité + +- **BUG-088 — plus d'injection de formule via la visionneuse Excel.** Une + saisie `=cmd|'/c calc'!A1` (ou `@…`) était stockée comme **formule** par + openpyxl, donc exécutée par Excel à la réouverture du fichier (DDE). + `edit_xlsx_cells` force maintenant le type texte (`cell.data_type = "s"`) + pour toute valeur commençant par `=` ou `@` ; l'API accepte + `allow_formula: true` et la visionneuse expose un bouton `f(x)` + (opt-in, état de session, jamais persisté). `+`/`-` restent des nombres. +- **BUG-087 — écriture concurrente d'un classeur.** `load_workbook()` → + `save()` n'était pas sérialisé : deux sauvegardes simultanées (deux + onglets, l'agent IA et la visionneuse) faisaient gagner la dernière, en + silence. Verrou par chemin (`backend/services/mutations.py::_xlsx_write_lock`, + timeout 15 s) autour du cycle lecture → édition → remplacement ; attente + dépassée → **409** `conflict`. L'endpoint `PUT …/xlsx/save` est devenu + synchrone pour que l'attente s'exécute dans le threadpool. + +### Corrigé + +- **BUG-085 — la perte de données à l'enregistrement d'un `.xlsx` est + annoncée, plus silencieuse.** `GET /api/file/{vault}` renvoie + `xlsx_lossy_features` (éléments qu'un round-trip openpyxl perd) ; la + visionneuse affiche un bandeau listant ces éléments et la première + sauvegarde demande confirmation avant de renvoyer `force: true`. Sans + `force`, l'API répond **409** `xlsx_lossy_content` avec + `details.features`. Périmètre **remesuré** sur openpyxl 3.1.5 : graphiques, + images, dessins et tableaux croisés sont bien préservés ; sont perdus les + valeurs calculées en cache, slicers/chronologies, contrôles de formulaire, + connexions/requêtes, custom XML, signature numérique, commentaires + enrichis et macros. +- **BUG-086 — écriture atomique des classeurs.** `wb.save()` écrivait en + place sur le fichier du vault : un plantage laissait un `.xlsx` tronqué. + L'écriture passe désormais par un `.tmp` puis `os.replace()` (le backup + `.bak` est inchangé, le `.tmp` est ignoré par le watcher). +- Le handler global `ServiceError` expose maintenant `code` et `details` dans + la réponse JSON, et `api()` (frontend) les propage sur l'Error — nécessaire + pour que le client distingue un 409 de confirmation d'une autre erreur. + +### Ajouté + +- **#153 (P0) — tests de la visionneuse Excel.** + `tests/frontend/xlsx-viewer.test.mjs` (10 tests JSDOM : bannière, + confirmation + reprise `force`, refus, toggle `f(x)`, payload de + sauvegarde) et `tests/e2e/xlsx-viewer.spec.js` (3 tests Playwright sur la + fixture `test_vault/sample-xlsx-lossy.xlsx`) ; la suite JSDOM est branchée + dans le CI. + +### Documentation + +- **#153 — Visionneuse & édition XLSX : audit complet et backlog de + complétude.** La visionneuse `.xlsx` livrée par #152 a été auditée couche + par couche (lecture `backend/xlsx_reader.py`, écriture + `backend/services.mutations.edit_xlsx_cells`, UI `renderXlsxViewer`, + indexation, outils IA, tests). Bilan : la grille de valeurs est éditée + correctement (sécurité, backup, audit, échappement HTML), mais l'ensemble + supporté est étroit, une partie du classeur est perdue à l'enregistrement, + les tableurs sont **invisibles pour la recherche** et l'IA ne sait que les + **créer**. Ouverture de l'item **#153** dans `docs/ROADMAP.md` (17 + sous-tâches suivies **A1 → A17** ; **P0 livré**, reste P1 recherche/IA/UX + puis P2 étendu) et création de la fiche + [docs/features/xlsx-viewer.md](docs/features/xlsx-viewer.md) : cartographie + du code, limites par couche, tableau des risques R1-R5 et critères + d'acceptation par sous-tâche. + +--- + ## [2.28.16] — 2026-09-27 --- diff --git a/README.fr.md b/README.fr.md index 33292d5..3357061 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.28.16-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.29.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -23,7 +23,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G | Guide | Contenu | |---|---| | 🚀 [Prise en main](docs/GUIDES/PRISE_EN_MAIN.md) | Premier lancement, interface, navigation, vaults, raccourcis | -| 🔍 [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes | +| 🔍 [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteurs PDF/Excel, diagrammes | | 🤖 [Assistant IA & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` | | 📝 [Édition & collaboration](docs/GUIDES/COLLABORATION.md) | Édition simultanée, curseurs distants, persistance | | 📱 [PWA & hors-ligne](docs/GUIDES/PWA_HORS_LIGNE.md) | Installation, cache hors-ligne, file de synchro, notifications | @@ -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), plus le téléchargement du fichier d'origine +- **📊 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)` - **🎨 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 @@ -673,7 +673,7 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md" ## 🔍 Recherche avancée -> 📖 Guide complet : [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) +> 📖 Guide complet : [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) ### Syntaxe de requête @@ -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.28.16). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.29.0). --- -*Projet : ObsiGate | Version : 2.28.16 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.29.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 70c7c1a..8ba4248 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.28.16-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.29.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -21,7 +21,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/): | Guide | What it covers | |---|---| | 🚀 [Getting Started](docs/GUIDES/PRISE_EN_MAIN.md) | First run, interface, navigation, vaults, shortcuts | -| 🔍 [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF viewer, diagrams | +| 🔍 [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF/Excel viewers, diagrams | | 🤖 [AI Assistant & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Providers, AI editor, BooksLM, Forge, `@` / `/` commands | | 📝 [Editing & Collaboration](docs/GUIDES/COLLABORATION.md) | Simultaneous editing, remote cursors, persistence | | 📱 [PWA & Offline](docs/GUIDES/PWA_HORS_LIGNE.md) | Install as an app, offline cache, sync queue, push | @@ -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), plus download of the original file +- **📊 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 - **🎨 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 @@ -804,7 +804,7 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md" ## 🔍 Advanced Search -> 📖 Full guide: [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) +> 📖 Full guide: [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) ### Query Syntax @@ -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.28.16). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.29.0). --- -*Project: ObsiGate | Version: 2.28.16 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.29.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 935e364..f013568 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.28.16 +2.29.0 diff --git a/backend/indexer.py b/backend/indexer.py index b9df90c..6371229 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -1226,6 +1226,12 @@ async def remove_vault_from_index(vault_name: str): if not _file_lookup[key]: _file_lookup.pop(key, None) + # Notify the inverted index, otherwise every document of the vault + # stays in it as a ghost (postings, doc_info, doc_vault, vault_docs) + # and keeps matching searches for a vault that no longer exists. + if _on_index_change: + _on_index_change('remove', vault_name, rel_path, f) # type: ignore[misc] + # Clean path_index path_index.pop(vault_name, None) diff --git a/backend/main.py b/backend/main.py index fcc3475..d5b38e4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -389,8 +389,20 @@ app.openapi = _custom_openapi # type: ignore[method-assign] @app.exception_handler(ServiceError) async def _service_error_handler(request: Request, exc: ServiceError): - """Map shared-layer domain errors to HTTP responses (``{"detail": ...}``).""" - return JSONResponse(status_code=exc.status, content={"detail": exc.message}) + """Map shared-layer domain errors to HTTP responses (``{"detail": ...}``). + + ``code`` and ``details`` travel with the message so the client can react to + a specific case instead of parsing prose (#153 A1 : ``xlsx_lossy_content`` + asks the viewer to confirm before forcing a lossy write). + """ + return JSONResponse( + status_code=exc.status, + content={ + "detail": exc.message, + "code": exc.code, + "details": exc.details, + }, + ) # GZip compression — reduces bandwidth by ~70% for text responses # Custom wrapper: skip compression for SSE streams (/api/events) diff --git a/backend/openapi_docs.py b/backend/openapi_docs.py index bedcdaa..432aa1e 100644 --- a/backend/openapi_docs.py +++ b/backend/openapi_docs.py @@ -182,7 +182,7 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = { "response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26}, }, ("put", "/api/file/{vault_name}/xlsx/save"): { - "request": {"sheet": "Budget", "cells": {"B1": "250"}}, + "request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False}, "response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1}, }, ("post", "/api/search/replace"): { diff --git a/backend/routers/config.py b/backend/routers/config.py index 6669916..f8f34a6 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -481,7 +481,7 @@ async def api_diagnostics(current_user=Depends(require_admin)): "total_postings": word_index_entries, "documents": inv.doc_count, "sorted_tokens": len(inv._sorted_tokens), - "is_stale": inv.is_stale(), + "is_ready": inv.is_ready(), "memory_estimate_mb": mem_estimate_mb, }, "config": _load_config(), diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index 20b6f63..95990a6 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -241,7 +241,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative # === Excel .xlsx: render sheets as HTML tables (binary, before read_text) === if ext == ".xlsx": try: - from backend.xlsx_reader import render_sheets + from backend.xlsx_reader import inspect_workbook, render_sheets sheets = render_sheets(file_path) size = file_path.stat().st_size @@ -257,6 +257,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative "is_markdown": False, "is_xlsx": True, "xlsx_sheets": sheets, + # #153 A1 — parts a save would drop; the viewer warns and asks + # for an explicit confirmation before forcing the write. + "xlsx_lossy_features": inspect_workbook(file_path), "unsupported": False, "size_bytes": size, } diff --git a/backend/routers/files_write.py b/backend/routers/files_write.py index 49f0132..9c22f97 100644 --- a/backend/routers/files_write.py +++ b/backend/routers/files_write.py @@ -114,17 +114,35 @@ async def api_file_save( @router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse) -async def api_file_xlsx_save( +def api_file_xlsx_save( vault_name: str, path: str = Query(..., description="Relative path to the .xlsx file"), - body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'), + body: dict = Body( + ..., + description=( + '{"sheet": str, "cells": {"A1": value}, ' + '"allow_formula": false, "force": false}' + ), + ), current_user=Depends(require_auth), ): """Apply cell edits to an .xlsx workbook. Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new - scalar values, max 500 per request). A backup is created before the - workbook is rewritten. + scalar values, max 500 per request) plus two optional boolean flags: + + * ``allow_formula`` — keep values starting with ``=``/``@`` as real + formulas. Off by default (#153 A4): such a value is stored as text so a + later Excel session cannot execute it (DDE). + * ``force`` — write a workbook carrying features openpyxl cannot re-serialize + (slicers, form controls, connections, custom XML, signature, cached formula + results). Without it the call fails **409** ``xlsx_lossy_content`` and the + client asks the user to confirm (#153 A1). + + A backup is created before the workbook is rewritten, and the new archive + swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl + round-trip and the per-file lock wait (#153 A3) then run in the threadpool + instead of blocking the event loop. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") @@ -138,8 +156,16 @@ async def api_file_xlsx_save( for ref, value in cells.items(): if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))): raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}") + flags: dict[str, bool] = {} + for name in ("allow_formula", "force"): + raw = body.get(name, False) + if not isinstance(raw, bool): + raise HTTPException(status_code=400, detail=f"Flag invalide: {name}") + flags[name] = raw - result = service_edit_xlsx_cells(vault_name, path, sheet, cells) + result = service_edit_xlsx_cells( + vault_name, path, sheet, cells, **flags + ) log_file_save( current_user["username"], vault_name, path, sum(len(str(v)) for v in cells.values()), diff --git a/backend/schemas.py b/backend/schemas.py index a1a1b1c..df8c845 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -288,6 +288,14 @@ class FileContentResponse(BaseModel): xlsx_sheets: list[dict[str, Any]] | None = Field( default=None, description="Rendered xlsx sheets [{name, html}]" ) + xlsx_lossy_features: list[str] | None = Field( + default=None, + description=( + "Workbook parts an openpyxl save would drop (#153 A1) — e.g. " + "cached_values, slicers, form_controls, connections, custom_xml, " + "signature, rich_comments, macros. Empty/absent = nothing at risk." + ), + ) is_json: bool | None = Field(default=None, description="True for JSON files") is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files") excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)") diff --git a/backend/search.py b/backend/search.py index 180029b..ab4072d 100644 --- a/backend/search.py +++ b/backend/search.py @@ -371,9 +371,15 @@ class InvertedIndex: self._sorted_tokens: SortedList = SortedList() self._ready: bool = False # True after initial build - def is_stale(self) -> bool: - """Return True if the index has not been built yet.""" - return not self._ready + def is_ready(self) -> bool: + """Return True once the initial build has completed. + + The index is then kept current incrementally by ``add_document()`` / + ``remove_document()``, so it never goes stale: there is no generation + counter, no cooldown and no lazy rebuild. Searches simply fall back to + a full scan while this is False (see ``search()``). + """ + return self._ready def rebuild(self) -> None: """Rebuild inverted index from the global ``index`` dict. @@ -537,6 +543,10 @@ class InvertedIndex: self.doc_vault.pop(doc_key, None) if vault_name in self.vault_docs: self.vault_docs[vault_name].discard(doc_key) + # Drop the empty entry so a fully removed vault leaves no trace + # (it is a defaultdict: a bare lookup would recreate the key). + if not self.vault_docs[vault_name]: + del self.vault_docs[vault_name] # Tags (per-document, NOT the global tag_norm_map) for tag in file_info.get("tags", []): td = self.tag_docs.get(tag.lower()) @@ -739,7 +749,7 @@ def search( results: list[dict[str, Any]] = [] inv = get_inverted_index() - use_index = (not inv.is_stale()) and inv.doc_count > 0 + use_index = inv.is_ready() and inv.doc_count > 0 if use_index: # BUG-033: retrieve candidates from the inverted index instead of diff --git a/backend/semantic_search.py b/backend/semantic_search.py index 7bfceb3..6d65160 100644 --- a/backend/semantic_search.py +++ b/backend/semantic_search.py @@ -457,10 +457,6 @@ class SemanticIndex: """Return True once a full rebuild has completed.""" return self._ready - def is_stale(self) -> bool: - """Alias used by callers that check index freshness.""" - return not self._ready - def _ensure_provider(self) -> EmbeddingProvider: if self.provider is None: self.provider = get_embedding_provider() diff --git a/backend/services/mutations.py b/backend/services/mutations.py index ff3420f..f2e2aaf 100644 --- a/backend/services/mutations.py +++ b/backend/services/mutations.py @@ -16,7 +16,9 @@ import logging import os import re import shutil -from collections.abc import Callable +import threading +from collections.abc import Callable, Iterator +from contextlib import contextmanager from pathlib import Path from typing import Any @@ -230,6 +232,41 @@ _XLSX_CELL_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,7}$") # number; dates/booleans stay text (upgrade path: parse locale dates too). _XLSX_INT_RE = re.compile(r"^[+-]?\d+$") _XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$") +# #153 A4 — openpyxl turns any string starting with "=" into a formula, which +# Excel then evaluates on open (DDE / =cmd|… / =HYPERLINK exfiltration). "@" is +# the legacy Lotus-style trigger. "+"/"-" are left alone: they are numbers here. +_XLSX_FORMULA_RE = re.compile(r"^[=@]") + +# #153 A3 — per-file write lock. Two concurrent saves (two tabs, the AI agent +# and the viewer, a watcher restore) would otherwise read-modify-write on the +# same archive and the last writer silently wins. Kept deliberately small: the +# lock only covers the load → edit → atomic-replace window. +_XLSX_LOCK_TIMEOUT = 15.0 +_xlsx_locks: dict[str, threading.Lock] = {} +_xlsx_locks_guard = threading.Lock() + + +@contextmanager +def _xlsx_write_lock(key: str) -> Iterator[None]: + """Serialize the read-modify-write of one workbook path. + + Raises: + ServiceError: ``conflict`` (409) when the lock is still held after + :data:`_XLSX_LOCK_TIMEOUT` seconds. + """ + with _xlsx_locks_guard: + lock = _xlsx_locks.setdefault(key, threading.Lock()) + if not lock.acquire(timeout=_XLSX_LOCK_TIMEOUT): + raise ServiceError( + "Workbook is being modified by another operation, retry shortly", + code="conflict", + status=409, + details={"path": key, "timeout_seconds": _XLSX_LOCK_TIMEOUT}, + ) + try: + yield + finally: + lock.release() def _coerce_xlsx_value(value: Any) -> Any: @@ -246,6 +283,19 @@ def _coerce_xlsx_value(value: Any) -> Any: return value +def _write_cell(ws: Any, ref: str, value: Any, *, allow_formula: bool) -> None: + """Assign one cell, forcing text when it looks like a formula. + + ``cell.data_type = "s"`` is what stops openpyxl from emitting ````: the + text is then stored as an inline/shared string and Excel shows it verbatim. + """ + cell = ws[ref] + coerced = _coerce_xlsx_value(value) + cell.value = coerced + if not allow_formula and isinstance(coerced, str) and _XLSX_FORMULA_RE.match(coerced): + cell.data_type = "s" + + def edit_xlsx_cells( vault_name: str, path: str, @@ -253,15 +303,29 @@ def edit_xlsx_cells( cells: dict[str, Any], *, backup: bool = True, + allow_formula: bool = False, + force: bool = False, ) -> dict[str, Any]: """Apply a batch of cell edits to an ``.xlsx`` workbook. - Raises: - ServiceError: ``not_found`` (404), ``read_only`` (403) or - ``invalid`` (400) for a bad sheet, cell reference or value. + Args: + vault_name: Name of the vault the workbook belongs to. + path: Vault-relative path of the ``.xlsx`` file. + sheet: Worksheet title to edit. + cells: Mapping of A1 references to new scalar values. + backup: Create a timestamped ``.bak`` before rewriting the archive. + allow_formula: Keep values starting with ``=``/``@`` as real formulas. + Off by default (#153 A4): a typed ``=cmd|…`` is a DDE payload when + the file is later opened in Excel. + force: Write even when the workbook carries features openpyxl drops + (slicers, form controls, connections, custom XML, signature, cached + formula results — see :data:`backend.xlsx_reader.LOSSY_PARTS`). - ponytail: openpyxl round-trips values/formulas/styles but drops charts, - images and pivot tables; use the SheetJS path if a workbook needs those. + Raises: + ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict`` + (409, concurrent write), ``xlsx_lossy_content`` (409, a lossy write was + attempted without ``force``) or ``invalid`` (400) for a bad sheet, cell + reference or value. """ root = get_vault_root(vault_name) _ensure_writable(root) @@ -286,30 +350,52 @@ def edit_xlsx_cells( f"Invalid cell reference: {ref!r}", code="invalid", status=400 ) - from openpyxl import load_workbook + if not force: + from backend.xlsx_reader import inspect_workbook - try: - wb = load_workbook(file_path) - except Exception as exc: - raise ServiceError( - f"Cannot open workbook: {exc}", code="invalid", status=400 - ) from exc - if sheet not in wb.sheetnames: - raise ServiceError( - f"Unknown sheet: {sheet}", - code="invalid", - status=400, - details={"sheets": wb.sheetnames}, - ) + lossy = inspect_workbook(file_path) + if lossy: + raise ServiceError( + "Saving this workbook would drop features ObsiGate cannot " + "preserve; retry with force=true after confirmation", + code="xlsx_lossy_content", + status=409, + details={"path": path, "features": lossy}, + ) - rel_path = _rel(root, file_path) - if backup: - create_backup(file_path, vault_name, rel_path) + with _xlsx_write_lock(str(file_path)): + from openpyxl import load_workbook - ws = wb[sheet] - for ref, value in cells.items(): - ws[ref].value = _coerce_xlsx_value(value) - wb.save(file_path) + try: + wb = load_workbook(file_path) + except Exception as exc: + raise ServiceError( + f"Cannot open workbook: {exc}", code="invalid", status=400 + ) from exc + if sheet not in wb.sheetnames: + raise ServiceError( + f"Unknown sheet: {sheet}", + code="invalid", + status=400, + details={"sheets": wb.sheetnames}, + ) + + rel_path = _rel(root, file_path) + if backup: + create_backup(file_path, vault_name, rel_path) + + ws = wb[sheet] + for ref, value in cells.items(): + _write_cell(ws, ref, value, allow_formula=allow_formula) + # #153 A2 — write beside the target then swap: a crash mid-save leaves + # the original workbook intact instead of a truncated archive. + tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp") + try: + wb.save(tmp_path) + os.replace(tmp_path, file_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}") return { diff --git a/backend/xlsx_reader.py b/backend/xlsx_reader.py index 250f1c7..4bee9d2 100644 --- a/backend/xlsx_reader.py +++ b/backend/xlsx_reader.py @@ -3,11 +3,16 @@ Read-only: formulas are shown as their text (``data_only=False``) so a round-trip through the viewer never depends on Excel's cached values. Write-side lives in ``backend.services.mutations.edit_xlsx_cells``. + +:func:`inspect_workbook` lists the workbook features that an openpyxl +round-trip would drop (#153 A1) so the UI can warn before saving. """ from __future__ import annotations import html +import re +import zipfile from datetime import date, datetime from pathlib import Path from typing import Any @@ -20,6 +25,29 @@ from openpyxl.utils import get_column_letter MAX_ROWS = 500 MAX_COLS = 40 +# #153 A1 — workbook parts openpyxl does not re-serialize on load+save. +# Verified against openpyxl 3.1.5: charts, images, drawings and pivot tables +# DO survive the round-trip, so they are deliberately absent from this map. +LOSSY_PARTS: dict[str, tuple[str, ...]] = { + "slicers": ("xl/slicers/", "xl/slicerCaches/", "xl/timelines/"), + "form_controls": ("xl/ctrlProps/", "xl/activeX/"), + "connections": ("xl/queryTables/", "xl/connections.xml"), + "custom_xml": ("customXml/",), + "signature": ("_xmlsignatures/",), + "rich_comments": ("xl/threadedComments/", "xl/persons/"), + "macros": ("xl/vbaProject.bin",), +} + +# A formula cell carrying its last computed result: ``……``. +# openpyxl writes an EMPTY ```` itself, hence the ``[^<]`` guard: only a +# non-empty value counts. openpyxl keeps the formula but drops the cached result, +# so any reader using ``data_only=True`` (pandas, converters) sees ``None`` until +# Excel recalculates. +_CACHED_FORMULA_RE = re.compile(rb"][^<]*\s*[^<]") + +# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES). +_MAX_PROBE_BYTES = 8_000_000 + def _fmt(value: Any) -> str: if value is None: @@ -68,6 +96,51 @@ def _table(grid: list[list[str]]) -> str: return "".join(out) +def _has_cached_formulas(zf: zipfile.ZipFile) -> bool: + """True when at least one formula cell still carries its computed value.""" + budget = _MAX_PROBE_BYTES + for name in zf.namelist(): + if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"): + continue + try: + with zf.open(name) as fh: + while budget > 0: + chunk = fh.read(65536) + if not chunk: + break + budget -= len(chunk) + if _CACHED_FORMULA_RE.search(chunk): + return True + except (KeyError, OSError, zipfile.BadZipFile): + continue + return False + + +def inspect_workbook(file_path: Path) -> list[str]: + """Return the sorted keys of :data:`LOSSY_PARTS` present in *file_path*. + + Read-only inspection of the OPC package (central directory + a bounded scan + of the sheet XML). Never raises: an unreadable or encrypted workbook simply + yields ``[]`` and the save path keeps its current behaviour. + + ``cached_values`` is a synthetic key: openpyxl keeps the formula but drops + the cached result, so the workbook stays correct once Excel recalculates it. + """ + try: + with zipfile.ZipFile(file_path) as zf: + names = set(zf.namelist()) + found = { + key + for key, prefixes in LOSSY_PARTS.items() + if any(name.startswith(prefix) for name in names for prefix in prefixes) + } + if _has_cached_formulas(zf): + found.add("cached_values") + return sorted(found) + except (OSError, zipfile.BadZipFile): + return [] + + def render_sheets(file_path: Path) -> list[dict[str, str]]: """Return ``[{"name": sheet_title, "html": table_html}, ...]``.""" wb = load_workbook(str(file_path), read_only=True, data_only=False) diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index fcc2b25..b9cf9ae 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.28.16" +version = "2.29.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 009096f..ad346dc 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.28.16" +version = "2.29.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 9e5d2dc..9b21c97 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.28.16", + "version": "2.29.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/PRISE_EN_MAIN.md b/docs/GUIDES/PRISE_EN_MAIN.md index 9198642..dc531ef 100644 --- a/docs/GUIDES/PRISE_EN_MAIN.md +++ b/docs/GUIDES/PRISE_EN_MAIN.md @@ -6,7 +6,7 @@ vaults Obsidian et raccourcis essentiels. > **Public :** tous les utilisateurs · **Durée de lecture :** ~10 min > **Voir aussi :** [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) · -> [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) · +> [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) · > [API REST](./API_REST.md) --- @@ -185,7 +185,7 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view). La recherche est un point fort d'ObsiGate : index inversé TF-IDF, stemming français, normalisation des accents, facettes et pagination. La syntaxe complète (`tag:`, `#`, `vault:`, `title:`, `path:`, `ext:`, phrases exactes) est décrite -dans le [Guide Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md). +dans le [Guide Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md). Démarrage rapide : @@ -234,7 +234,7 @@ Voir [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md). | Objectif | Guide | |---|---| -| Mieux chercher, lire PDF et Excalidraw | [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | +| Mieux chercher, lire PDF/Excel et Excalidraw | [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | | Utiliser l'IA intégrée | [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) | | Éditer à plusieurs | [Édition & collaboration](./COLLABORATION.md) | | Sécuriser l'accès | [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) | diff --git a/docs/GUIDES/README.md b/docs/GUIDES/README.md index f2c9571..2c5b6c9 100644 --- a/docs/GUIDES/README.md +++ b/docs/GUIDES/README.md @@ -15,7 +15,7 @@ captures conceptuelles). | Guide | Public | Contenu | |---|---|---| | 🚀 [Prise en main](./PRISE_EN_MAIN.md) | Tous | Premier lancement, interface, navigation, vaults, raccourcis | -| 🔍 [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes | +| 🔍 [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteurs PDF/Excel, diagrammes | | 🤖 [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) | Tous | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` | | 📝 [Édition & collaboration](./COLLABORATION.md) | Tous | Édition simultanée, curseurs distants, persistance | | 📱 [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md) | Tous | Installation PWA, cache, file de synchronisation, notifications | diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index 5a239f9..ba8c67b 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -1,4 +1,4 @@ -# 🔍 Guide Recherche, PDF & Excalidraw +# 🔍 Guide Recherche, PDF, Excel & Excalidraw ObsiGate va au-delà de la simple lecture : recherche puissante, rendu des documents riches (PDF, diagrammes) et indexation de leur contenu pour que tout @@ -131,7 +131,58 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf" --- -## 6. Diagrammes Excalidraw +## 6. Tableurs Excel (XLSX) + +### 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 +**atomique** (le classeur n'est jamais laissé à moitié écrit). + +### Avertissement avant enregistrement + +Certains classeurs contiennent des éléments qu'ObsiGate ne sait pas +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. + +> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés. + +### Formules + +Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme +texte** (une formule injectée s'exécuterait à l'ouverture du fichier dans +Excel). Le bouton `f(x)` de la barre d'outils active les vraies formules pour +la session en cours. + +```bash +curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx" -H "Content-Type: application/json" -d '{"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": false, "force": false}' +``` + +- `allow_formula` : `true` pour écrire une vraie formule (`=B1*2`). +- `force` : `true` pour enregistrer malgré les éléments non préservés + (sinon l'API répond **409** `xlsx_lossy_content`). +- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit + **409** `conflict` au lieu d'écraser la première. + +### Limites + +- Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille, sans + pagination : au-delà, le contenu n'est pas affiché (et non éditable). +- Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas + rendus ; le contenu des tableurs n'est pas non plus indexé pour la + recherche (contrairement aux PDF). +- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`. + +--- + +## 7. Diagrammes Excalidraw Les fichiers `.excalidraw` et `.excalidraw.md` (dont le format compressé du **plugin Obsidian Excalidraw**) s'ouvrent dans un **éditeur visuel Excalidraw @@ -147,7 +198,7 @@ Fiche technique : [`features/excalidraw.md`](../features/excalidraw.md). --- -## 7. Autres contenus riches +## 8. Autres contenus riches ### Mermaid @@ -182,7 +233,7 @@ curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes" --- -## 8. Dépannage +## 9. Dépannage | Symptôme | Piste | |---|---| diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index dfb5a89..1aef536 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -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-24 +- **Dernière mise à jour** : 2026-09-27 --- @@ -190,6 +190,11 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire | | *BUG-081* | `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée (`user` None, `AttributeError` sur `user.get`) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/auth/router.py::mfa_status`, `tests/test_mfa.py` | Auth désactivée : `curl http://127.0.0.1:2029/api/auth/mfa/status` → 500 (reproduit live 2026-09-27) | Garde `user is None` → payload MFA désactivé (`mfa_enabled: false`, `totp_enabled: false`, `webauthn_credentials: 0`) ; test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0 | `require_auth` laisse passer le pseudo-user anonymous, `get_user(username)` → None non gardé. Trouvé via les logs E2E pendant BUG-080 | | *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 | +| *BUG-084* | Index inversé : la suppression d'une vault y laisse des documents fantômes (résultats pour une vault inexistante) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/indexer.py::remove_vault_from_index`, `backend/search.py::_remove_doc_internals` | Supprimer une vault configurée, puis chercher un terme contenu dans ses fichiers → les résultats la concernent encore | `remove_vault_from_index()` déclenche `_on_index_change('remove', …)` pour chaque fichier de la vault ; `_remove_doc_internals()` supprime la clé `vault_docs` dont le set devient vide (`defaultdict` : une lecture la recréait). Test `tests/test_search_advanced.py::TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le correctif) | Trouvé pendant la relecture de `plan.md` (étape 6 déjà livrée). Mesuré : 8 documents fantômes sur 8 après suppression de la vault de test (`postings`, `doc_info`, `doc_vault`, `vault_docs`) ; seul un reindex manuel les effaçait. Vérifié : `test_search_advanced.py` 27 passed, ruff/mypy 0, suite complète 1374 passed / 6 skipped | +| *BUG-085* | Édition d'un `.xlsx` : les valeurs calculées en cache disparaissent du classeur (et tout lecteur `data_only=True` voit `None`) | 🟢 corrigé | P1 | tableur Excel | IA | `backend/xlsx_reader.py::inspect_workbook`, `backend/services/mutations.py::edit_xlsx_cells`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | Ouvrir un classeur contenant `=B1*2` (avec sa valeur calculée) → éditer une cellule → le `` disparaît du XML de la feuille | `LOSSY_PARTS` + sonde `…[^<]` ; la lecture renvoie `xlsx_lossy_features` ; `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`) ; bandeau + confirmation UI puis reprise `force: true`. Tests : `TestXlsxLossyGuard` (5) + `xlsx-viewer.test.mjs` (10) + `tests/e2e/xlsx-viewer.spec.js` (3) | #153 A1. Périmètre réel vérifié sur openpyxl 3.1.5 : graphiques, images, dessins **et** TCD survivent au round-trip ; les pertes sont valeurs en cache, slicers/chronologies, contrôles de formulaire, connexions/requêtes, custom XML, signature, commentaires enrichis, macros. Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, E2E 3/3 | +| *BUG-086* | Édition d'un `.xlsx` : `wb.save()` écrit en place, un plantage laisse un classeur corrompu | 🟢 corrigé | P1 | tableur Excel | IA | `backend/services/mutations.py::edit_xlsx_cells` | Simuler un `OSError` pendant `Workbook.save` → le fichier d'origine est tronqué | Écriture atomique : `wb.save(..tmp)` puis `os.replace()` ; `.tmp` supprimé sur échec ; le backup `.bak` reste inchangé. Test : `TestXlsxAtomicWrite::test_failed_save_keeps_the_original` (octets identiques après échec) + `test_no_tmp_left_after_a_successful_save` | #153 A2. Le fichier temporaire a un suffixe `.tmp` → ignoré par le watcher (`_is_relevant` ne retient que les extensions supportées). Vérifié : cf. BUG-085 | +| *BUG-087* | Édition d'un `.xlsx` concurrente (deux onglets, agent IA + viewer) : read-modify-write sans verrou, le dernier écrivain gagne silencieusement | 🟢 corrigé | P1 | tableur Excel | IA | `backend/services/mutations.py::_xlsx_write_lock` | Deux `PUT xlsx/save` simultanés sur le même fichier → une écriture est écrasée sans trace | Verrou par chemin (registre + garde, timeout 15 s) autour du cycle load → edit → `os.replace` ; attente dépassée → **409** `conflict`. L'endpoint est devenu `def` (sync) pour que l'attente s'exécute dans le threadpool et ne bloque pas la boucle d'événements. Test : `TestXlsxWriteLock` (2) | #153 A3. Verrou en mémoire, par processus : protège les cas d'un même serveur (le cas desktop/Tauri). Vérifié : cf. BUG-085 | +| *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 `` 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 | ### TODOs techniques (améliorations / nouvelles tâches) @@ -207,6 +212,7 @@ 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-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) | | 2026-09-09 | BUG-001, BUG-002 | Correction | `backend/main.py`, `frontend/excalidraw-editor.html`, `tests/test_pdf_stream.py` | BUG-001: Content-Disposition RFC 5987 (nom PDF accentué ne casse plus l'en-tête → plus de 500). BUG-002: suppression alias esm.sh (408 jotai) + React 19 cohérent + prop `excalidrawAPI` → Loading masqué, save OK. Vérifié: 534 tests backend verts + E2E navigateur. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-11 | BUG-003, BUG-004 | Correction | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml`, `README.md`, `README.fr.md` | BUG-003: 33 erreurs mypy corrigées (annotations, gardes `None`, import `PROVIDERS` manquant → bug latent) + étape CI mypy rendue bloquante. BUG-004: lien `README.md` → `docs/CONTRIBUTING.md`. Vérifié: mypy 0 erreur, ruff OK, pytest 728 passed, frontend OK. | 🟢 corrigé (en attente vérif utilisateur) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 60aebb9..66e5bec 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.28.16 | **Dernière mise à jour :** 2026-09-27 +> **Version :** 2.29.0 | **Dernière mise à jour :** 2026-09-27 > **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)** @@ -42,6 +42,56 @@ --- +## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2) + +### 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), reste P1 puis P2 +- **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, + ni styles en écriture, ni formule recalculée), **écriture destructive** d'une partie du classeur, + tableurs **invisibles à la recherche** et **inutilisables par l'IA** au-delà de la création. Ce + lot suit ces ajouts ; les cases ci-dessous sont le **suivi de référence**, la fiche feature porte + le détail. +- **Constat (points de départ) :** `MAX_ROWS = 500` / `MAX_COLS = 40` sans indicateur (troncature + silencieuse) · `wb.save()` non atomique et sans verrou (concurrence) · saisie `=…` stockée comme + formule par openpyxl (injection DDE) · `content=""` à l'indexation (recherche TF-IDF et sémantique + aveugles) · aucun outil IA de lecture/édition d'un classeur existant · aucun test frontend ni + E2E sur le viewer. +- **Périmètre réel des pertes au round-trip (mesuré sur openpyxl 3.1.5, 2026-09-27) :** graphiques, + images, dessins **et** tableaux croisés sont préservés ; sont perdus les **valeurs calculées en + cache**, slicers/chronologies, contrôles de formulaire, connexions/requêtes, custom XML, + signature numérique, commentaires enrichis et macros. +- **Sous-tâches :** + - **P0 — garde-fous d'écriture (🔴, 2-3 j) — 🟢 livré** + - [x] **A1** Alerte de fidélité avant écriture : `inspect_workbook()` → `xlsx_lossy_features` + bandeau FR/EN + **409** `xlsx_lossy_content` sans `force` (confirmation explicite puis reprise) — BUG-085 + - [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) — ⚪ à faire** + - [ ] **A5** Indexation du contenu des feuilles (TF-IDF + sémantique, plafond ~5 k caractères) + - [ ] **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) + - [ ] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) + - [ ] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel) + - [ ] **A10** Types & formats de saisie (nombre/texte, booléens, dates localisées FR) + - [ ] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI + - [ ] **A12** Affichage de la valeur calculée en cache (lecture `data_only=True`, mention 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) +- **Convention de suivi :** chaque sous-tâche démarre par son ID stable (`#153-A` 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 ; + A8 le sera à son tour). + +--- + ## ⚪ Backlog — Priorité 4 (P4) ### 73. Synchronisation multi-appareils — Obsidian Sync compatible @@ -168,6 +218,7 @@ | 🔵 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 ; A5-A12 4-6 j, A13-A17 2-4 j) | 6-11 jours restants | | **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** | --- @@ -175,6 +226,7 @@ ## Notes - **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). - **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. diff --git a/docs/features/xlsx-viewer.md b/docs/features/xlsx-viewer.md new file mode 100644 index 0000000..8acef42 --- /dev/null +++ b/docs/features/xlsx-viewer.md @@ -0,0 +1,201 @@ +# #153 — Visionneuse & édition XLSX — état des lieux et backlog + +> **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), P1/P2 restants +> **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.** + +--- + +## 1. Périmètre et architecture + +| Couche | Fichier | Rôle | +|---|---|---| +| Lecture | `backend/xlsx_reader.py` | `render_sheets()` → un tableau HTML par feuille (openpyxl `read_only=True`, `data_only=False`) | +| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html}]` | +| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets` | +| Écriture | `backend/services/mutations.py:227-320` | `edit_xlsx_cells()` (backup, refs A1 validées, coercion `str`→`int`/`float`) | +| Endpoint écriture | `backend/routers/files_write.py:116-148` | `PUT /api/file/{vault}/xlsx/save` (1 à 500 cellules / requête) | +| Documentation API | `backend/openapi_docs.py:184-187` | exemple d'appel `xlsx/save` | +| UI | `frontend/js/viewer.js:998-1100` | `renderXlsxViewer()` (onglets, cellules sales, Entrée/Échap, collage monoligne) | +| 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) | + +## 2. Ce qui est supporté aujourd'hui (livré, non concerné par #153 sauf mention) + +**Lecture** — multi-feuilles avec onglets ; en-têtes A1/A2/B1 et numéros de ligne ; valeurs +`_fmt()` (dates `YYYY-MM-DD` / `YYYY-MM-DD HH:MM`) ; lignes et colonnes de fin élaguées +(`_trim`) ; feuille vide affichée ; `html.escape()` sur chaque valeur. + +**Édition** — `contentEditable` par ``, classe `xlsx-dirty`, bouton Save actif seulement si +modification ; `Entrée` → blur, `Échap` → restauration, collage forcé en monoligne ; un `PUT` par +feuille sale ; coercion automatique des nombres (`"250"` → int `250`) ; chaîne vide → cellule +vidée ; backup `.bak` avant écriture ; garde-fou vault read-only (403) ; `resolve_safe_path()` +(anti path-traversal) ; `check_vault_access()` + `require_auth` ; journalisation d'audit +(`log_file_save`). + +**Divers** — téléchargement de l'original ; refresh de l'arborescence via le watcher après +écriture ; rafraîchissement de la visionneuse après une action IA (`create_xlsx` → +`obsigate:file-written`, BUG-076). + +## 3. Limites connues (par couche) + +### 3.1 Fidélité du round-trip — risque n°1 + +`load_workbook()` → `wb.save()` : ce qui est **réellement** perdu a été mesuré sur +openpyxl 3.1.5 (2026-09-27), et non repris de la documentation : + +| Élément | Round-trip openpyxl 3.1.5 | +|---|---| +| Graphiques, images, dessins | ✅ **préservés** (mesuré : `xl/charts/`, `xl/drawings/`, `xl/media/` intacts) | +| Tableaux croisés (pivot) + caches | ✅ **préservés** (`reader/excel.py` relit les `TableDefinition`, `workbook/_writer.py` les réécrit) | +| Styles, formats, fusions, validation de données, mise en forme conditionnelle, commentaires | ✅ préservés | +| **Valeurs calculées en cache** (`……`) | ❌ **perdues** → tout lecteur `data_only=True` (pandas, script tiers, convertisseur) renvoie `None` tant qu'Excel n'a pas recalculé | +| Slicers / chronologies, contrôles de formulaire (`ctrlProps`/`activeX`), connexions & requêtes, custom XML, signature numérique, commentaires enrichis, macros | ❌ **perdus** (parties absentes de l'archive après écriture) | + +La liste fait foi dans le code : [`LOSSY_PARTS`](../backend/xlsx_reader.py) + la sonde +`…[^<]` pour les valeurs en cache (openpyxl écrivant lui-même un `` vide). + +**Ce qui reste ouvert** (non mesuré, prudence) : types de graphiques exotiques (treemap, +sunburst, funnel…), `sparklines`, `xl/queryTables` en lecture Excel. Un classeur qui en contient +peut sortir dégradé, voire échouer au chargement — d'où le refus par défaut (A1). + +### 3.2 Lecture + +- Aucun style, format de nombre, devise, pourcentage, largeur de colonne, ligne figée, cellule + fusionnée, commentaire, lien hypertexte, validation de données, mise en forme conditionnelle. +- Plafonds durs `MAX_ROWS = 500`, `MAX_COLS = 40` par feuille, **sans indicateur dans l'UI** : au-delà, + contenu silencieusement tronqué et **non éditable**. +- Pas de pagination ni de chargement à la demande : toutes les feuilles sont rendues d'un bloc + dans le JSON (20 feuilles × 20 000 cellules = payload énorme, UI gelée). +- Formules affichées **en texte** (`=B1*2`), jamais recalculées ; après édition, les cellules + dépendantes ne se mettent pas à jour à l'écran. + +### 3.3 UI (`viewer.js`) + +Navigation clavier (Tab/flèches) absente ; pas de barre de formule, pas de nom de cellule actif, +pas d'undo/redo global, pas de recherche dans la feuille, pas de tri/filtre, pas d'export CSV, +pas d'ajout/renommage/suppression de feuille, pas d'insertion/suppression de ligne ou colonne, +pas de sélection de plage, pas de copie d'une plage, pas de retour ligne dans une cellule +(`Maj+Entrée`) ; seul le retour de l'API est signalé (plafond 500 cellules) ; seule la +**colonne A** est `sticky` (le `thead` ne l'est pas → les en-têtes de colonnes disparaissent au +défilement vertical). **Couverture de test** : `tests/frontend/xlsx-viewer.test.mjs` (10) et +`tests/e2e/xlsx-viewer.spec.js` (3) depuis #153 P0 — la navigation clavier et la barre de formule +restent à faire (A7). + +### 3.4 Recherche, IA et knowledge base + +- **Indexation** : `content=""` → un `.xlsx` est totalement **invisible** à la recherche TF-IDF, à + la recherche sémantique, au remplacement global, aux tags et aux statistiques de contenu. +- **Outils IA** : seul `create_xlsx` existe (crée un fichier neuf, une seule feuille, + `overwrite=True` par défaut) ; `read_file` fait un `read_text()` sur l'archive ZIP → **bruit + binaire** envoyé au LLM ; pas de `update_xlsx_cells` pourtant le service existe déjà, pas + d'ajout de lignes, pas de `xlsx → markdown` pour le contexte. + +## 4. Risques de sécurité / robustesse + +| # | Risque | Où | Traitement | État | +|---|---|---|---|---| +| R1 | Perte silencieuse (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) | `mutations.edit_xlsx_cells` | **A1** — bandeau + **409** `xlsx_lossy_content` sans `force` | 🟢 livré (BUG-085) | +| R2 | Écriture non atomique (`wb.save()` en place) → classeur corrompu si crash | `mutations.edit_xlsx_cells` | **A2** — `.tmp` + `os.replace` | 🟢 livré (BUG-086) | +| R3 | Concurrence : deux éditions (onglets, watcher + IA) → dernier écrivain gagne | `mutations.edit_xlsx_cells` | **A3** — verrou par chemin, **409** `conflict` | 🟢 livré (BUG-087) | +| R4 | **Injection de formule** : une saisie `=cmd\|…`, `=HYPERLINK(…)` est stockée comme formule par openpyxl → DDE à l'ouverture dans Excel | `mutations._write_cell` | **A4** — forçage texte (`data_type="s"`), opt-in `allow_formula` | 🟢 livré (BUG-088) | +| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | ⚪ à faire | + +## 5. Backlog #153 — sous-tâches + +Légende : 🔴 P0 (sécurité / perte de données) · 🟡 P1 (valeur immédiate) · 🟢 P2 (confort / +couverture) · effort en jours-homme de développement + tests. + +### P0 — Garde-fous d'écriture (2-3 j) — 🟢 livré le 2026-09-27 + +- [x] **A1 — Alerte de fidélité avant écriture (R1).** `inspect_workbook()` liste ce qu'un + round-trip perd (`LOSSY_PARTS` + sonde valeurs en cache) ; la lecture renvoie + `xlsx_lossy_features` ; la visionneuse affiche un bandeau listant les éléments ; `PUT + …/xlsx/save` répond **409** `xlsx_lossy_content` (avec `details.features`) tant que `force` n'est + pas passé, le client demande confirmation puis réémet avec `force: true` (une seule fois par + session). *Vérifié :* `TestXlsxLossyGuard` (5), `xlsx-viewer.test.mjs` (10), E2E (3). +- [x] **A2 — Écriture atomique (R2).** `wb.save(..tmp)` puis `os.replace()` ; `.tmp` + supprimé sur échec ; backup `.bak` inchangé. Le `.tmp` est ignoré par le watcher. *Vérifié :* + `TestXlsxAtomicWrite` (2) — les octets d'origine sont intacts après un `save` en échec. +- [x] **A3 — Verrou par fichier (R3).** Verrou `threading.Lock` par chemin (registre + garde, + timeout 15 s) autour du cycle load → edit → replace ; **409** `conflict` si le délai est dépassé. + L'endpoint est passé en `def` (sync) pour que l'attente s'exécute dans le threadpool. *Vérifié :* + `TestXlsxWriteLock` (2). *Limite :* verrou en mémoire, par processus (suffisant pour un serveur + ObsiGate, y compris desktop). +- [x] **A4 — Neutralisation de l'injection de formule (R4).** `cell.data_type = "s"` après + affectation : une saisie `=`/`@` est stockée en texte. Opt-in `allow_formula: true` côté API et + bouton `f(x)` dans la visionneuse (état de session, jamais persisté). `+`/`-` restent des + 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 — Indexation du contenu des feuilles.** Extraire un texte (noms de feuilles + + en-têtes + N premières lignes, plafond ~5 k caractères) pour le TF-IDF et la recherche + sémantique, tout en gardant la lecture binaire pour l'affichage ; `content_preview` + renseigné ; exclusion si le classeur est chiffré/corrompu. *Critère :* une cellule contenant + un mot-clé rend le fichier trouvable ; `test_xlsx_indexing` étendu. +- [ ] **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 + 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`). +- [ ] **A8 — `thead` sticky + indicateur de troncature (R5).** Ligne d'en-têtes figlée au + défilement vertical ; bandeau « feuille tronquée à 500 lignes × 40 colonnes » ; libellés + FR/EN. +- [ ] **A9 — Chargement paresseux par feuille (supprime le plafond).** Endpoint + `GET /api/file/{vault}/xlsx/sheet?sheet=N&offset=&limit=` (`response_model` + + `backend/openapi_docs.py`), rendu à la demande avec défilement virtuel, bouton « charger + tout ». +- [ ] **A10 — Types et formats de saisie.** Coercion symétrique à l'écriture/à l'affichage + (nombre vs texte, booléens `TRUE`/`FALSE`, dates localisées FR — le TODO existe déjà dans + `_coerce_xlsx_value`) ; affichage du type d'origine dans l'info-bulle de cellule. +- [ ] **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. +- [ ] **A12 — Valeurs calculées.** Afficher la valeur en cache (2ᵉ ligne discrète) quand elle + existe, via une lecture `data_only=True` de la même page d'onglets ; mention FR/EN + « valeur recalculée par Excel ». + +### P2 — Étendu (2-4 j) + +- [ ] **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 + 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. + +## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`) + +- Chaque sous-tâche démarre par un **ID stable** : nouvelle feature = `#153-A` dans la + Roadmap ; si la sous-tâche est un **défaut** (A1, A2, A3, A4, A8), l'ouvrir aussi comme + `BUG-NNN` dans `docs/ISSUES_TODOLIST.md` au moment du démarrage. +- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans + `backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`. +- Frontend : vanilla JS sans build, `safeCreateIcons()`, **variables CSS** (jamais de couleur + hardcodée), **i18n FR + EN** pour chaque nouveau texte (`test_i18n_parity.py` vert). +- Tests : `pytest tests/test_xlsx_viewer.py`, `ruff`, `mypy`, `validate-imports`, suite frontend + ciblée, E2E si l'UI change — puis CI verte. +- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée), cette fiche (résultat), + guide utilisateur i18n + README si impact utilisateur. + +## 7. Historique + +| Date | Événement | +|---|---| +| 2.27.0 | #152 livré : affichage multi-feuilles, édition des cellules, téléchargement (`docs/archive/COMPLETED_v1-v2.md`) | +| 2026-09-27 | Audit complet → création de #153 : limites, risques R1-R5, backlog A1-A17 | +| 2026-09-27 | Périmètre de perte **remesuré** sur openpyxl 3.1.5 : graphiques / images / TCD sont préservés, seules les valeurs en cache et quelques parties exotiques sont perdues | +| 2026-09-27 | **P0 livré** (BUG-085 → BUG-088) : `xlsx_lossy_features` + 409 `xlsx_lossy_content`, écriture atomique, verrou par fichier, formules stockées en texte par défaut | diff --git a/frontend/js/auth.js b/frontend/js/auth.js index 2b841d1..085e3b7 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -72,14 +72,25 @@ async function api(path, opts) { } if (!res.ok) { var detail = ""; + var code = ""; + var details = null; try { var body = await res.json(); detail = body.detail || ""; + // #153 A1 : the service layer exposes a stable code + details so callers + // can branch on the failure (e.g. confirm a lossy .xlsx write) instead of + // matching on the message. + code = body.code || ""; + details = body.details || null; } catch (_) { /* no json body */ } showToast(detail || "Erreur API : " + res.status, "error"); - throw new Error(detail || "API error: " + res.status); + var apiError = new Error(detail || "API error: " + res.status); + apiError.status = res.status; + apiError.code = code; + apiError.details = details; + throw apiError; } return res.json(); } diff --git a/frontend/js/config.js b/frontend/js/config.js index e1b5f85..205506d 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -1222,7 +1222,7 @@ function renderDiagnostics(container, data) { ["Postings total", data.inverted_index.total_postings.toLocaleString()], ["Documents", data.inverted_index.documents], ["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"], - ["Stale", data.inverted_index.is_stale ? "Oui" : "Non"], + ["Index prêt", data.inverted_index.is_ready ? "Oui" : "Non"], ], }, { diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index be78043..ea36747 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -998,9 +998,21 @@ export function renderVideoViewer(area, data) { // ── Excel .xlsx — sheet tabs + editable cells ───────────────────────────── // Cells are contenteditable; edits are collected per sheet and sent to // PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are -// saved back as formulas (no client-side recalculation — ceiling accepted). -function renderXlsxViewer(area, data) { +// never recalculated here. +// +// #153 A1/A4 — the read response carries `xlsx_lossy_features` (parts openpyxl +// drops on save): a banner lists them and the first save asks for an explicit +// confirmation before retrying with `force: true`. A value starting with "=" or +// "@" is stored as text unless the user turns the formula toggle on, so a typed +// `=cmd|…` cannot execute when the file is later opened in Excel. +export function renderXlsxViewer(area, data) { const sheets = data.xlsx_sheets || []; + const lossy = data.xlsx_lossy_features || []; + // Session-scoped state: once the lossy write is confirmed, the rest of the + // session saves without asking again (never persisted — a confirmation is + // per workbook, not a global preference). + let lossyConfirmed = false; + let allowFormula = false; const tabs = sheets.length > 1 ? `
${sheets.map((s, i) => `` @@ -1009,18 +1021,33 @@ function renderXlsxViewer(area, data) { const panels = sheets.map((s, i) => `` ).join(""); + const lossWarning = lossy.length + ? `
+ +
+ ${escapeHtml(t("xlsx.lossy_title"))} + ${lossy + .map((f) => `${escapeHtml(t("xlsx.feature_" + f))}`) + .join("")} + ${escapeHtml(t("xlsx.lossy_hint"))} +
+
` + : ""; area.innerHTML = `
${tabs} +
+ ${lossWarning}
${panels}
`; @@ -1061,6 +1088,23 @@ function renderXlsxViewer(area, data) { }); }); + // Formula toggle (#153 A4) — opt-in for this viewing session only. + const formulaBtn = area.querySelector("#xlsx-formula-btn"); + formulaBtn.addEventListener("click", () => { + allowFormula = !allowFormula; + formulaBtn.setAttribute("aria-pressed", String(allowFormula)); + formulaBtn.classList.toggle("active", allowFormula); + }); + + const putSheet = (job, force) => api( + `/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...job, allow_formula: allowFormula, force }), + }, + ); + saveBtn.addEventListener("click", async () => { // One PUT per sheet (dirty cells can span tabs before a save). const jobs = panelEls @@ -1074,11 +1118,25 @@ function renderXlsxViewer(area, data) { saveBtn.disabled = true; try { for (const job of jobs) { - await api(`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(job), - }); + // 409 xlsx_lossy_content → confirm once, then retry with force: true. + // (Also covers a workbook that became lossy while it was open.) + let force = lossyConfirmed; + for (;;) { + try { + await putSheet(job, force); + break; + } catch (err) { + 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; + lossyConfirmed = true; + force = true; + continue; + } + throw err; + } + } } area.querySelectorAll("td.xlsx-dirty").forEach((td) => { td.classList.remove("xlsx-dirty"); @@ -1088,7 +1146,12 @@ function renderXlsxViewer(area, data) { showToast(t("editor.saved"), "success"); } catch (err) { refreshSaveState(); - showToast(`${t("editor.save_error")}: ${err.message || err}`, "error"); + // 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 { + showToast(`${t("editor.save_error")}: ${err.message || err}`, "error"); + } } }); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 8c9b7e5..a803c72 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1823,6 +1823,19 @@ "viewer.copy": "Copy", "viewer.copy_error": "Copy error", "viewer.download": "Download", + "xlsx.lossy_title": "Simplified save", + "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.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)", + "xlsx.feature_cached_values": "cached values", + "xlsx.feature_slicers": "slicers and timelines", + "xlsx.feature_form_controls": "form controls", + "xlsx.feature_connections": "connections and queries", + "xlsx.feature_custom_xml": "custom XML", + "xlsx.feature_signature": "digital signature", + "xlsx.feature_rich_comments": "rich comments", + "xlsx.feature_macros": "macros", "viewer.download_md": "Download as .md", "viewer.download_file": "Download file", "viewer.pretty": "Pretty", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index f4fd09c..383547b 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1823,6 +1823,19 @@ "viewer.copy": "Copier", "viewer.copy_error": "Erreur lors de la copie", "viewer.download": "Télécharger", + "xlsx.lossy_title": "Enregistrement simplifié", + "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.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)", + "xlsx.feature_cached_values": "valeurs calculées", + "xlsx.feature_slicers": "segments et chronologies", + "xlsx.feature_form_controls": "contrôles de formulaire", + "xlsx.feature_connections": "connexions et requêtes", + "xlsx.feature_custom_xml": "XML personnalisé", + "xlsx.feature_signature": "signature numérique", + "xlsx.feature_rich_comments": "commentaires enrichis", + "xlsx.feature_macros": "macros", "viewer.download_md": "Télécharger en .md", "viewer.download_file": "Télécharger le fichier", "viewer.pretty": "Pretty", diff --git a/frontend/style.css b/frontend/style.css index 59cf57c..5f87302 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -10987,6 +10987,66 @@ body.desktop-mode .editor-container { background: rgba(255, 196, 0, 0.18); } +/* #153 A1/A4 — lossy-save warning + formula toggle */ +.xlsx-warning { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + margin-bottom: 8px; + border: 1px solid var(--warning, #e0a800); + border-left-width: 3px; + border-radius: 4px; + background: var(--surface); + color: var(--text-secondary); + font-size: 0.82rem; + line-height: 1.45; +} +.xlsx-warning-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; + margin-top: 1px; + color: var(--warning, #e0a800); +} +.xlsx-warning-body { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.xlsx-warning-body strong { + color: var(--text-primary); + font-weight: 600; +} +.xlsx-warning-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.xlsx-warning-tag { + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.75rem; + white-space: nowrap; +} +.xlsx-warning-hint { + color: var(--text-secondary); + opacity: 0.85; +} +.xlsx-formula-toggle { + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-weight: 600; +} +.xlsx-formula-toggle.active { + background: var(--accent, #4a90d9); + border-color: var(--accent, #4a90d9); + color: #fff; +} + /* ── JSON Viewer ── */ .json-viewer { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; diff --git a/frontend/sw.js b/frontend/sw.js index 84bfec9..98e249e 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -11,7 +11,7 @@ * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * a separate store. Bumping SW_VERSION invalidates it on every release. */ -const SW_VERSION = 'v26'; +const SW_VERSION = 'v27'; const CODE_CACHE = `obsigate-code-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`; diff --git a/package.json b/package.json index 327a584..5c7257e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.28.16", + "version": "2.29.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": { diff --git a/plan.md b/plan.md index 73acc35..f1d36dc 100644 --- a/plan.md +++ b/plan.md @@ -1,4 +1,9 @@ -# Plan: Incremental InvertedIndex for 40k+ files +# Incremental InvertedIndex for 40k+ files — livré + +> **Statut : LIVRÉ (BUG-033, v2.3.0).** Ce fichier a servi de plan +> d'exécution ; il est conservé comme **trace de conception**. Le code réel +> a divergé sur plusieurs points (voir [État réel](#état-réel-corrigé-au-2026-09-27)) +> — ne pas lire les extraits de code ci-dessous comme du code actuel. ## Problem Summary @@ -17,6 +22,11 @@ Then hook these into `_add_file_to_structures` and `_remove_file_from_structures Remove the `is_stale()` / `rebuild()` / cooldown mechanism entirely. The inverted index is always current. +> ⚠️ **Nuance retenue à l'implémentation** : un unique `rebuild()` reste nécessaire au +> démarrage (le hook est inerte tant que l'index n'est pas prêt) et au reindex manuel +> d'une vault. Ce qui disparaît, c'est la *staleness* : plus de compteur de génération, +> plus de cooldown, plus de rebuild paresseux. + ## Dependency Architecture **Current import chain:** @@ -346,3 +356,61 @@ This hack was only needed to reduce the number of inverted index rebuilds. With 4. **Sorted tokens performance:** `bisect.insort` and `list.pop(idx)` are O(V) worst case for large V. For 40k files, the vocabulary size V is typically 50k-200k tokens. O(V) for a single insertion is ~0.001ms, acceptable. The rebuild() call at startup handles the initial bulk. 5. **tag_norm_map / tag_prefix_index growth:** These grow monotonically (never shrink on incremental remove). With 40k files and thousands of tags, this is a few thousand entries — negligible. A manual "Réindexer" button triggers a full `rebuild()` to clean up. + +--- + +## État réel (corrigé le 2026-09-27) + +Le plan ci-dessus a servi de brouillon : **le code livré en est différent sur +quatre points**. Relevé fait sur `backend/search.py`, `backend/indexer.py` et +`backend/main.py`, pas de mémoire. + +| Point prévu | État réel | +|---|---| +| Étapes 1-2 : hook + `add_document()` / `remove_document()` | ✅ livré tel que prévu | +| Étapes 4-5 : `rebuild()` initial via `init_inverted_index()` appelé depuis la lifespan | ✅ livré (`backend/main.py:297`, dans l'exécuteur de recherche) | +| Étape 6 : retirer `is_stale()` + `_last_rebuild` / `_rebuild_cooldown` / `_source_generation` | ✅ **déjà fait** avant cette relecture — aucun de ces symboles ne subsiste | +| Étape 7 : retirer le hack de coalescence `_index_generation` dans `_on_vault_change` | ✅ **déjà fait** — `_on_vault_change` n'existe plus | +| `get_inverted_index()` simplifié | ✅ mais **sans le fallback `_needs_rebuild`** prévu par le plan | + +### Écarts assumés + +1. **`is_stale()` a survécu sous un autre nom.** L'étape 6 est faite, mais la + méthode a été conservée car elle répond à une autre question : *l'index + initial est-il construit ?* Elle ne mesure plus aucune staleness (le compteur + de génération et le cooldown ont disparu) et le nom était trompeur. Elle est + donc renommée `is_ready()` — cohérent avec le `is_ready()` déjà exposé par + `SemanticIndex` (`backend/semantic_search.py`). L'alias `is_stale()` de + `SemanticIndex`, sans aucun appelant, est supprimé. + + Impact : le champ de `/api/diagnostics` passe de `is_stale` à `is_ready` + (libellé « Index prêt » côté `frontend/js/config.js`). + +2. **Pas de repli `_needs_rebuild`.** Le plan prévoyait qu'un échec + d'incrémentation marque l'index pour reconstruction. L'implémentation + retenue se contente de logger un warning et de continuer à servir l'index. + Choix assumé : un échec d'incrémentation est exceptionnel, et reconstruire + silencieusement serait plus coûteux que l'état dégradé. **Si ce compromis + devient critiquique, c'est le point à rouvrir.** + +3. **`_ready` remplace `doc_count == 0`.** Le plan prévoyait de sauter le hook + « index vide » ; le drapeau explicite `_ready` est plus sûr (un vault + réellement vide serait sinon pris pour un index non construit). + +4. **`rebuild()` reste nécessaire** au démarrage et au reindex manuel d'une + vault. Le plan parlait de le supprimer de `get_inverted_index()`, ce qui est + fait, mais la méthode elle-même est conservée. + +### Bug trouvé pendant cette relecture (corrigé ici) + +`remove_vault_from_index()` (`backend/indexer.py`) ne notifiait pas le hook. +Conséquence mesurée : après suppression d'une vault, ses 8 documents test +restaient dans l'index inversé — `postings`, `doc_info`, `doc_vault`, +`vault_docs` — et continuaient de correspondre aux recherches pour une vault +inexistante. Seul un reindex manuel les effaçait. + +Le correctif déclenche `_on_index_change('remove', …)` pour chaque fichier de +la vault, et `_remove_doc_internals()` supprime désormais la clé `vault_docs` +quand son set devient vide (c'est un `defaultdict` : une simple lecture la +ré créait). Test de non-régression : +`TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le patch). diff --git a/test_vault/sample-xlsx-lossy.xlsx b/test_vault/sample-xlsx-lossy.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ed893b1268f6cd4f6cc7fa56d577b8e17800e49e GIT binary patch literal 5002 zcmZ`-1yoes79Lt)$e|ocX>@3iE|DRG5hMhL5*T2R1}RBN38g_w8jL4v`QL zk&>=A=z7av-v4ISy=To@^L_X3efHkpK3nZR7B&R{0JsK7h!;12cBv}Hp?~$GFH-bn zW@oDAXlL)l^Vr^=+s(#W6{Skp$_vDu|M1YI=5;7THt{{l(6mMr_tU^?PMd(^%?)g6 zTX*+v+Dz^jOl;MO^4r`?HUj)zL^5N{$5MU08KD_V&x-kG{ld$@B-xA%SZ$d$ zK#`9NHKl8xxSMH6o0T!d-#QEH@p z?X^2K!})+$Pp$Ts38TuDdz+d4 zPCGse3!7i9{EHg-C+8e|RtNi)i1mPZ43Wt~I^`+bi~RJmkd)t0jGH9YH0Qkr@ed%cAx? zRhb0}fJ^0q)A&Cjh*V&pMTb?rS^yi(W5=TgU-O#-qtazgcoUqWW)0cLO*|)~DYUL{ zznmSdJvFQ;m5SK`cNt9*h(Yx$c9&{K&)U95DH(3(zWqVq-S99q?l)IIi zrlIO$;t;|CKYU8PL-@CMUhAhYli~sZDeM3M2<;s=YaVAygbm{No$t~=8+vf3DG>_q z&5}Vki+vly&O3h9l^`di%XH=IfDIU9Ael~twYynVfX@eoJEVQ_L>tkn^~n<0khPNm zd4-i#5%=96+$O6*A+bAd#*;kLV+}PtbMue*S;%C0ujiF)H525h7*^>a0c|2)Js|S7 zndf2G?z>U+jp@(B!92`k-zukPwF8jW88ixJ#$JXHOPN7Ubv-+y8`UQ-4~2 z$d<*g%t8*DP3%tA;%u6vMERxQhn+yLm!OJboGro}_P>mDS(U`62p( z?7XuBdki^zJzfy%m>P*g3=nP~Qc?3Pk*yA$Z;c+-j~qjBR;hunC3XAlDLlW~F;jB3 zm(b9YE#Q&rB{uLR)(2UDk+;#1jEXsRT3vdR7c;JUcfh_8g1>#j41XNVZYvIQ6r;1a z>xtUmkIG@?M{OPvtivQM7`5X|4N$11v~Wk}hS_}`ul)xKw#?)C%@s`Ju}~KIH{D$I zOJpKdN<{o1>1(M$iB@WKnA!Mz@fksfm7RrT^B}L9rfc1wBSH=e?2;rOW#}kP1ssw> z!3!x)U!8`mPw;Y}L;G5dGg%2blo$y#p9S9uK|03V;+nUMQY7d>ZP>7DEZk%cP=KvfFp1Os?MnJ62KoRXgMd80(RD4!OaGE6GlE3x#5&jJv=0 zM?D=Fiy|hAHWWtxcOWd1>;prPh;q_Krh^XlS(;&+Qp@V|KJ&9$@1RiuYskw3g-_JV zOC)T0(rf&Jcv(rh`0oR>t=_x~7LLQ-aBIQg z^JY;WZZA~MX5CN8jNuw#W-p7Dkudli)E3@;Qp`6sF38Pc`pB`ryGyeqhI2ynP4o9N zOR2Q&jpIp{fRZF#E@rC8`+gO&EVZX$y<4~1@mI~@toVJJBM9M8^0d3Owrd)D%5me% zC3PIN67Z$9k_=}l2$2*K~TJ zlYIl1CFUGnjRd(`@ZBaXoBGrlF_E;ys5*j?hmm$|M{6B!;04i1>^iT+M>Z=HLqjJk zE$XFNn-cN#T_HP}H-BUVC8FBog594PC0fLFstHoqSQoNDpf$#|D6aHcSsiTak$%i-O)cL|Ka# zJxU9{Q`%%DSxE^)o$D5LzX6iLs0hgwO=GEZl{0s*Yc#FgR%+K0`5-S)O`bs0xcIfp z0j5z)?ov&H$zRtXn(u+&v&h}~%mu`Uz(aJ&7gP;4Mpu$kianTIjuTv4jCaoLdlX2P z#aE1e8psuU@6RqC+s241s-(DgkYPB>V=~9l;ZjlaFRA^Aj>H9F^vCwx+roTqmP_ZG zcue?y2Uy2RIZz}e>LU;F4}uvEvyxMUmI85ZQadhgZpK)-hL0aZ{A>a=`OQYDd*xoV zND4U0O=z<27EiGe22L+%_BVi=*o^PDdDy0Mv0qFZev+)DmW1wRy`E>N2zE;LsBM)m zywDkAIO6{-YCkPDhh_K`1d+@3Zt)5Y^(8AXX{s-IaKxG-Uioa>??&?J*mU;(>^#u( zQ-NNN*=;iE`*@y=(YU7J@SlQ4uP984a?6T3r}%*GzB;?W40Dms!SYF9fzhx+(Fg)N z*-@Eg>FG~Imit74bWu2le6OqTWG%r6BxcSxiAr1BUw+vL`+)JcfYj|hx3s_j020yi zO7mMlTk9$)_ zv$Mxed8}r3a1##3E|`12=cx6DxK;4BgTzf)pR|*>FnoKsBta_jHiS&Y!=)5^g@z+y z+r60r#44ONrSP*s{n>E&9kTeBW;`ylo8SBKQvww?vr)k>`{1x6!06Qf;$>`pG%b+>wxJPR&mv}=Mg{mh!o zEy405eApzLoz-TMD3x`7Wpg}tvu4!M8?%oO01*A&94BXYYlPFKuFq;uhfbq+gz^i? z+n7Wl2c;b~IpYj2!UhsAEfZ0))rH=j=%4H2%%Uz81-cww1*%&g=~C-xEk;fX$WazI zB^oRd$2KneO(!WUXWJ0LP3cJ(jf~#9;6Zx5#+)l`D_0>}`^c({!>K=~$Awm4_5Hr2 z_*BcGO?CIqY;Ki#HKfAQH%p&zzjyRbL0vQ+dz)@?PCay&fJmXB(3IgJnIi7}i`%Vz zZy(2JF>*ybwftxru(jHfuW6-h94FsqJL0ezDDX@TlbzGmouAKn3BCee_r{Ko|e3Nyr#ms*lStUy&}kV!aj64 z2zZMadfZ?*GLkF2v?!TwemoSU7B`%%sHJ=Wj&lASsjjt#)NqKDRrQ;q4}bI0Up($B z1r98(H^Rdonf;Cf+m-Kq7DCB6uU%Y{@f#Os=sRWRkQ@OH0)m#BDCk=tFCGDB=oY=_ zu4J#>=X}reyqXiDzY9Y`Xaij{I_5y=_#;HGMve$;Cm!z0dunu@ExO~D@#z>$u6f_#PnDYGGxT^*KBb4L0QsW)1Sp;*oO z3ONvUxA+r;K`3}w=H9faT&W&JN{0KodChw>sywIcMD1zujd8MHYCLO9I`JRzX`WzqnuKmc7wGvaNN)Pzz zk%Ofs!OIy_)^G=t%5$OTJtW_~0e>3`_5r8~perCV+E9|;Np@KQP3-KRT&7us`q1U^ z;xz6Sql`FqOybykUW^!072t-X&FrWi`JQi|71h&g=h_M;X z1tum#vkkJ55ZbLfb83XLaY++!_3mK7YG8FJk*{0AocW~09+kf?7kdd;-ncKevMhn} zemlb#I-flTHui9xbh)E}etTDA@>dv=hUvYk=Ft;_M}agv6z%l}Jxltw=2@k9RFI3o z2C1byu7&0hE~!G?o_-09Hn3d;WF)6Fxc*+F7RedZw$7 z#k$C;Pv3y^i^~Fp;!=Zen7#rQlhBy>+XyO^GB{RLG;OTY7*1#|I%eh0s~9PmjOa*`Eoca20FL7NpW_W$;ms432Jx(F(SpB zAD5?b`E2&U-dIw&+NM|UvOu5U_L}v5-vngCD?_;l7I`07Q5P=g2H`dizqcZd=BSsQ z5HL&o@j5d_;#|)8=`7My!G*e z?e%Uf^xUvPYI~JFfK{%zQwtx+7xypX7k>uYyE$L2)QCv;=<8?$QCPpvVQ@J+TW5r= zvyrB|J;KT0GLVX5R4)}2Z5;y6$_>#;&!sgcG_c1oeE=>vkFn4jo)&;jDW{WD2C5di z%{8LZ5(?*Dk(I8d?oGtft9w#lYT?05=$YJ#D{qP_ohDT?DObh7Ne}v#{nc7PZtpmr z#jcMER65$_yREs?s&1*iZZ^@Ch-m$2rF=b0NSS=Rqh*~j_S?O;bxwoE7EIhaYM})I z`bYHk-=2xfC`0ASqFxy0n8vLZ$%yGSdrU3#UYDB&7YM4B2F(>HmRV5%3EyMQdN@ebu)iPsNG|MeX{X`Z9_iu{oUpxaMg_rzH zbiSevpLJ4S&<<>qwf^`ZUDh}T1N(dp`yy@Vb4Bx1^z=n9>kV4+093!j#mO3Jif}}C zBfr*P?MMaZK#vNHYq$mroy{1S6oCIf^FVX_`uL)^=YJ<4SJ79;4ZpDffG_=&-`b;Uj<(+5`Vw}#J|A*r(|5^xtfQ6c#!DY{x4a1mEdal j{UNA856~_>_rJlYb{_{lf&l;s(BJ2171_BQwE_MEfEIhN literal 0 HcmV?d00001 diff --git a/tests/e2e/xlsx-viewer.spec.js b/tests/e2e/xlsx-viewer.spec.js new file mode 100644 index 0000000..a7322bd --- /dev/null +++ b/tests/e2e/xlsx-viewer.spec.js @@ -0,0 +1,120 @@ +/** + * E2E tests — Excel viewer, write guards (ROADMAP #153 P0). + * + * Fixture : `test_vault/sample-xlsx-lossy.xlsx` — a plain 2x2 workbook whose + * sheet XML carries a cached formula result (`B1*2200`) and whose + * package contains `xl/slicers/slicer1.xml`. Both are dropped by an openpyxl + * round-trip, so the read response must report + * `xlsx_lossy_features: ["cached_values", "slicers"]` (BUG-085 A1). + * + * Covered : + * - the warning banner lists both features ; + * - saving a cell on that workbook asks for confirmation (native dialog) and + * then succeeds (the client retries with `force: true`) ; + * - the f(x) toggle is off by default, so "=B1*3" is stored as text. + * + * The fixture is restored byte-for-byte in `afterAll` so a local run never + * dirties the working copy. + * + * Run (local) : BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xlsx-viewer.spec.js + */ + +import { test, expect } from '@playwright/test'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; + +const BASE = process.env.BASE_URL || 'http://localhost:2029'; +const VAULT = 'TestVault'; +const FIXTURE = 'sample-xlsx-lossy.xlsx'; +// Playwright runs from the repository root (run-e2e-local.* / CI both do). +const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE); + +let originalBytes = null; + +async function login(page) { + await page.goto(BASE); + const loginForm = page.locator('#login-screen'); + await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {}); + if (await loginForm.isVisible()) { + await page.fill('#login-username', process.env.OBSIGATE_USER || 'admin'); + await page.fill('#login-password', process.env.OBSIGATE_PASS || 'test123'); + await page.click('#login-btn'); + } + await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 }); +} + +async function openFixture(page) { + const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${FIXTURE}"]`); + if (!(await treeItem.count())) { + await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click(); + await treeItem.waitFor({ state: 'attached', timeout: 8000 }); + } + await treeItem.dblclick({ timeout: 5000 }); + await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 }); +} + +test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => { + test.beforeAll(() => { + if (existsSync(FIXTURE_PATH)) originalBytes = readFileSync(FIXTURE_PATH); + }); + + test.afterAll(() => { + if (originalBytes) writeFileSync(FIXTURE_PATH, originalBytes); + }); + + test('affiche la bannière listant les éléments non préservés', async ({ page }) => { + await login(page); + await openFixture(page); + + const banner = page.locator('#content-area .xlsx-warning'); + await expect(banner).toBeVisible(); + // 2 features : valeurs calculées + segments (jamais de couleur codée en dur, + // les libellés viennent bien des locales). + await expect(banner.locator('.xlsx-warning-tag')).toHaveCount(2); + await expect(banner).toContainText('segments'); + await expect(banner).toContainText('valeurs calculées'); + }); + + test('demande confirmation puis enregistre la cellule', async ({ page }) => { + 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é'); + await cell.press('Enter'); + + const save = page.locator('#xlsx-save-btn'); + await expect(save).toBeEnabled(); + await save.click(); + + await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments'); + await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 }); + + // La cellule reste modifiée côté UI (plus de marque « sale »). + await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0); + }); + + test('le toggle f(x) est désactivé par défaut (formule stockée en texte)', async ({ page }) => { + await login(page); + await openFixture(page); + + 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()); + 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(); + await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs new file mode 100644 index 0000000..6e58711 --- /dev/null +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node +/** + * ObsiGate — JSDOM integration tests for the Excel viewer (ROADMAP #153 P0). + * + * Loads the real viewer.js module and drives renderXlsxViewer(): + * - A1 : `xlsx_lossy_features` renders a warning banner; a save on such a + * workbook gets 409 `xlsx_lossy_content`, asks for confirmation and + * retries with `force: true` (or gives up when refused); + * - A4 : the f(x) toggle flips `allow_formula` in the save payload. + * + * Usage: node tests/frontend/xlsx-viewer.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +// ── JSDOM bootstrap ───────────────────────────────────────────────────────── +const dom = new JSDOM( + ` + + +
+ +`, + { url: "http://localhost/", pretendToBeVisual: true } +); + +const w = dom.window; +globalThis.window = w; +globalThis.document = w.document; +globalThis.HTMLElement = w.HTMLElement; +globalThis.Element = w.Element; +globalThis.Node = w.Node; +globalThis.Event = w.Event; +globalThis.CustomEvent = w.CustomEvent; +globalThis.MouseEvent = w.MouseEvent; +globalThis.localStorage = w.localStorage; +globalThis.sessionStorage = w.sessionStorage; +globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0); +Object.defineProperty(globalThis, "navigator", { + value: w.navigator, + configurable: true, + writable: true, +}); + +// ── fetch / confirm doubles ───────────────────────────────────────────────── +let calls = []; +let nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } }); +let confirmAnswer = true; +let confirmCalls = 0; +// Every confirm() prompt is captured so the tests can assert on its text. +let confirmPrompts = []; + +const FR = JSON.parse( + readFileSync(path.join(REPO_ROOT, "frontend", "locales", "fr.json"), "utf8") +); + +globalThis.fetch = async (url, opts = {}) => { + // The i18n bootstrap fetches the locale files: serve the real FR one so the + // assertions run on the shipped strings, not on raw keys. + if (url.includes("/static/locales/")) { + return { ok: true, status: 200, json: async () => FR }; + } + calls.push({ url, body: opts.body ? JSON.parse(opts.body) : null }); + const res = nextResponse(url, opts); + return { + ok: res.ok, + status: res.status, + json: async () => res.body, + }; +}; +globalThis.confirm = (msg) => { confirmCalls++; confirmPrompts.push(msg); return confirmAnswer; }; +w.confirm = globalThis.confirm; + +// ── Helpers ───────────────────────────────────────────────────────────────── +let testCount = 0; +let passCount = 0; + +async function test(name, fn) { + testCount++; + calls = []; + confirmCalls = 0; + confirmPrompts = []; + confirmAnswer = true; + nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } }); + try { + await fn(); + console.log(` ✓ ${name}`); + passCount++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` ${e.message}`); + if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`); + } +} + +const { renderXlsxViewer } = await import( + pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "viewer.js")).href +); +// Load the FR catalog so t() resolves the real strings. +const { initI18n } = await import( + pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href +); +await initI18n(); + +const sheetHtml = (value) => + '
' + + '' + + `` + + "
A
1${value}
"; + +function mount({ lossy = [] } = {}) { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + renderXlsxViewer(area, { + vault: "V", + path: "data.xlsx", + is_xlsx: true, + xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], + xlsx_lossy_features: lossy, + }); + return area; +} + +/** Mark a cell dirty the way a user edit would. */ +function editCell(area, ref, text) { + const td = area.querySelector(`td[data-cell="${ref}"]`); + td.textContent = text; + td.dispatchEvent(new w.Event("input", { bubbles: true })); + return td; +} + +const lossyError = { + ok: false, + status: 409, + body: { + detail: "Saving this workbook would drop features…", + code: "xlsx_lossy_content", + details: { features: ["slicers"] }, + }, +}; + +console.log("\n── xlsx viewer JSDOM integration tests (#153 P0) ──\n"); + +// ── A1 — warning banner ───────────────────────────────────────────────────── + +await test("no banner when the workbook has nothing at risk", () => { + const area = mount(); + assert.equal(area.querySelector(".xlsx-warning"), null); +}); + +await test("banner lists every lossy feature reported by the backend", () => { + const area = mount({ lossy: ["cached_values", "slicers"] }); + const banner = area.querySelector(".xlsx-warning"); + assert.ok(banner, "banner absent"); + const tags = [...banner.querySelectorAll(".xlsx-warning-tag")].map((n) => n.textContent); + assert.equal(tags.length, 2); + assert.ok(tags.includes(FR["xlsx.feature_cached_values"]), tags.join("|")); + assert.ok(tags.includes(FR["xlsx.feature_slicers"]), tags.join("|")); + assert.ok(banner.textContent.includes(FR["xlsx.lossy_title"])); + assert.ok(banner.textContent.includes(FR["xlsx.lossy_hint"])); +}); + +await test("formula toggle is present and starts unpressed", () => { + const area = mount(); + const btn = area.querySelector("#xlsx-formula-btn"); + assert.ok(btn); + assert.equal(btn.getAttribute("aria-pressed"), "false"); + assert.equal(btn.getAttribute("title"), FR["xlsx.formula_toggle_title"]); +}); + +// ── Save payload ──────────────────────────────────────────────────────────── + +await test("save sends one PUT per dirty sheet with the cell map", async () => { + const area = mount(); + editCell(area, "A1", "250"); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(calls.length, 1); + assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/save\?path=data\.xlsx/); + assert.deepEqual(calls[0].body.cells, { A1: "250" }); + assert.equal(calls[0].body.sheet, "Feuille1"); + assert.equal(calls[0].body.force, false); + assert.equal(calls[0].body.allow_formula, false); +}); + +await test("save button stays disabled when nothing is dirty", async () => { + const area = mount(); + const btn = area.querySelector("#xlsx-save-btn"); + assert.equal(btn.disabled, true); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(calls.length, 0); +}); + +// ── A4 — formula toggle ───────────────────────────────────────────────────── + +await test("f(x) toggle flips allow_formula on the next save", async () => { + const area = mount(); + area.querySelector("#xlsx-formula-btn").click(); + assert.equal(area.querySelector("#xlsx-formula-btn").getAttribute("aria-pressed"), "true"); + editCell(area, "A1", "=B1*2"); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(calls[0].body.allow_formula, true); +}); + +// ── A1 — 409 confirmation & force retry ───────────────────────────────────── + +await test("409 xlsx_lossy_content asks once then retries with force", async () => { + const area = mount({ lossy: ["slicers"] }); + editCell(area, "A1", "250"); + 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); + 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); +}); + +await test("confirming once is enough for the following saves", async () => { + const area = mount({ lossy: ["cached_values"] }); + editCell(area, "A1", "1"); + nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} }); + area.querySelector("#xlsx-save-btn").click(); + 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); +}); + +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); + 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 non-409 failure is not retried", async () => { + const area = mount(); + editCell(area, "A1", "250"); + nextResponse = () => ({ ok: false, status: 500, body: { detail: "boom" } }); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(calls.length, 1); + assert.equal(confirmCalls, 0); + assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); +}); + +// ── Report ────────────────────────────────────────────────────────────────── +console.log(`\n${passCount}/${testCount} tests passed\n`); +process.exit(passCount === testCount ? 0 : 1); diff --git a/tests/test_search_advanced.py b/tests/test_search_advanced.py index 417ad57..00f2812 100644 --- a/tests/test_search_advanced.py +++ b/tests/test_search_advanced.py @@ -115,6 +115,25 @@ class TestInvertedIndex: inv.remove_document("V", "p.md") assert inv.doc_count == 0 # Skipped + def test_is_ready_tracks_initial_build(self, client): + """is_ready() is the only freshness signal: no generation counter, + no cooldown, no lazy rebuild (plan.md step 6).""" + inv = InvertedIndex() + assert inv.is_ready() is False + inv.rebuild() + assert inv.is_ready() is True + # The old staleness API is gone for good. + assert not hasattr(inv, "is_stale") + + def test_is_ready_survives_incremental_updates(self, client): + """Incremental add/remove must not flip readiness back (which would + silently push search() onto the O(N) full-scan fallback).""" + self.inv.rebuild() + assert self.inv.is_ready() is True + self.inv.add_document("V", "p.md", {"path": "p.md", "title": "T", "tags": [], "content": "x"}) + self.inv.remove_document("V", "p.md") + assert self.inv.is_ready() is True + # ═══════════════════════════════════════════════════════════════════ # Search / Advanced Search integration tests @@ -192,3 +211,40 @@ class TestSearchFunctions: def test_suggest_tags_no_match(self, client): suggestions = suggest_tags("xyznonexistent", vault_filter="all") assert len(suggestions) == 0 + + +# ═══════════════════════════════════════════════════════════════════ +# Vault removal — inverted index must not keep ghost documents +# ═══════════════════════════════════════════════════════════════════ + +class TestVaultRemovalPurgesInvertedIndex: + """`remove_vault_from_index()` must notify the inverted-index hook. + + Regression: it only cleaned the indexer's own structures, so every document + of the removed vault survived in the inverted index (postings, doc_info, + doc_vault, vault_docs) and kept matching searches for a vault that no + longer exists — a leak that only a manual reindex used to clear. + """ + + def test_removing_a_vault_purges_its_documents(self, client): + import asyncio + + import backend.indexer as ix + import backend.search as bs + + ix.set_index_change_hook(bs._on_index_change_hook) + inv = bs._inverted_index + inv.rebuild() + + vault_keys = [k for k in inv.doc_info if k.startswith("TestVault::")] + assert vault_keys, "vault non indexe, test sans valeur" + before = inv.doc_count + + asyncio.run(ix.remove_vault_from_index("TestVault")) + + ghosts = [k for k in inv.doc_info if k.startswith("TestVault::")] + assert not ghosts, f"documents fantomes dans l'index inverse : {ghosts[:5]}" + assert inv.doc_count == before - len(vault_keys) + assert "TestVault" not in inv.vault_docs + # The index stays usable for the remaining vaults. + assert inv.is_ready() is True diff --git a/tests/test_xlsx_viewer.py b/tests/test_xlsx_viewer.py index d766e97..1fd78ed 100644 --- a/tests/test_xlsx_viewer.py +++ b/tests/test_xlsx_viewer.py @@ -1,7 +1,13 @@ -"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save).""" +"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save). + +Covers #152 (affichage / édition) and #153 P0 : A1 alerte de fidélité avant +écriture, A2 écriture atomique, A3 verrou par fichier, A4 neutralisation de +l'injection de formule. +""" from __future__ import annotations +import zipfile from pathlib import Path import pytest @@ -29,6 +35,43 @@ def xlsx_file(test_vault_dir: str) -> str: return str(path) +def _add_lossy_parts(path: Path, parts: dict[str, bytes]) -> None: + """Re-pack *path* with extra OPC parts openpyxl cannot write back.""" + with zipfile.ZipFile(path) as zf: + items = {n: zf.read(n) for n in zf.namelist()} + items.update(parts) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + for name, blob in items.items(): + zf.writestr(name, blob) + + +@pytest.fixture +def lossy_xlsx(test_vault_dir: str) -> str: + """Workbook with a slicer + a formula carrying its cached result.""" + from openpyxl import Workbook + + path = Path(test_vault_dir) / "risky.xlsx" + wb = Workbook() + ws = wb.active + ws.title = "Data" + ws["A1"] = 3 + ws["A2"] = "=A1*3" + wb.save(path) + # …… : openpyxl keeps the formula, drops the cached result. + with zipfile.ZipFile(path) as zf: + items = {n: zf.read(n) for n in zf.namelist()} + sheet = next(n for n in items if n.startswith("xl/worksheets/sheet")) + xml = items[sheet].decode("utf-8").replace( + "A1*3", "A1*39" + ) + items[sheet] = xml.encode("utf-8") + items["xl/slicers/slicer1.xml"] = b"" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + for name, blob in items.items(): + zf.writestr(name, blob) + return str(path) + + # ── Display ─────────────────────────────────────────────────────────────── @@ -150,3 +193,169 @@ class TestXlsxSave: def test_missing_sheet_field_400(self, client, xlsx_file): resp = self._save(client, {"cells": {"A1": "x"}}) assert resp.status_code == 400 + + def test_non_boolean_flag_400(self, client, xlsx_file): + for flag in ("force", "allow_formula"): + resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "x"}, flag: "yes"}) + assert resp.status_code == 400, flag + + +# ── #153 A1 — lossy-write guard ────────────────────────────────────────── + + +class TestXlsxLossyGuard: + def _save(self, client, body, path): + return client.put( + f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body, + ) + + def test_read_reports_lossy_features(self, client, lossy_xlsx): + data = client.get(f"/api/file/{VAULT}", params={"path": "risky.xlsx"}).json() + assert data["is_xlsx"] is True + assert "slicers" in data["xlsx_lossy_features"] + assert "cached_values" in data["xlsx_lossy_features"] + + def test_read_reports_nothing_for_a_plain_workbook(self, client, xlsx_file): + data = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"}).json() + # B2 holds "=B1*2" but openpyxl wrote no cached for it. + assert data["xlsx_lossy_features"] == [] + + def test_save_refuses_without_force(self, client, lossy_xlsx): + resp = self._save(client, {"sheet": "Data", "cells": {"B1": "hello"}}, "risky.xlsx") + assert resp.status_code == 409 + body = resp.json() + assert body["code"] == "xlsx_lossy_content" + assert "slicers" in body["details"]["features"] + + def test_refused_save_leaves_the_file_untouched(self, client, lossy_xlsx): + before = Path(lossy_xlsx).read_bytes() + self._save(client, {"sheet": "Data", "cells": {"B1": "hello"}}, "risky.xlsx") + assert Path(lossy_xlsx).read_bytes() == before + + def test_save_with_force_succeeds(self, client, lossy_xlsx): + resp = self._save( + client, + {"sheet": "Data", "cells": {"B1": "hello"}, "force": True}, + "risky.xlsx", + ) + assert resp.status_code == 200 + assert openpyxl.load_workbook(lossy_xlsx)["Data"]["B1"].value == "hello" + + def test_inspect_flags_every_known_family(self, lossy_xlsx): + from backend.xlsx_reader import LOSSY_PARTS, inspect_workbook + + for key, prefixes in LOSSY_PARTS.items(): + _add_lossy_parts( + Path(lossy_xlsx), + {f"{prefixes[0]}probe.xml": b"" for _ in [0]}, + ) + assert key in inspect_workbook(Path(lossy_xlsx)), key + + def test_inspect_is_quiet_on_a_corrupt_archive(self, test_vault_dir): + from backend.xlsx_reader import inspect_workbook + + bad = Path(test_vault_dir) / "broken.xlsx" + bad.write_bytes(b"not a zip at all") + assert inspect_workbook(bad) == [] + + +# ── #153 A2 — atomic write ─────────────────────────────────────────────── + + +class TestXlsxAtomicWrite: + def test_failed_save_keeps_the_original(self, client, xlsx_file, monkeypatch): + from openpyxl.workbook.workbook import Workbook + + before = Path(xlsx_file).read_bytes() + + def boom(self, *args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(Workbook, "save", boom) + # TestClient re-raises the server exception (in production: 500). + with pytest.raises(OSError): + client.put( + f"/api/file/{VAULT}/xlsx/save", + params={"path": "budget.xlsx"}, + json={"sheet": "Budget", "cells": {"A1": "perdu"}}, + ) + # The workbook on disk is byte-identical : the write never reached it. + assert Path(xlsx_file).read_bytes() == before + # No temporary file left behind in the vault. + assert list(Path(xlsx_file).parent.glob("*.tmp")) == [] + + def test_no_tmp_left_after_a_successful_save(self, client, xlsx_file): + client.put( + f"/api/file/{VAULT}/xlsx/save", + params={"path": "budget.xlsx"}, + json={"sheet": "Budget", "cells": {"A1": "ok"}}, + ) + assert list(Path(xlsx_file).parent.glob("*.tmp")) == [] + + +# ── #153 A3 — per-file write lock ──────────────────────────────────────── + + +class TestXlsxWriteLock: + def test_concurrent_write_returns_409(self, client, xlsx_file): + from backend.services import mutations + + key = str(Path(xlsx_file).resolve()) + with mutations._xlsx_write_lock(key): + resp = client.put( + f"/api/file/{VAULT}/xlsx/save", + params={"path": "budget.xlsx"}, + json={"sheet": "Budget", "cells": {"A1": "concurrent"}}, + ) + assert resp.status_code == 409 + assert resp.json()["code"] == "conflict" + # The blocked call wrote nothing. + assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "Poste" + + def test_lock_is_released_after_a_normal_save(self, client, xlsx_file): + from backend.services import mutations + + key = str(Path(xlsx_file).resolve()) + client.put( + f"/api/file/{VAULT}/xlsx/save", + params={"path": "budget.xlsx"}, + json={"sheet": "Budget", "cells": {"A1": "premier"}}, + ) + # The lock must be free again once the request returned. + with mutations._xlsx_write_lock(key): + pass + assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "premier" + + +# ── #153 A4 — formula injection ────────────────────────────────────────── + + +class TestXlsxFormulaGuard: + def _save(self, client, cells, **extra): + return client.put( + f"/api/file/{VAULT}/xlsx/save", + params={"path": "budget.xlsx"}, + json={"sheet": "Budget", "cells": cells, **extra}, + ) + + def test_formula_like_value_is_stored_as_text(self, client, xlsx_file): + resp = self._save(client, {"A3": "=cmd|'/c calc'!A1"}) + assert resp.status_code == 200 + cell = openpyxl.load_workbook(xlsx_file)["Budget"]["A3"] + assert cell.value == "=cmd|'/c calc'!A1" + assert cell.data_type == "s" # pas de dans l'archive + + def test_at_prefix_is_stored_as_text(self, client, xlsx_file): + self._save(client, {"A4": "@SUM(A1:A2)"}) + assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].data_type == "s" + + def test_allow_formula_keeps_a_real_formula(self, client, xlsx_file): + resp = self._save(client, {"A3": "=B1+5"}, allow_formula=True) + assert resp.status_code == 200 + assert openpyxl.load_workbook(xlsx_file)["Budget"]["A3"].data_type == "f" + + def test_numbers_are_unaffected(self, client, xlsx_file): + self._save(client, {"B3": "42", "B4": "-3.5"}) + ws = openpyxl.load_workbook(xlsx_file)["Budget"] + assert ws["B3"].value == 42 and isinstance(ws["B3"].value, int) + assert ws["B4"].value == -3.5