feat: garde-fous d'écriture des classeurs Excel #153 (P0)
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 <[email protected]>
This commit is contained in:
@@ -48,7 +48,7 @@ jobs:
|
|||||||
node tests/frontend/media-viewer.test.mjs
|
node tests/frontend/media-viewer.test.mjs
|
||||||
node tests/frontend/mfa-settings.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: |
|
run: |
|
||||||
cd tests/frontend
|
cd tests/frontend
|
||||||
if [ -d node_modules ]; then
|
if [ -d node_modules ]; then
|
||||||
@@ -68,6 +68,7 @@ jobs:
|
|||||||
node ai-quick-actions.test.mjs
|
node ai-quick-actions.test.mjs
|
||||||
node upload.test.mjs
|
node upload.test.mjs
|
||||||
node config-ai-keys.test.mjs
|
node config-ai-keys.test.mjs
|
||||||
|
node xlsx-viewer.test.mjs
|
||||||
else
|
else
|
||||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||||
npm install --no-audit --no-fund --silent
|
npm install --no-audit --no-fund --silent
|
||||||
@@ -87,6 +88,7 @@ jobs:
|
|||||||
node ai-quick-actions.test.mjs
|
node ai-quick-actions.test.mjs
|
||||||
node upload.test.mjs
|
node upload.test.mjs
|
||||||
node config-ai-keys.test.mjs
|
node config-ai-keys.test.mjs
|
||||||
|
node xlsx-viewer.test.mjs
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Tests ─────────────────────────────────────────────────────────
|
# ── Tests ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
+11
@@ -34,6 +34,17 @@ backend/VERSION
|
|||||||
# Artefacts générés par les runs E2E (excalidraw crée ces diagrammes)
|
# Artefacts générés par les runs E2E (excalidraw crée ces diagrammes)
|
||||||
test_vault/IT/e2e-diagram-*.excalidraw
|
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 <chemin>`.
|
||||||
|
test_dir/music/
|
||||||
|
test_dir/video/
|
||||||
|
test_vault/images/
|
||||||
|
test_vault/markdown/
|
||||||
|
test_vault/budget.xlsx
|
||||||
|
|
||||||
# Tauri updater signing keys (private key — never commit)
|
# Tauri updater signing keys (private key — never commit)
|
||||||
desktop/*.key
|
desktop/*.key
|
||||||
desktop/*.key.pub
|
desktop/*.key.pub
|
||||||
|
|||||||
+113
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
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
|
> **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
|
## [2.28.16] — 2026-09-27
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+6
-6
@@ -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.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -23,7 +23,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
|||||||
| Guide | Contenu |
|
| Guide | Contenu |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 🚀 [Prise en main](docs/GUIDES/PRISE_EN_MAIN.md) | Premier lancement, interface, navigation, vaults, raccourcis |
|
| 🚀 [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 `@` / `/` |
|
| 🤖 [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 |
|
| 📝 [É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 |
|
| 📱 [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
|
- **🖼️ 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
|
- **🎬 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)
|
- **🎨 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
|
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
- **🌓 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
|
- **📡 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
|
## 🔍 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
|
### Syntaxe de requête
|
||||||
|
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -21,7 +21,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
|||||||
| Guide | What it covers |
|
| Guide | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 🚀 [Getting Started](docs/GUIDES/PRISE_EN_MAIN.md) | First run, interface, navigation, vaults, shortcuts |
|
| 🚀 [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 |
|
| 🤖 [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 |
|
| 📝 [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 |
|
| 📱 [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
|
- **🖼️ 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
|
- **🎬 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)
|
- **🎨 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
|
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
- **📡 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
|
## 🔍 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
|
### Query Syntax
|
||||||
|
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -1226,6 +1226,12 @@ async def remove_vault_from_index(vault_name: str):
|
|||||||
if not _file_lookup[key]:
|
if not _file_lookup[key]:
|
||||||
_file_lookup.pop(key, None)
|
_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
|
# Clean path_index
|
||||||
path_index.pop(vault_name, None)
|
path_index.pop(vault_name, None)
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -389,8 +389,20 @@ app.openapi = _custom_openapi # type: ignore[method-assign]
|
|||||||
|
|
||||||
@app.exception_handler(ServiceError)
|
@app.exception_handler(ServiceError)
|
||||||
async def _service_error_handler(request: Request, exc: ServiceError):
|
async def _service_error_handler(request: Request, exc: ServiceError):
|
||||||
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``)."""
|
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``).
|
||||||
return JSONResponse(status_code=exc.status, content={"detail": exc.message})
|
|
||||||
|
``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
|
# GZip compression — reduces bandwidth by ~70% for text responses
|
||||||
# Custom wrapper: skip compression for SSE streams (/api/events)
|
# Custom wrapper: skip compression for SSE streams (/api/events)
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
|||||||
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
||||||
},
|
},
|
||||||
("put", "/api/file/{vault_name}/xlsx/save"): {
|
("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},
|
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
||||||
},
|
},
|
||||||
("post", "/api/search/replace"): {
|
("post", "/api/search/replace"): {
|
||||||
|
|||||||
@@ -481,7 +481,7 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
|||||||
"total_postings": word_index_entries,
|
"total_postings": word_index_entries,
|
||||||
"documents": inv.doc_count,
|
"documents": inv.doc_count,
|
||||||
"sorted_tokens": len(inv._sorted_tokens),
|
"sorted_tokens": len(inv._sorted_tokens),
|
||||||
"is_stale": inv.is_stale(),
|
"is_ready": inv.is_ready(),
|
||||||
"memory_estimate_mb": mem_estimate_mb,
|
"memory_estimate_mb": mem_estimate_mb,
|
||||||
},
|
},
|
||||||
"config": _load_config(),
|
"config": _load_config(),
|
||||||
|
|||||||
@@ -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) ===
|
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
|
||||||
if ext == ".xlsx":
|
if ext == ".xlsx":
|
||||||
try:
|
try:
|
||||||
from backend.xlsx_reader import render_sheets
|
from backend.xlsx_reader import inspect_workbook, render_sheets
|
||||||
|
|
||||||
sheets = render_sheets(file_path)
|
sheets = render_sheets(file_path)
|
||||||
size = file_path.stat().st_size
|
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_markdown": False,
|
||||||
"is_xlsx": True,
|
"is_xlsx": True,
|
||||||
"xlsx_sheets": sheets,
|
"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,
|
"unsupported": False,
|
||||||
"size_bytes": size,
|
"size_bytes": size,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,17 +114,35 @@ async def api_file_save(
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
|
@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,
|
vault_name: str,
|
||||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
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),
|
current_user=Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Apply cell edits to an .xlsx workbook.
|
"""Apply cell edits to an .xlsx workbook.
|
||||||
|
|
||||||
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
|
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
|
||||||
scalar values, max 500 per request). A backup is created before the
|
scalar values, max 500 per request) plus two optional boolean flags:
|
||||||
workbook is rewritten.
|
|
||||||
|
* ``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):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
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():
|
for ref, value in cells.items():
|
||||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
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}")
|
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(
|
log_file_save(
|
||||||
current_user["username"], vault_name, path,
|
current_user["username"], vault_name, path,
|
||||||
sum(len(str(v)) for v in cells.values()),
|
sum(len(str(v)) for v in cells.values()),
|
||||||
|
|||||||
@@ -288,6 +288,14 @@ class FileContentResponse(BaseModel):
|
|||||||
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
||||||
default=None, description="Rendered xlsx sheets [{name, html}]"
|
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_json: bool | None = Field(default=None, description="True for JSON files")
|
||||||
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram 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)")
|
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
|
||||||
|
|||||||
+14
-4
@@ -371,9 +371,15 @@ class InvertedIndex:
|
|||||||
self._sorted_tokens: SortedList = SortedList()
|
self._sorted_tokens: SortedList = SortedList()
|
||||||
self._ready: bool = False # True after initial build
|
self._ready: bool = False # True after initial build
|
||||||
|
|
||||||
def is_stale(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
"""Return True if the index has not been built yet."""
|
"""Return True once the initial build has completed.
|
||||||
return not self._ready
|
|
||||||
|
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:
|
def rebuild(self) -> None:
|
||||||
"""Rebuild inverted index from the global ``index`` dict.
|
"""Rebuild inverted index from the global ``index`` dict.
|
||||||
@@ -537,6 +543,10 @@ class InvertedIndex:
|
|||||||
self.doc_vault.pop(doc_key, None)
|
self.doc_vault.pop(doc_key, None)
|
||||||
if vault_name in self.vault_docs:
|
if vault_name in self.vault_docs:
|
||||||
self.vault_docs[vault_name].discard(doc_key)
|
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)
|
# Tags (per-document, NOT the global tag_norm_map)
|
||||||
for tag in file_info.get("tags", []):
|
for tag in file_info.get("tags", []):
|
||||||
td = self.tag_docs.get(tag.lower())
|
td = self.tag_docs.get(tag.lower())
|
||||||
@@ -739,7 +749,7 @@ def search(
|
|||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
|
|
||||||
inv = get_inverted_index()
|
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:
|
if use_index:
|
||||||
# BUG-033: retrieve candidates from the inverted index instead of
|
# BUG-033: retrieve candidates from the inverted index instead of
|
||||||
|
|||||||
@@ -457,10 +457,6 @@ class SemanticIndex:
|
|||||||
"""Return True once a full rebuild has completed."""
|
"""Return True once a full rebuild has completed."""
|
||||||
return self._ready
|
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:
|
def _ensure_provider(self) -> EmbeddingProvider:
|
||||||
if self.provider is None:
|
if self.provider is None:
|
||||||
self.provider = get_embedding_provider()
|
self.provider = get_embedding_provider()
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from collections.abc import Callable
|
import threading
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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).
|
# number; dates/booleans stay text (upgrade path: parse locale dates too).
|
||||||
_XLSX_INT_RE = re.compile(r"^[+-]?\d+$")
|
_XLSX_INT_RE = re.compile(r"^[+-]?\d+$")
|
||||||
_XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\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:
|
def _coerce_xlsx_value(value: Any) -> Any:
|
||||||
@@ -246,6 +283,19 @@ def _coerce_xlsx_value(value: Any) -> Any:
|
|||||||
return value
|
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 ``<f>``: 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(
|
def edit_xlsx_cells(
|
||||||
vault_name: str,
|
vault_name: str,
|
||||||
path: str,
|
path: str,
|
||||||
@@ -253,15 +303,29 @@ def edit_xlsx_cells(
|
|||||||
cells: dict[str, Any],
|
cells: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
backup: bool = True,
|
backup: bool = True,
|
||||||
|
allow_formula: bool = False,
|
||||||
|
force: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
||||||
|
|
||||||
Raises:
|
Args:
|
||||||
ServiceError: ``not_found`` (404), ``read_only`` (403) or
|
vault_name: Name of the vault the workbook belongs to.
|
||||||
``invalid`` (400) for a bad sheet, cell reference or value.
|
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,
|
Raises:
|
||||||
images and pivot tables; use the SheetJS path if a workbook needs those.
|
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)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root)
|
||||||
@@ -286,6 +350,20 @@ def edit_xlsx_cells(
|
|||||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
from backend.xlsx_reader import inspect_workbook
|
||||||
|
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
|
with _xlsx_write_lock(str(file_path)):
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -308,8 +386,16 @@ def edit_xlsx_cells(
|
|||||||
|
|
||||||
ws = wb[sheet]
|
ws = wb[sheet]
|
||||||
for ref, value in cells.items():
|
for ref, value in cells.items():
|
||||||
ws[ref].value = _coerce_xlsx_value(value)
|
_write_cell(ws, ref, value, allow_formula=allow_formula)
|
||||||
wb.save(file_path)
|
# #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)}")
|
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
Read-only: formulas are shown as their text (``data_only=False``) so a
|
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.
|
round-trip through the viewer never depends on Excel's cached values.
|
||||||
Write-side lives in ``backend.services.mutations.edit_xlsx_cells``.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -20,6 +25,29 @@ from openpyxl.utils import get_column_letter
|
|||||||
MAX_ROWS = 500
|
MAX_ROWS = 500
|
||||||
MAX_COLS = 40
|
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: ``<f>…</f><v>…</v>``.
|
||||||
|
# openpyxl writes an EMPTY ``<v></v>`` 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"<f[ >][^<]*</f>\s*<v>[^<]")
|
||||||
|
|
||||||
|
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||||
|
_MAX_PROBE_BYTES = 8_000_000
|
||||||
|
|
||||||
|
|
||||||
def _fmt(value: Any) -> str:
|
def _fmt(value: Any) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -68,6 +96,51 @@ def _table(grid: list[list[str]]) -> str:
|
|||||||
return "".join(out)
|
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]]:
|
def render_sheets(file_path: Path) -> list[dict[str, str]]:
|
||||||
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``."""
|
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``."""
|
||||||
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.28.16"
|
version = "2.29.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.28.16"
|
version = "2.29.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.28.16",
|
"version": "2.29.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ vaults Obsidian et raccourcis essentiels.
|
|||||||
|
|
||||||
> **Public :** tous les utilisateurs · **Durée de lecture :** ~10 min
|
> **Public :** tous les utilisateurs · **Durée de lecture :** ~10 min
|
||||||
> **Voir aussi :** [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) ·
|
> **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)
|
> [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
|
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
|
français, normalisation des accents, facettes et pagination. La syntaxe complète
|
||||||
(`tag:`, `#`, `vault:`, `title:`, `path:`, `ext:`, phrases exactes) est décrite
|
(`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 :
|
Démarrage rapide :
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ Voir [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md).
|
|||||||
|
|
||||||
| Objectif | Guide |
|
| 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) |
|
| Utiliser l'IA intégrée | [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) |
|
||||||
| Éditer à plusieurs | [Édition & collaboration](./COLLABORATION.md) |
|
| Éditer à plusieurs | [Édition & collaboration](./COLLABORATION.md) |
|
||||||
| Sécuriser l'accès | [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) |
|
| Sécuriser l'accès | [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) |
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ captures conceptuelles).
|
|||||||
| Guide | Public | Contenu |
|
| Guide | Public | Contenu |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 🚀 [Prise en main](./PRISE_EN_MAIN.md) | Tous | Premier lancement, interface, navigation, vaults, raccourcis |
|
| 🚀 [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 `@` / `/` |
|
| 🤖 [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 |
|
| 📝 [É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 |
|
| 📱 [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md) | Tous | Installation PWA, cache, file de synchronisation, notifications |
|
||||||
|
|||||||
@@ -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
|
ObsiGate va au-delà de la simple lecture : recherche puissante, rendu des
|
||||||
documents riches (PDF, diagrammes) et indexation de leur contenu pour que tout
|
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
|
Les fichiers `.excalidraw` et `.excalidraw.md` (dont le format compressé du
|
||||||
**plugin Obsidian Excalidraw**) s'ouvrent dans un **éditeur visuel Excalidraw
|
**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
|
### Mermaid
|
||||||
|
|
||||||
@@ -182,7 +233,7 @@ curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Dépannage
|
## 9. Dépannage
|
||||||
|
|
||||||
| Symptôme | Piste |
|
| Symptôme | Piste |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
||||||
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
- **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-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-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-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 `<v>` disparaît du XML de la feuille | `LOSSY_PARTS` + sonde `<f>…</f><v>[^<]` ; 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(<nom>.<pid>.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 `<f>` n'est écrit. Opt-in via `allow_formula: true` (endpoint) et le bouton `f(x)` de la visionneuse (session, jamais persisté). Test : `TestXlsxFormulaGuard` (4) + `xlsx-viewer.test.mjs` (toggle) | #153 A4. `+`/`-` ne sont pas neutralisés : ils sont déjà convertis en nombre par `_coerce_xlsx_value`. Le handler global `ServiceError` expose désormais `code` + `details` (le client en a besoin pour le 409), et `api()` (frontend) les propage sur l'Error. Vérifié : cf. BUG-085 |
|
||||||
|
|
||||||
### TODOs techniques (améliorations / nouvelles tâches)
|
### 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 |
|
| 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) |
|
| *(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-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) |
|
| 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) |
|
||||||
|
|||||||
+53
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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<n>` dans cette
|
||||||
|
Roadmap) ; celles qui sont des **défauts** sont aussi ouvertes comme `BUG-NNN` dans
|
||||||
|
[ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) (A1→BUG-085, A2→BUG-086, A3→BUG-087, A4→BUG-088 ;
|
||||||
|
A8 le sera à son tour).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ⚪ Backlog — Priorité 4 (P4)
|
## ⚪ Backlog — Priorité 4 (P4)
|
||||||
|
|
||||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
### 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 |
|
| 🔵 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é |
|
| ⚪ 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 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** |
|
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -175,6 +226,7 @@
|
|||||||
## Notes
|
## 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.
|
- **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`.
|
- **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.
|
- 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.
|
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
|
||||||
|
|||||||
@@ -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 `<td>`, 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** (`<f>…</f><v>…</v>`) | ❌ **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
|
||||||
|
`<f>…</f><v>[^<]` pour les valeurs en cache (openpyxl écrivant lui-même un `<v></v>` 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(<nom>.<pid>.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<n>` 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 |
|
||||||
+12
-1
@@ -72,14 +72,25 @@ async function api(path, opts) {
|
|||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
var detail = "";
|
var detail = "";
|
||||||
|
var code = "";
|
||||||
|
var details = null;
|
||||||
try {
|
try {
|
||||||
var body = await res.json();
|
var body = await res.json();
|
||||||
detail = body.detail || "";
|
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 (_) {
|
} catch (_) {
|
||||||
/* no json body */
|
/* no json body */
|
||||||
}
|
}
|
||||||
showToast(detail || "Erreur API : " + res.status, "error");
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1222,7 +1222,7 @@ function renderDiagnostics(container, data) {
|
|||||||
["Postings total", data.inverted_index.total_postings.toLocaleString()],
|
["Postings total", data.inverted_index.total_postings.toLocaleString()],
|
||||||
["Documents", data.inverted_index.documents],
|
["Documents", data.inverted_index.documents],
|
||||||
["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"],
|
["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"],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+70
-7
@@ -998,9 +998,21 @@ export function renderVideoViewer(area, data) {
|
|||||||
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
|
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
|
||||||
// Cells are contenteditable; edits are collected per sheet and sent to
|
// Cells are contenteditable; edits are collected per sheet and sent to
|
||||||
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
|
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
|
||||||
// saved back as formulas (no client-side recalculation — ceiling accepted).
|
// never recalculated here.
|
||||||
function renderXlsxViewer(area, data) {
|
//
|
||||||
|
// #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 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
|
const tabs = sheets.length > 1
|
||||||
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
|
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
|
||||||
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
|
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
|
||||||
@@ -1009,18 +1021,33 @@ function renderXlsxViewer(area, data) {
|
|||||||
const panels = sheets.map((s, i) =>
|
const panels = sheets.map((s, i) =>
|
||||||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
|
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
|
||||||
).join("");
|
).join("");
|
||||||
|
const lossWarning = lossy.length
|
||||||
|
? `<div class="xlsx-warning" role="note">
|
||||||
|
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
|
||||||
|
<div class="xlsx-warning-body">
|
||||||
|
<strong>${escapeHtml(t("xlsx.lossy_title"))}</strong>
|
||||||
|
<span class="xlsx-warning-list">${lossy
|
||||||
|
.map((f) => `<span class="xlsx-warning-tag">${escapeHtml(t("xlsx.feature_" + f))}</span>`)
|
||||||
|
.join("")}</span>
|
||||||
|
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.lossy_hint"))}</span>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
area.innerHTML = `
|
area.innerHTML = `
|
||||||
<div class="xlsx-viewer">
|
<div class="xlsx-viewer">
|
||||||
<div class="xlsx-toolbar">
|
<div class="xlsx-toolbar">
|
||||||
${tabs}
|
${tabs}
|
||||||
<span class="xlsx-toolbar-actions">
|
<span class="xlsx-toolbar-actions">
|
||||||
|
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||||
|
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||||
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||||||
<button class="btn-action" id="xlsx-download-btn">
|
<button class="btn-action" id="xlsx-download-btn">
|
||||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
${lossWarning}
|
||||||
<div class="xlsx-panels">${panels}</div>
|
<div class="xlsx-panels">${panels}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -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 () => {
|
saveBtn.addEventListener("click", async () => {
|
||||||
// One PUT per sheet (dirty cells can span tabs before a save).
|
// One PUT per sheet (dirty cells can span tabs before a save).
|
||||||
const jobs = panelEls
|
const jobs = panelEls
|
||||||
@@ -1074,11 +1118,25 @@ function renderXlsxViewer(area, data) {
|
|||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
await api(`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, {
|
// 409 xlsx_lossy_content → confirm once, then retry with force: true.
|
||||||
method: "PUT",
|
// (Also covers a workbook that became lossy while it was open.)
|
||||||
headers: { "Content-Type": "application/json" },
|
let force = lossyConfirmed;
|
||||||
body: JSON.stringify(job),
|
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) => {
|
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
|
||||||
td.classList.remove("xlsx-dirty");
|
td.classList.remove("xlsx-dirty");
|
||||||
@@ -1088,8 +1146,13 @@ function renderXlsxViewer(area, data) {
|
|||||||
showToast(t("editor.saved"), "success");
|
showToast(t("editor.saved"), "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
refreshSaveState();
|
refreshSaveState();
|
||||||
|
// 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");
|
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
area.querySelector("#xlsx-download-btn").addEventListener("click", () => {
|
area.querySelector("#xlsx-download-btn").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -1823,6 +1823,19 @@
|
|||||||
"viewer.copy": "Copy",
|
"viewer.copy": "Copy",
|
||||||
"viewer.copy_error": "Copy error",
|
"viewer.copy_error": "Copy error",
|
||||||
"viewer.download": "Download",
|
"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_md": "Download as .md",
|
||||||
"viewer.download_file": "Download file",
|
"viewer.download_file": "Download file",
|
||||||
"viewer.pretty": "Pretty",
|
"viewer.pretty": "Pretty",
|
||||||
|
|||||||
@@ -1823,6 +1823,19 @@
|
|||||||
"viewer.copy": "Copier",
|
"viewer.copy": "Copier",
|
||||||
"viewer.copy_error": "Erreur lors de la copie",
|
"viewer.copy_error": "Erreur lors de la copie",
|
||||||
"viewer.download": "Télécharger",
|
"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_md": "Télécharger en .md",
|
||||||
"viewer.download_file": "Télécharger le fichier",
|
"viewer.download_file": "Télécharger le fichier",
|
||||||
"viewer.pretty": "Pretty",
|
"viewer.pretty": "Pretty",
|
||||||
|
|||||||
@@ -10987,6 +10987,66 @@ body.desktop-mode .editor-container {
|
|||||||
background: rgba(255, 196, 0, 0.18);
|
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 ── */
|
||||||
.json-viewer {
|
.json-viewer {
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
* 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 CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -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
|
## 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.
|
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
|
## Dependency Architecture
|
||||||
|
|
||||||
**Current import chain:**
|
**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.
|
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.
|
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).
|
||||||
|
|||||||
Binary file not shown.
@@ -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 (`<f>B1*2</f><v>200</v>`) 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<div id="content-area"></div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ 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) =>
|
||||||
|
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||||
|
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
|
||||||
|
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td></tr>` +
|
||||||
|
"</tbody></table></div>";
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -115,6 +115,25 @@ class TestInvertedIndex:
|
|||||||
inv.remove_document("V", "p.md")
|
inv.remove_document("V", "p.md")
|
||||||
assert inv.doc_count == 0 # Skipped
|
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
|
# Search / Advanced Search integration tests
|
||||||
@@ -192,3 +211,40 @@ class TestSearchFunctions:
|
|||||||
def test_suggest_tags_no_match(self, client):
|
def test_suggest_tags_no_match(self, client):
|
||||||
suggestions = suggest_tags("xyznonexistent", vault_filter="all")
|
suggestions = suggest_tags("xyznonexistent", vault_filter="all")
|
||||||
assert len(suggestions) == 0
|
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
|
||||||
|
|||||||
+210
-1
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -29,6 +35,43 @@ def xlsx_file(test_vault_dir: str) -> str:
|
|||||||
return str(path)
|
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)
|
||||||
|
# <f>…</f><v>…</v> : 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(
|
||||||
|
"<f>A1*3</f>", "<f>A1*3</f><v>9</v>"
|
||||||
|
)
|
||||||
|
items[sheet] = xml.encode("utf-8")
|
||||||
|
items["xl/slicers/slicer1.xml"] = b"<slicer/>"
|
||||||
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for name, blob in items.items():
|
||||||
|
zf.writestr(name, blob)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
# ── Display ───────────────────────────────────────────────────────────────
|
# ── Display ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -150,3 +193,169 @@ class TestXlsxSave:
|
|||||||
def test_missing_sheet_field_400(self, client, xlsx_file):
|
def test_missing_sheet_field_400(self, client, xlsx_file):
|
||||||
resp = self._save(client, {"cells": {"A1": "x"}})
|
resp = self._save(client, {"cells": {"A1": "x"}})
|
||||||
assert resp.status_code == 400
|
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 <v> 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"<x/>" 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 <f> 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
|
||||||
|
|||||||
Reference in New Issue
Block a user