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:
2026-09-27 20:39:12 -04:00
parent 4c4b1222d5
commit 31d4616baf
40 changed files with 1628 additions and 87 deletions
+3 -1
View File
@@ -48,7 +48,7 @@ jobs:
node tests/frontend/media-viewer.test.mjs
node tests/frontend/mfa-settings.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload)
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload + XLSX)
run: |
cd tests/frontend
if [ -d node_modules ]; then
@@ -68,6 +68,7 @@ jobs:
node ai-quick-actions.test.mjs
node upload.test.mjs
node config-ai-keys.test.mjs
node xlsx-viewer.test.mjs
else
echo "tests/frontend/node_modules missing - installing jsdom"
npm install --no-audit --no-fund --silent
@@ -87,6 +88,7 @@ jobs:
node ai-quick-actions.test.mjs
node upload.test.mjs
node config-ai-keys.test.mjs
node xlsx-viewer.test.mjs
fi
# ── Tests ─────────────────────────────────────────────────────────
+11
View File
@@ -34,6 +34,17 @@ backend/VERSION
# Artefacts générés par les runs E2E (excalidraw crée ces diagrammes)
test_vault/IT/e2e-diagram-*.excalidraw
# Fixtures de test locales non versionnées (~200 Mo, pas de fixture CI).
# Aucun test/CI ne les référence : les tests unitaires génèrent leurs fixtures
# dans tmp_path (tests/conftest.py), et l'E2E n'utilise que les fixtures
# committées (test_vault/sample-*.{mp3,png,svg,webm,pdf}, test_dir/*.md).
# → à committer volontairement : `git add -f <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)
desktop/*.key
desktop/*.key.pub
+113 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.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
---
+6 -6
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.28.16-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.29.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -23,7 +23,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
| Guide | Contenu |
|---|---|
| 🚀 [Prise en main](docs/GUIDES/PRISE_EN_MAIN.md) | Premier lancement, interface, navigation, vaults, raccourcis |
| 🔍 [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
| 🔍 [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteurs PDF/Excel, diagrammes |
| 🤖 [Assistant IA & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
| 📝 [Édition & collaboration](docs/GUIDES/COLLABORATION.md) | Édition simultanée, curseurs distants, persistance |
| 📱 [PWA & hors-ligne](docs/GUIDES/PWA_HORS_LIGNE.md) | Installation, cache hors-ligne, file de synchro, notifications |
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
- **📊 Tableurs Excel** : les fichiers `.xlsx` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique), plus le téléchargement du fichier d'origine
- **📊 Tableurs Excel** : les fichiers `.xlsx` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
@@ -673,7 +673,7 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
## 🔍 Recherche avancée
> 📖 Guide complet : [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
> 📖 Guide complet : [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
### Syntaxe de requête
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.16).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.29.0).
---
*Projet : ObsiGate | Version : 2.28.16 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.29.0 | Dernière mise à jour : Septembre 2026*
+6 -6
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.28.16-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.29.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -21,7 +21,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
| Guide | What it covers |
|---|---|
| 🚀 [Getting Started](docs/GUIDES/PRISE_EN_MAIN.md) | First run, interface, navigation, vaults, shortcuts |
| 🔍 [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF viewer, diagrams |
| 🔍 [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF/Excel viewers, diagrams |
| 🤖 [AI Assistant & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Providers, AI editor, BooksLM, Forge, `@` / `/` commands |
| 📝 [Editing & Collaboration](docs/GUIDES/COLLABORATION.md) | Simultaneous editing, remote cursors, persistence |
| 📱 [PWA & Offline](docs/GUIDES/PWA_HORS_LIGNE.md) | Install as an app, offline cache, sync queue, push |
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
- **📊 Excel Spreadsheets** : `.xlsx` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup), plus download of the original file
- **📊 Excel Spreadsheets** : `.xlsx` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
@@ -804,7 +804,7 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
## 🔍 Advanced Search
> 📖 Full guide: [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
> 📖 Full guide: [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
### Query Syntax
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.16).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.29.0).
---
*Project: ObsiGate | Version: 2.28.16 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.29.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.28.16
2.29.0
+6
View File
@@ -1226,6 +1226,12 @@ async def remove_vault_from_index(vault_name: str):
if not _file_lookup[key]:
_file_lookup.pop(key, None)
# Notify the inverted index, otherwise every document of the vault
# stays in it as a ghost (postings, doc_info, doc_vault, vault_docs)
# and keeps matching searches for a vault that no longer exists.
if _on_index_change:
_on_index_change('remove', vault_name, rel_path, f) # type: ignore[misc]
# Clean path_index
path_index.pop(vault_name, None)
+14 -2
View File
@@ -389,8 +389,20 @@ app.openapi = _custom_openapi # type: ignore[method-assign]
@app.exception_handler(ServiceError)
async def _service_error_handler(request: Request, exc: ServiceError):
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``)."""
return JSONResponse(status_code=exc.status, content={"detail": exc.message})
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``).
``code`` and ``details`` travel with the message so the client can react to
a specific case instead of parsing prose (#153 A1 : ``xlsx_lossy_content``
asks the viewer to confirm before forcing a lossy write).
"""
return JSONResponse(
status_code=exc.status,
content={
"detail": exc.message,
"code": exc.code,
"details": exc.details,
},
)
# GZip compression — reduces bandwidth by ~70% for text responses
# Custom wrapper: skip compression for SSE streams (/api/events)
+1 -1
View File
@@ -182,7 +182,7 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
},
("put", "/api/file/{vault_name}/xlsx/save"): {
"request": {"sheet": "Budget", "cells": {"B1": "250"}},
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
},
("post", "/api/search/replace"): {
+1 -1
View File
@@ -481,7 +481,7 @@ async def api_diagnostics(current_user=Depends(require_admin)):
"total_postings": word_index_entries,
"documents": inv.doc_count,
"sorted_tokens": len(inv._sorted_tokens),
"is_stale": inv.is_stale(),
"is_ready": inv.is_ready(),
"memory_estimate_mb": mem_estimate_mb,
},
"config": _load_config(),
+4 -1
View File
@@ -241,7 +241,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
if ext == ".xlsx":
try:
from backend.xlsx_reader import render_sheets
from backend.xlsx_reader import inspect_workbook, render_sheets
sheets = render_sheets(file_path)
size = file_path.stat().st_size
@@ -257,6 +257,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
# #153 A1 — parts a save would drop; the viewer warns and asks
# for an explicit confirmation before forcing the write.
"xlsx_lossy_features": inspect_workbook(file_path),
"unsupported": False,
"size_bytes": size,
}
+31 -5
View File
@@ -114,17 +114,35 @@ async def api_file_save(
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
async def api_file_xlsx_save(
def api_file_xlsx_save(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx file"),
body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'),
body: dict = Body(
...,
description=(
'{"sheet": str, "cells": {"A1": value}, '
'"allow_formula": false, "force": false}'
),
),
current_user=Depends(require_auth),
):
"""Apply cell edits to an .xlsx workbook.
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
scalar values, max 500 per request). A backup is created before the
workbook is rewritten.
scalar values, max 500 per request) plus two optional boolean flags:
* ``allow_formula`` — keep values starting with ``=``/``@`` as real
formulas. Off by default (#153 A4): such a value is stored as text so a
later Excel session cannot execute it (DDE).
* ``force`` — write a workbook carrying features openpyxl cannot re-serialize
(slicers, form controls, connections, custom XML, signature, cached formula
results). Without it the call fails **409** ``xlsx_lossy_content`` and the
client asks the user to confirm (#153 A1).
A backup is created before the workbook is rewritten, and the new archive
swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl
round-trip and the per-file lock wait (#153 A3) then run in the threadpool
instead of blocking the event loop.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
@@ -138,8 +156,16 @@ async def api_file_xlsx_save(
for ref, value in cells.items():
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
flags: dict[str, bool] = {}
for name in ("allow_formula", "force"):
raw = body.get(name, False)
if not isinstance(raw, bool):
raise HTTPException(status_code=400, detail=f"Flag invalide: {name}")
flags[name] = raw
result = service_edit_xlsx_cells(vault_name, path, sheet, cells)
result = service_edit_xlsx_cells(
vault_name, path, sheet, cells, **flags
)
log_file_save(
current_user["username"], vault_name, path,
sum(len(str(v)) for v in cells.values()),
+8
View File
@@ -288,6 +288,14 @@ class FileContentResponse(BaseModel):
xlsx_sheets: list[dict[str, Any]] | None = Field(
default=None, description="Rendered xlsx sheets [{name, html}]"
)
xlsx_lossy_features: list[str] | None = Field(
default=None,
description=(
"Workbook parts an openpyxl save would drop (#153 A1) — e.g. "
"cached_values, slicers, form_controls, connections, custom_xml, "
"signature, rich_comments, macros. Empty/absent = nothing at risk."
),
)
is_json: bool | None = Field(default=None, description="True for JSON files")
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
+14 -4
View File
@@ -371,9 +371,15 @@ class InvertedIndex:
self._sorted_tokens: SortedList = SortedList()
self._ready: bool = False # True after initial build
def is_stale(self) -> bool:
"""Return True if the index has not been built yet."""
return not self._ready
def is_ready(self) -> bool:
"""Return True once the initial build has completed.
The index is then kept current incrementally by ``add_document()`` /
``remove_document()``, so it never goes stale: there is no generation
counter, no cooldown and no lazy rebuild. Searches simply fall back to
a full scan while this is False (see ``search()``).
"""
return self._ready
def rebuild(self) -> None:
"""Rebuild inverted index from the global ``index`` dict.
@@ -537,6 +543,10 @@ class InvertedIndex:
self.doc_vault.pop(doc_key, None)
if vault_name in self.vault_docs:
self.vault_docs[vault_name].discard(doc_key)
# Drop the empty entry so a fully removed vault leaves no trace
# (it is a defaultdict: a bare lookup would recreate the key).
if not self.vault_docs[vault_name]:
del self.vault_docs[vault_name]
# Tags (per-document, NOT the global tag_norm_map)
for tag in file_info.get("tags", []):
td = self.tag_docs.get(tag.lower())
@@ -739,7 +749,7 @@ def search(
results: list[dict[str, Any]] = []
inv = get_inverted_index()
use_index = (not inv.is_stale()) and inv.doc_count > 0
use_index = inv.is_ready() and inv.doc_count > 0
if use_index:
# BUG-033: retrieve candidates from the inverted index instead of
-4
View File
@@ -457,10 +457,6 @@ class SemanticIndex:
"""Return True once a full rebuild has completed."""
return self._ready
def is_stale(self) -> bool:
"""Alias used by callers that check index freshness."""
return not self._ready
def _ensure_provider(self) -> EmbeddingProvider:
if self.provider is None:
self.provider = get_embedding_provider()
+113 -27
View File
@@ -16,7 +16,9 @@ import logging
import os
import re
import shutil
from collections.abc import Callable
import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
@@ -230,6 +232,41 @@ _XLSX_CELL_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,7}$")
# number; dates/booleans stay text (upgrade path: parse locale dates too).
_XLSX_INT_RE = re.compile(r"^[+-]?\d+$")
_XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$")
# #153 A4 — openpyxl turns any string starting with "=" into a formula, which
# Excel then evaluates on open (DDE / =cmd|… / =HYPERLINK exfiltration). "@" is
# the legacy Lotus-style trigger. "+"/"-" are left alone: they are numbers here.
_XLSX_FORMULA_RE = re.compile(r"^[=@]")
# #153 A3 — per-file write lock. Two concurrent saves (two tabs, the AI agent
# and the viewer, a watcher restore) would otherwise read-modify-write on the
# same archive and the last writer silently wins. Kept deliberately small: the
# lock only covers the load → edit → atomic-replace window.
_XLSX_LOCK_TIMEOUT = 15.0
_xlsx_locks: dict[str, threading.Lock] = {}
_xlsx_locks_guard = threading.Lock()
@contextmanager
def _xlsx_write_lock(key: str) -> Iterator[None]:
"""Serialize the read-modify-write of one workbook path.
Raises:
ServiceError: ``conflict`` (409) when the lock is still held after
:data:`_XLSX_LOCK_TIMEOUT` seconds.
"""
with _xlsx_locks_guard:
lock = _xlsx_locks.setdefault(key, threading.Lock())
if not lock.acquire(timeout=_XLSX_LOCK_TIMEOUT):
raise ServiceError(
"Workbook is being modified by another operation, retry shortly",
code="conflict",
status=409,
details={"path": key, "timeout_seconds": _XLSX_LOCK_TIMEOUT},
)
try:
yield
finally:
lock.release()
def _coerce_xlsx_value(value: Any) -> Any:
@@ -246,6 +283,19 @@ def _coerce_xlsx_value(value: Any) -> Any:
return value
def _write_cell(ws: Any, ref: str, value: Any, *, allow_formula: bool) -> None:
"""Assign one cell, forcing text when it looks like a formula.
``cell.data_type = "s"`` is what stops openpyxl from emitting ``<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(
vault_name: str,
path: str,
@@ -253,15 +303,29 @@ def edit_xlsx_cells(
cells: dict[str, Any],
*,
backup: bool = True,
allow_formula: bool = False,
force: bool = False,
) -> dict[str, Any]:
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403) or
``invalid`` (400) for a bad sheet, cell reference or value.
Args:
vault_name: Name of the vault the workbook belongs to.
path: Vault-relative path of the ``.xlsx`` file.
sheet: Worksheet title to edit.
cells: Mapping of A1 references to new scalar values.
backup: Create a timestamped ``.bak`` before rewriting the archive.
allow_formula: Keep values starting with ``=``/``@`` as real formulas.
Off by default (#153 A4): a typed ``=cmd|…`` is a DDE payload when
the file is later opened in Excel.
force: Write even when the workbook carries features openpyxl drops
(slicers, form controls, connections, custom XML, signature, cached
formula results — see :data:`backend.xlsx_reader.LOSSY_PARTS`).
ponytail: openpyxl round-trips values/formulas/styles but drops charts,
images and pivot tables; use the SheetJS path if a workbook needs those.
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
(409, concurrent write), ``xlsx_lossy_content`` (409, a lossy write was
attempted without ``force``) or ``invalid`` (400) for a bad sheet, cell
reference or value.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
@@ -286,30 +350,52 @@ def edit_xlsx_cells(
f"Invalid cell reference: {ref!r}", code="invalid", status=400
)
from openpyxl import load_workbook
if not force:
from backend.xlsx_reader import inspect_workbook
try:
wb = load_workbook(file_path)
except Exception as exc:
raise ServiceError(
f"Cannot open workbook: {exc}", code="invalid", status=400
) from exc
if sheet not in wb.sheetnames:
raise ServiceError(
f"Unknown sheet: {sheet}",
code="invalid",
status=400,
details={"sheets": wb.sheetnames},
)
lossy = inspect_workbook(file_path)
if lossy:
raise ServiceError(
"Saving this workbook would drop features ObsiGate cannot "
"preserve; retry with force=true after confirmation",
code="xlsx_lossy_content",
status=409,
details={"path": path, "features": lossy},
)
rel_path = _rel(root, file_path)
if backup:
create_backup(file_path, vault_name, rel_path)
with _xlsx_write_lock(str(file_path)):
from openpyxl import load_workbook
ws = wb[sheet]
for ref, value in cells.items():
ws[ref].value = _coerce_xlsx_value(value)
wb.save(file_path)
try:
wb = load_workbook(file_path)
except Exception as exc:
raise ServiceError(
f"Cannot open workbook: {exc}", code="invalid", status=400
) from exc
if sheet not in wb.sheetnames:
raise ServiceError(
f"Unknown sheet: {sheet}",
code="invalid",
status=400,
details={"sheets": wb.sheetnames},
)
rel_path = _rel(root, file_path)
if backup:
create_backup(file_path, vault_name, rel_path)
ws = wb[sheet]
for ref, value in cells.items():
_write_cell(ws, ref, value, allow_formula=allow_formula)
# #153 A2 — write beside the target then swap: a crash mid-save leaves
# the original workbook intact instead of a truncated archive.
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
try:
wb.save(tmp_path)
os.replace(tmp_path, file_path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
return {
+73
View File
@@ -3,11 +3,16 @@
Read-only: formulas are shown as their text (``data_only=False``) so a
round-trip through the viewer never depends on Excel's cached values.
Write-side lives in ``backend.services.mutations.edit_xlsx_cells``.
:func:`inspect_workbook` lists the workbook features that an openpyxl
round-trip would drop (#153 A1) so the UI can warn before saving.
"""
from __future__ import annotations
import html
import re
import zipfile
from datetime import date, datetime
from pathlib import Path
from typing import Any
@@ -20,6 +25,29 @@ from openpyxl.utils import get_column_letter
MAX_ROWS = 500
MAX_COLS = 40
# #153 A1 — workbook parts openpyxl does not re-serialize on load+save.
# Verified against openpyxl 3.1.5: charts, images, drawings and pivot tables
# DO survive the round-trip, so they are deliberately absent from this map.
LOSSY_PARTS: dict[str, tuple[str, ...]] = {
"slicers": ("xl/slicers/", "xl/slicerCaches/", "xl/timelines/"),
"form_controls": ("xl/ctrlProps/", "xl/activeX/"),
"connections": ("xl/queryTables/", "xl/connections.xml"),
"custom_xml": ("customXml/",),
"signature": ("_xmlsignatures/",),
"rich_comments": ("xl/threadedComments/", "xl/persons/"),
"macros": ("xl/vbaProject.bin",),
}
# A formula cell carrying its last computed result: ``<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:
if value is None:
@@ -68,6 +96,51 @@ def _table(grid: list[list[str]]) -> str:
return "".join(out)
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
"""True when at least one formula cell still carries its computed value."""
budget = _MAX_PROBE_BYTES
for name in zf.namelist():
if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"):
continue
try:
with zf.open(name) as fh:
while budget > 0:
chunk = fh.read(65536)
if not chunk:
break
budget -= len(chunk)
if _CACHED_FORMULA_RE.search(chunk):
return True
except (KeyError, OSError, zipfile.BadZipFile):
continue
return False
def inspect_workbook(file_path: Path) -> list[str]:
"""Return the sorted keys of :data:`LOSSY_PARTS` present in *file_path*.
Read-only inspection of the OPC package (central directory + a bounded scan
of the sheet XML). Never raises: an unreadable or encrypted workbook simply
yields ``[]`` and the save path keeps its current behaviour.
``cached_values`` is a synthetic key: openpyxl keeps the formula but drops
the cached result, so the workbook stays correct once Excel recalculates it.
"""
try:
with zipfile.ZipFile(file_path) as zf:
names = set(zf.namelist())
found = {
key
for key, prefixes in LOSSY_PARTS.items()
if any(name.startswith(prefix) for name in names for prefix in prefixes)
}
if _has_cached_formulas(zf):
found.add("cached_values")
return sorted(found)
except (OSError, zipfile.BadZipFile):
return []
def render_sheets(file_path: Path) -> list[dict[str, str]]:
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``."""
wb = load_workbook(str(file_path), read_only=True, data_only=False)
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.16"
version = "2.29.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.28.16"
version = "2.29.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.28.16",
"version": "2.29.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+3 -3
View File
@@ -6,7 +6,7 @@ vaults Obsidian et raccourcis essentiels.
> **Public :** tous les utilisateurs · **Durée de lecture :** ~10 min
> **Voir aussi :** [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) ·
> [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) ·
> [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) ·
> [API REST](./API_REST.md)
---
@@ -185,7 +185,7 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
La recherche est un point fort d'ObsiGate : index inversé TF-IDF, stemming
français, normalisation des accents, facettes et pagination. La syntaxe complète
(`tag:`, `#`, `vault:`, `title:`, `path:`, `ext:`, phrases exactes) est décrite
dans le [Guide Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md).
dans le [Guide Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md).
Démarrage rapide :
@@ -234,7 +234,7 @@ Voir [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md).
| Objectif | Guide |
|---|---|
| Mieux chercher, lire PDF et Excalidraw | [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) |
| Mieux chercher, lire PDF/Excel et Excalidraw | [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) |
| Utiliser l'IA intégrée | [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) |
| Éditer à plusieurs | [Édition & collaboration](./COLLABORATION.md) |
| Sécuriser l'accès | [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) |
+1 -1
View File
@@ -15,7 +15,7 @@ captures conceptuelles).
| Guide | Public | Contenu |
|---|---|---|
| 🚀 [Prise en main](./PRISE_EN_MAIN.md) | Tous | Premier lancement, interface, navigation, vaults, raccourcis |
| 🔍 [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
| 🔍 [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteurs PDF/Excel, diagrammes |
| 🤖 [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) | Tous | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
| 📝 [Édition & collaboration](./COLLABORATION.md) | Tous | Édition simultanée, curseurs distants, persistance |
| 📱 [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md) | Tous | Installation PWA, cache, file de synchronisation, notifications |
+55 -4
View File
@@ -1,4 +1,4 @@
# 🔍 Guide Recherche, PDF & Excalidraw
# 🔍 Guide Recherche, PDF, Excel & Excalidraw
ObsiGate va au-delà de la simple lecture : recherche puissante, rendu des
documents riches (PDF, diagrammes) et indexation de leur contenu pour que tout
@@ -131,7 +131,58 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
---
## 6. Diagrammes Excalidraw
## 6. Tableurs Excel (XLSX)
### Affichage et édition
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
feuille, des onglets pour naviguer entre elles, les en-têtes A1/B1 et les
numéros de ligne. Chaque cellule est modifiable directement (clic), `Entrée`
valide, `Échap` annule la saisie. **Enregistrer** envoie les cellules
modifiées à `PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille,
avec **backup automatique** du fichier avant écriture, et une écriture
**atomique** (le classeur n'est jamais laissé à moitié écrit).
### Avertissement avant enregistrement
Certains classeurs contiennent des éléments qu'ObsiGate ne sait pas
réécrire : **valeurs calculées** mises en cache par Excel, segments
(slicers), chronologies, contrôles de formulaire, connexions/requêtes,
XML personnalisé, signature numérique, commentaires enrichis, macros.
L'ouverture affiche alors un bandeau qui les liste, et la première
sauvegarde demande confirmation. Si vous refusez, rien n'est écrit.
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
### Formules
Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme
texte** (une formule injectée s'exécuterait à l'ouverture du fichier dans
Excel). Le bouton `f(x)` de la barre d'outils active les vraies formules pour
la session en cours.
```bash
curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx" -H "Content-Type: application/json" -d '{"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": false, "force": false}'
```
- `allow_formula` : `true` pour écrire une vraie formule (`=B1*2`).
- `force` : `true` pour enregistrer malgré les éléments non préservés
(sinon l'API répond **409** `xlsx_lossy_content`).
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
**409** `conflict` au lieu d'écraser la première.
### Limites
- Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille, sans
pagination : au-delà, le contenu n'est pas affiché (et non éditable).
- Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas
rendus ; le contenu des tableurs n'est pas non plus indexé pour la
recherche (contrairement aux PDF).
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
---
## 7. Diagrammes Excalidraw
Les fichiers `.excalidraw` et `.excalidraw.md` (dont le format compressé du
**plugin Obsidian Excalidraw**) s'ouvrent dans un **éditeur visuel Excalidraw
@@ -147,7 +198,7 @@ Fiche technique : [`features/excalidraw.md`](../features/excalidraw.md).
---
## 7. Autres contenus riches
## 8. Autres contenus riches
### Mermaid
@@ -182,7 +233,7 @@ curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes"
---
## 8. Dépannage
## 9. Dépannage
| Symptôme | Piste |
|---|---|
+7 -1
View File
@@ -14,7 +14,7 @@
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
- **Dernière mise à jour** : 2026-09-24
- **Dernière mise à jour** : 2026-09-27
---
@@ -190,6 +190,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
| *BUG-081* | `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée (`user` None, `AttributeError` sur `user.get`) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/auth/router.py::mfa_status`, `tests/test_mfa.py` | Auth désactivée : `curl http://127.0.0.1:2029/api/auth/mfa/status` → 500 (reproduit live 2026-09-27) | Garde `user is None` → payload MFA désactivé (`mfa_enabled: false`, `totp_enabled: false`, `webauthn_credentials: 0`) ; test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0 | `require_auth` laisse passer le pseudo-user anonymous, `get_user(username)` → None non gardé. Trouvé via les logs E2E pendant BUG-080 |
| *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 |
| *BUG-084* | Index inversé : la suppression d'une vault y laisse des documents fantômes (résultats pour une vault inexistante) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/indexer.py::remove_vault_from_index`, `backend/search.py::_remove_doc_internals` | Supprimer une vault configurée, puis chercher un terme contenu dans ses fichiers → les résultats la concernent encore | `remove_vault_from_index()` déclenche `_on_index_change('remove', …)` pour chaque fichier de la vault ; `_remove_doc_internals()` supprime la clé `vault_docs` dont le set devient vide (`defaultdict` : une lecture la recréait). Test `tests/test_search_advanced.py::TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le correctif) | Trouvé pendant la relecture de `plan.md` (étape 6 déjà livrée). Mesuré : 8 documents fantômes sur 8 après suppression de la vault de test (`postings`, `doc_info`, `doc_vault`, `vault_docs`) ; seul un reindex manuel les effaçait. Vérifié : `test_search_advanced.py` 27 passed, ruff/mypy 0, suite complète 1374 passed / 6 skipped |
| *BUG-085* | Édition d'un `.xlsx` : les valeurs calculées en cache disparaissent du classeur (et tout lecteur `data_only=True` voit `None`) | 🟢 corrigé | P1 | tableur Excel | IA | `backend/xlsx_reader.py::inspect_workbook`, `backend/services/mutations.py::edit_xlsx_cells`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | Ouvrir un classeur contenant `=B1*2` (avec sa valeur calculée) → éditer une cellule → le `<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)
@@ -207,6 +212,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|---|---|---|---|---|---|
| 2026-09-27 | BUG-085 → BUG-088 (#153 A1-A4) | Correction | `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/main.py`, `frontend/js/viewer.js`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-lossy.xlsx`, `.gitea/workflows/ci.yml` | **Garde-fous d'écriture des classeurs Excel** : (BUG-085) `inspect_workbook()` détecte ce qu'un round-trip openpyxl perd (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) → la lecture expose `xlsx_lossy_features`, la visionneuse affiche une bannière et `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`, confirmation explicite puis reprise) ; (BUG-086) écriture atomique `.tmp` + `os.replace` ; (BUG-087) verrou par fichier (409 `conflict`, endpoint sync pour le threadpool) ; (BUG-088) une saisie `=`/`@` est stockée en texte (`data_type = "s"`), sauf opt-in `allow_formula` / bouton `f(x)`. Le handler `ServiceError` expose désormais `code` + `details` et `api()` les propage. Périmètre de perte revalidé empiriquement sur openpyxl 3.1.5 (graphiques, images et TCD sont préservés). Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10, E2E 3/3 | 🟢 corrigé (en attente vérif utilisateur) |
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
| 2026-09-09 | BUG-001, BUG-002 | Correction | `backend/main.py`, `frontend/excalidraw-editor.html`, `tests/test_pdf_stream.py` | BUG-001: Content-Disposition RFC 5987 (nom PDF accentué ne casse plus l'en-tête → plus de 500). BUG-002: suppression alias esm.sh (408 jotai) + React 19 cohérent + prop `excalidrawAPI` → Loading masqué, save OK. Vérifié: 534 tests backend verts + E2E navigateur. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-11 | BUG-003, BUG-004 | Correction | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml`, `README.md`, `README.fr.md` | BUG-003: 33 erreurs mypy corrigées (annotations, gardes `None`, import `PROVIDERS` manquant → bug latent) + étape CI mypy rendue bloquante. BUG-004: lien `README.md` → `docs/CONTRIBUTING.md`. Vérifié: mypy 0 erreur, ruff OK, pytest 728 passed, frontend OK. | 🟢 corrigé (en attente vérif utilisateur) |
+53 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.28.16 | **Dernière mise à jour :** 2026-09-27
> **Version :** 2.29.0 | **Dernière mise à jour :** 2026-09-27
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -42,6 +42,56 @@
---
## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2)
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
- **Effort :** 8-13 jours (P0 ✅ 2-3 j · P1 : 4-6 j · P2 : 2-4 j) | **Impact :** 🟡
- **Statut :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), reste P1 puis P2
- **Analyse, risques et critères d'acceptation :** [features/xlsx-viewer.md](./features/xlsx-viewer.md)
- **Description :** #152 (visionneuse XLSX, 2.27.0) lit et édite correctement la **grille de
valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure,
ni styles en écriture, ni formule recalculée), **écriture destructive** d'une partie du classeur,
tableurs **invisibles à la recherche** et **inutilisables par l'IA** au-delà de la création. Ce
lot suit ces ajouts ; les cases ci-dessous sont le **suivi de référence**, la fiche feature porte
le détail.
- **Constat (points de départ) :** `MAX_ROWS = 500` / `MAX_COLS = 40` sans indicateur (troncature
silencieuse) · `wb.save()` non atomique et sans verrou (concurrence) · saisie `=…` stockée comme
formule par openpyxl (injection DDE) · `content=""` à l'indexation (recherche TF-IDF et sémantique
aveugles) · aucun outil IA de lecture/édition d'un classeur existant · aucun test frontend ni
E2E sur le viewer.
- **Périmètre réel des pertes au round-trip (mesuré sur openpyxl 3.1.5, 2026-09-27) :** graphiques,
images, dessins **et** tableaux croisés sont préservés ; sont perdus les **valeurs calculées en
cache**, slicers/chronologies, contrôles de formulaire, connexions/requêtes, custom XML,
signature numérique, commentaires enrichis et macros.
- **Sous-tâches :**
- **P0 — garde-fous d'écriture (🔴, 2-3 j) — 🟢 livré**
- [x] **A1** Alerte de fidélité avant écriture : `inspect_workbook()` → `xlsx_lossy_features` + bandeau FR/EN + **409** `xlsx_lossy_content` sans `force` (confirmation explicite puis reprise) — BUG-085
- [x] **A2** Écriture atomique (`wb.save(.tmp)` + `os.replace()`, backup inchangé) — BUG-086
- [x] **A3** Verrou par fichier autour du read-modify-write (timeout 15 s + **409** `conflict`) — BUG-087
- [x] **A4** Neutralisation de l'injection de formule (`=`/`@` stockés en texte, opt-in `allow_formula` + bouton `f(x)`) — BUG-088
- **P1 — recherche, IA, UX (🟡, 4-6 j) — ⚪ à faire**
- [ ] **A5** Indexation du contenu des feuilles (TF-IDF + sémantique, plafond ~5 k caractères)
- [ ] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets`
- [ ] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches, `Maj+Entrée`, copie de plage)
- [ ] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse)
- [ ] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
- [ ] **A10** Types & formats de saisie (nombre/texte, booléens, dates localisées FR)
- [ ] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI
- [ ] **A12** Affichage de la valeur calculée en cache (lecture `data_only=True`, mention FR/EN)
- **P2 — étendu (🟢, 2-4 j) — ⚪ à faire**
- [ ] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection
- [ ] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer)
- [ ] **A15** Styles minimaux en écriture + lecture fidèle (gras, fond, formats, fusions, volets figés)
- [ ] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`, `.ods`, `.csv` éditable)
- [ ] **A17** Vue « tableau de bord » (plages nommées, TCD, KPI par feuille, actions IA)
- **Convention de suivi :** chaque sous-tâche démarre par son ID stable (`#153-A<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)
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
@@ -168,6 +218,7 @@
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; A5-A12 4-6 j, A13-A17 2-4 j) | 6-11 jours restants |
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
---
@@ -175,6 +226,7 @@
## Notes
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
- **Clôture #85 (v2.27.13) :** monolithe découpé (T1→T9), stores verrouillés + rate-limit SQLite (T10), fiche `docs/features/archi-refonte-85.md`.
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
+201
View File
@@ -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
View File
@@ -72,14 +72,25 @@ async function api(path, opts) {
}
if (!res.ok) {
var detail = "";
var code = "";
var details = null;
try {
var body = await res.json();
detail = body.detail || "";
// #153 A1 : the service layer exposes a stable code + details so callers
// can branch on the failure (e.g. confirm a lossy .xlsx write) instead of
// matching on the message.
code = body.code || "";
details = body.details || null;
} catch (_) {
/* no json body */
}
showToast(detail || "Erreur API : " + res.status, "error");
throw new Error(detail || "API error: " + res.status);
var apiError = new Error(detail || "API error: " + res.status);
apiError.status = res.status;
apiError.code = code;
apiError.details = details;
throw apiError;
}
return res.json();
}
+1 -1
View File
@@ -1222,7 +1222,7 @@ function renderDiagnostics(container, data) {
["Postings total", data.inverted_index.total_postings.toLocaleString()],
["Documents", data.inverted_index.documents],
["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"],
["Stale", data.inverted_index.is_stale ? "Oui" : "Non"],
["Index prêt", data.inverted_index.is_ready ? "Oui" : "Non"],
],
},
{
+71 -8
View File
@@ -998,9 +998,21 @@ export function renderVideoViewer(area, data) {
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
// Cells are contenteditable; edits are collected per sheet and sent to
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
// saved back as formulas (no client-side recalculation — ceiling accepted).
function renderXlsxViewer(area, data) {
// never recalculated here.
//
// #153 A1/A4 — the read response carries `xlsx_lossy_features` (parts openpyxl
// drops on save): a banner lists them and the first save asks for an explicit
// confirmation before retrying with `force: true`. A value starting with "=" or
// "@" is stored as text unless the user turns the formula toggle on, so a typed
// `=cmd|…` cannot execute when the file is later opened in Excel.
export function renderXlsxViewer(area, data) {
const sheets = data.xlsx_sheets || [];
const lossy = data.xlsx_lossy_features || [];
// Session-scoped state: once the lossy write is confirmed, the rest of the
// session saves without asking again (never persisted — a confirmation is
// per workbook, not a global preference).
let lossyConfirmed = false;
let allowFormula = false;
const tabs = sheets.length > 1
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
`<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) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
).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 = `
<div class="xlsx-viewer">
<div class="xlsx-toolbar">
${tabs}
<span class="xlsx-toolbar-actions">
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
</span>
</div>
${lossWarning}
<div class="xlsx-panels">${panels}</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 () => {
// One PUT per sheet (dirty cells can span tabs before a save).
const jobs = panelEls
@@ -1074,11 +1118,25 @@ function renderXlsxViewer(area, data) {
saveBtn.disabled = true;
try {
for (const job of jobs) {
await api(`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
});
// 409 xlsx_lossy_content → confirm once, then retry with force: true.
// (Also covers a workbook that became lossy while it was open.)
let force = lossyConfirmed;
for (;;) {
try {
await putSheet(job, force);
break;
} catch (err) {
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) throw err;
lossyConfirmed = true;
force = true;
continue;
}
throw err;
}
}
}
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
td.classList.remove("xlsx-dirty");
@@ -1088,7 +1146,12 @@ function renderXlsxViewer(area, data) {
showToast(t("editor.saved"), "success");
} catch (err) {
refreshSaveState();
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
// A refused confirmation is a decision, not a failure: neutral toast.
if (err && err.code === "xlsx_lossy_content") {
showToast(t("xlsx.lossy_cancelled"), "info");
} else {
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
}
});
+13
View File
@@ -1823,6 +1823,19 @@
"viewer.copy": "Copy",
"viewer.copy_error": "Copy error",
"viewer.download": "Download",
"xlsx.lossy_title": "Simplified save",
"xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.",
"xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}",
"xlsx.lossy_cancelled": "Save cancelled",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"xlsx.feature_cached_values": "cached values",
"xlsx.feature_slicers": "slicers and timelines",
"xlsx.feature_form_controls": "form controls",
"xlsx.feature_connections": "connections and queries",
"xlsx.feature_custom_xml": "custom XML",
"xlsx.feature_signature": "digital signature",
"xlsx.feature_rich_comments": "rich comments",
"xlsx.feature_macros": "macros",
"viewer.download_md": "Download as .md",
"viewer.download_file": "Download file",
"viewer.pretty": "Pretty",
+13
View File
@@ -1823,6 +1823,19 @@
"viewer.copy": "Copier",
"viewer.copy_error": "Erreur lors de la copie",
"viewer.download": "Télécharger",
"xlsx.lossy_title": "Enregistrement simplifié",
"xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.",
"xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}",
"xlsx.lossy_cancelled": "Sauvegarde annulée",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"xlsx.feature_cached_values": "valeurs calculées",
"xlsx.feature_slicers": "segments et chronologies",
"xlsx.feature_form_controls": "contrôles de formulaire",
"xlsx.feature_connections": "connexions et requêtes",
"xlsx.feature_custom_xml": "XML personnalisé",
"xlsx.feature_signature": "signature numérique",
"xlsx.feature_rich_comments": "commentaires enrichis",
"xlsx.feature_macros": "macros",
"viewer.download_md": "Télécharger en .md",
"viewer.download_file": "Télécharger le fichier",
"viewer.pretty": "Pretty",
+60
View File
@@ -10987,6 +10987,66 @@ body.desktop-mode .editor-container {
background: rgba(255, 196, 0, 0.18);
}
/* #153 A1/A4 — lossy-save warning + formula toggle */
.xlsx-warning {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
margin-bottom: 8px;
border: 1px solid var(--warning, #e0a800);
border-left-width: 3px;
border-radius: 4px;
background: var(--surface);
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.45;
}
.xlsx-warning-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
margin-top: 1px;
color: var(--warning, #e0a800);
}
.xlsx-warning-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.xlsx-warning-body strong {
color: var(--text-primary);
font-weight: 600;
}
.xlsx-warning-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.xlsx-warning-tag {
padding: 1px 6px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.75rem;
white-space: nowrap;
}
.xlsx-warning-hint {
color: var(--text-secondary);
opacity: 0.85;
}
.xlsx-formula-toggle {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-weight: 600;
}
.xlsx-formula-toggle.active {
background: var(--accent, #4a90d9);
border-color: var(--accent, #4a90d9);
color: #fff;
}
/* ── JSON Viewer ── */
.json-viewer {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v26';
const SW_VERSION = 'v27';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.28.16",
"version": "2.29.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+69 -1
View File
@@ -1,4 +1,9 @@
# Plan: Incremental InvertedIndex for 40k+ files
# Incremental InvertedIndex for 40k+ files — livré
> **Statut : LIVRÉ (BUG-033, v2.3.0).** Ce fichier a servi de plan
> d'exécution ; il est conservé comme **trace de conception**. Le code réel
> a divergé sur plusieurs points (voir [État réel](#état-réel-corrigé-au-2026-09-27))
> — ne pas lire les extraits de code ci-dessous comme du code actuel.
## Problem Summary
@@ -17,6 +22,11 @@ Then hook these into `_add_file_to_structures` and `_remove_file_from_structures
Remove the `is_stale()` / `rebuild()` / cooldown mechanism entirely. The inverted index is always current.
> ⚠️ **Nuance retenue à l'implémentation** : un unique `rebuild()` reste nécessaire au
> démarrage (le hook est inerte tant que l'index n'est pas prêt) et au reindex manuel
> d'une vault. Ce qui disparaît, c'est la *staleness* : plus de compteur de génération,
> plus de cooldown, plus de rebuild paresseux.
## Dependency Architecture
**Current import chain:**
@@ -346,3 +356,61 @@ This hack was only needed to reduce the number of inverted index rebuilds. With
4. **Sorted tokens performance:** `bisect.insort` and `list.pop(idx)` are O(V) worst case for large V. For 40k files, the vocabulary size V is typically 50k-200k tokens. O(V) for a single insertion is ~0.001ms, acceptable. The rebuild() call at startup handles the initial bulk.
5. **tag_norm_map / tag_prefix_index growth:** These grow monotonically (never shrink on incremental remove). With 40k files and thousands of tags, this is a few thousand entries — negligible. A manual "Réindexer" button triggers a full `rebuild()` to clean up.
---
## État réel (corrigé le 2026-09-27)
Le plan ci-dessus a servi de brouillon : **le code livré en est différent sur
quatre points**. Relevé fait sur `backend/search.py`, `backend/indexer.py` et
`backend/main.py`, pas de mémoire.
| Point prévu | État réel |
|---|---|
| Étapes 1-2 : hook + `add_document()` / `remove_document()` | ✅ livré tel que prévu |
| Étapes 4-5 : `rebuild()` initial via `init_inverted_index()` appelé depuis la lifespan | ✅ livré (`backend/main.py:297`, dans l'exécuteur de recherche) |
| Étape 6 : retirer `is_stale()` + `_last_rebuild` / `_rebuild_cooldown` / `_source_generation` | ✅ **déjà fait** avant cette relecture — aucun de ces symboles ne subsiste |
| Étape 7 : retirer le hack de coalescence `_index_generation` dans `_on_vault_change` | ✅ **déjà fait** — `_on_vault_change` n'existe plus |
| `get_inverted_index()` simplifié | ✅ mais **sans le fallback `_needs_rebuild`** prévu par le plan |
### Écarts assumés
1. **`is_stale()` a survécu sous un autre nom.** L'étape 6 est faite, mais la
méthode a été conservée car elle répond à une autre question : *l'index
initial est-il construit ?* Elle ne mesure plus aucune staleness (le compteur
de génération et le cooldown ont disparu) et le nom était trompeur. Elle est
donc renommée `is_ready()` — cohérent avec le `is_ready()` déjà exposé par
`SemanticIndex` (`backend/semantic_search.py`). L'alias `is_stale()` de
`SemanticIndex`, sans aucun appelant, est supprimé.
Impact : le champ de `/api/diagnostics` passe de `is_stale` à `is_ready`
(libellé « Index prêt » côté `frontend/js/config.js`).
2. **Pas de repli `_needs_rebuild`.** Le plan prévoyait qu'un échec
d'incrémentation marque l'index pour reconstruction. L'implémentation
retenue se contente de logger un warning et de continuer à servir l'index.
Choix assumé : un échec d'incrémentation est exceptionnel, et reconstruire
silencieusement serait plus coûteux que l'état dégradé. **Si ce compromis
devient critiquique, c'est le point à rouvrir.**
3. **`_ready` remplace `doc_count == 0`.** Le plan prévoyait de sauter le hook
« index vide » ; le drapeau explicite `_ready` est plus sûr (un vault
réellement vide serait sinon pris pour un index non construit).
4. **`rebuild()` reste nécessaire** au démarrage et au reindex manuel d'une
vault. Le plan parlait de le supprimer de `get_inverted_index()`, ce qui est
fait, mais la méthode elle-même est conservée.
### Bug trouvé pendant cette relecture (corrigé ici)
`remove_vault_from_index()` (`backend/indexer.py`) ne notifiait pas le hook.
Conséquence mesurée : après suppression d'une vault, ses 8 documents test
restaient dans l'index inversé — `postings`, `doc_info`, `doc_vault`,
`vault_docs` — et continuaient de correspondre aux recherches pour une vault
inexistante. Seul un reindex manuel les effaçait.
Le correctif déclenche `_on_index_change('remove', …)` pour chaque fichier de
la vault, et `_remove_doc_internals()` supprime désormais la clé `vault_docs`
quand son set devient vide (c'est un `defaultdict` : une simple lecture la
ré créait). Test de non-régression :
`TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le patch).
Binary file not shown.
+120
View File
@@ -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 });
});
});
+273
View File
@@ -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);
+56
View File
@@ -115,6 +115,25 @@ class TestInvertedIndex:
inv.remove_document("V", "p.md")
assert inv.doc_count == 0 # Skipped
def test_is_ready_tracks_initial_build(self, client):
"""is_ready() is the only freshness signal: no generation counter,
no cooldown, no lazy rebuild (plan.md step 6)."""
inv = InvertedIndex()
assert inv.is_ready() is False
inv.rebuild()
assert inv.is_ready() is True
# The old staleness API is gone for good.
assert not hasattr(inv, "is_stale")
def test_is_ready_survives_incremental_updates(self, client):
"""Incremental add/remove must not flip readiness back (which would
silently push search() onto the O(N) full-scan fallback)."""
self.inv.rebuild()
assert self.inv.is_ready() is True
self.inv.add_document("V", "p.md", {"path": "p.md", "title": "T", "tags": [], "content": "x"})
self.inv.remove_document("V", "p.md")
assert self.inv.is_ready() is True
# ═══════════════════════════════════════════════════════════════════
# Search / Advanced Search integration tests
@@ -192,3 +211,40 @@ class TestSearchFunctions:
def test_suggest_tags_no_match(self, client):
suggestions = suggest_tags("xyznonexistent", vault_filter="all")
assert len(suggestions) == 0
# ═══════════════════════════════════════════════════════════════════
# Vault removal — inverted index must not keep ghost documents
# ═══════════════════════════════════════════════════════════════════
class TestVaultRemovalPurgesInvertedIndex:
"""`remove_vault_from_index()` must notify the inverted-index hook.
Regression: it only cleaned the indexer's own structures, so every document
of the removed vault survived in the inverted index (postings, doc_info,
doc_vault, vault_docs) and kept matching searches for a vault that no
longer exists — a leak that only a manual reindex used to clear.
"""
def test_removing_a_vault_purges_its_documents(self, client):
import asyncio
import backend.indexer as ix
import backend.search as bs
ix.set_index_change_hook(bs._on_index_change_hook)
inv = bs._inverted_index
inv.rebuild()
vault_keys = [k for k in inv.doc_info if k.startswith("TestVault::")]
assert vault_keys, "vault non indexe, test sans valeur"
before = inv.doc_count
asyncio.run(ix.remove_vault_from_index("TestVault"))
ghosts = [k for k in inv.doc_info if k.startswith("TestVault::")]
assert not ghosts, f"documents fantomes dans l'index inverse : {ghosts[:5]}"
assert inv.doc_count == before - len(vault_keys)
assert "TestVault" not in inv.vault_docs
# The index stays usable for the remaining vaults.
assert inv.is_ready() is True
+210 -1
View File
@@ -1,7 +1,13 @@
"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save)."""
"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save).
Covers #152 (affichage / édition) and #153 P0 : A1 alerte de fidélité avant
écriture, A2 écriture atomique, A3 verrou par fichier, A4 neutralisation de
l'injection de formule.
"""
from __future__ import annotations
import zipfile
from pathlib import Path
import pytest
@@ -29,6 +35,43 @@ def xlsx_file(test_vault_dir: str) -> str:
return str(path)
def _add_lossy_parts(path: Path, parts: dict[str, bytes]) -> None:
"""Re-pack *path* with extra OPC parts openpyxl cannot write back."""
with zipfile.ZipFile(path) as zf:
items = {n: zf.read(n) for n in zf.namelist()}
items.update(parts)
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
for name, blob in items.items():
zf.writestr(name, blob)
@pytest.fixture
def lossy_xlsx(test_vault_dir: str) -> str:
"""Workbook with a slicer + a formula carrying its cached result."""
from openpyxl import Workbook
path = Path(test_vault_dir) / "risky.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = 3
ws["A2"] = "=A1*3"
wb.save(path)
# <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 ───────────────────────────────────────────────────────────────
@@ -150,3 +193,169 @@ class TestXlsxSave:
def test_missing_sheet_field_400(self, client, xlsx_file):
resp = self._save(client, {"cells": {"A1": "x"}})
assert resp.status_code == 400
def test_non_boolean_flag_400(self, client, xlsx_file):
for flag in ("force", "allow_formula"):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "x"}, flag: "yes"})
assert resp.status_code == 400, flag
# ── #153 A1 — lossy-write guard ──────────────────────────────────────────
class TestXlsxLossyGuard:
def _save(self, client, body, path):
return client.put(
f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body,
)
def test_read_reports_lossy_features(self, client, lossy_xlsx):
data = client.get(f"/api/file/{VAULT}", params={"path": "risky.xlsx"}).json()
assert data["is_xlsx"] is True
assert "slicers" in data["xlsx_lossy_features"]
assert "cached_values" in data["xlsx_lossy_features"]
def test_read_reports_nothing_for_a_plain_workbook(self, client, xlsx_file):
data = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"}).json()
# B2 holds "=B1*2" but openpyxl wrote no cached <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