Files
ObsiGate/docs/features/xlsx-viewer.md
T
bruno 31d4616baf 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]>
2026-09-27 20:39:12 -04:00

14 KiB
Raw Blame History

#153 — Visionneuse & édition XLSX — état des lieux et backlog

Item de roadmap : #153 — Visionneuse & édition XLSX — complétude Origine : #152 (visionneuse XLSX, livrée en 2.27.0 — voir 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 + 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

  • 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).
  • 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.
  • 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).
  • 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