22 KiB
#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 : ✅ Backlog terminé et livré le 2026-09-28 — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis A6→A17 en v2.33.0 → v2.39.0 (A11 étant au CI depuis A5/A8) Effort estimé : 8-13 jours au total (P0 ✅ 2-3 j · P1 4-6 j · P2 2-4 j) Règle de maintenance : la Roadmap porte les cases à cocher (suivi), cette fiche porte l'analyse, les risques et les critères d'acceptation. Ne pas dupliquer le détail.
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, rows, cols, total_*, max_*, truncated}] |
| Endpoint fenêtre | backend/routers/files_read.py |
GET /api/file/{vault}/xlsx/sheet?path=&sheet=&offset=&limit= (#153 A9) — une fenêtre de lignes, vraies coordonnées A1 |
| Schéma API | backend/schemas.py:286-290 |
is_xlsx, xlsx_sheets, XlsxSheetWindowResponse |
| É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 (58) + test_xlsx_styles.py (9) + test_xlsx_formats.py (12) + test_xlsx_dashboard.py (8) + test_xlsx_structure.py (11) + test_spreadsheet_tools.py (17) · tests/frontend/xlsx-viewer.test.mjs (35) · tests/e2e/xlsx-viewer.spec.js (9) |
Backend, JSDOM et E2E (chromium-desktop) |
2. Ce qui est supporté aujourd'hui (livré, non concerné par #153 sauf mention)
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)
Note (2026-09-28) : les limites ci-dessous décrivent l'état du jour de l'audit (2026-09-27). La quasi-totalité a été levée depuis par le backlog §5 (styles, navigation clavier, tri/filtre/recherche, structure, formats
.xlsm/.xls/.ods/.csv, indexation, outils IA) — se reporter aux cases cochées et à l'historique §7 ; ne pas relire cette section comme l'état actuel.
3.1 Fidélité du round-trip — risque n°1
load_workbook() → wb.save() : ce qui est réellement perdu a été mesuré sur
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 = 40par 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.xlsxest totalement invisible à la recherche TF-IDF, à la recherche sémantique, au remplacement global, aux tags et aux statistiques de contenu. - Outils IA : seul
create_xlsxexiste (crée un fichier neuf, une seule feuille,overwrite=Truepar défaut) ;read_filefait unread_text()sur l'archive ZIP → bruit binaire envoyé au LLM ; pas deupdate_xlsx_cellspourtant le service existe déjà, pas d'ajout de lignes, pas dexlsx → markdownpour 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 | 🟢 bandeau + dimensions exposées (BUG-090) ; le chargement paresseux par fenêtres sert les lignes au-delà du plafond |
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 renvoiexlsx_lossy_features; la visionneuse affiche un bandeau listant les éléments ;PUT …/xlsx/saverépond 409xlsx_lossy_content(avecdetails.features) tant queforcen'est pas passé, le client demande confirmation puis réémet avecforce: 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)puisos.replace();.tmpsupprimé sur échec ; backup.bakinchangé. Le.tmpest ignoré par le watcher. Vérifié :TestXlsxAtomicWrite(2) — les octets d'origine sont intacts après unsaveen échec. - A3 — Verrou par fichier (R3). Verrou
threading.Lockpar chemin (registre + garde, timeout 15 s) autour du cycle load → edit → replace ; 409conflictsi le délai est dépassé. L'endpoint est passé endef(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-inallow_formula: truecôté API et boutonf(x)dans la visionneuse (état de session, jamais persisté).+/-restent des nombres. Au passage : le handlerServiceErrorexposecode+detailsetapi()les propage sur l'Error. Vérifié :TestXlsxFormulaGuard(4) + test du toggle côté UI.
P1 — Recherche, IA, UX (4-6 j) — 🟢 livré le 2026-09-28 (A5 → A12)
- A5 — Indexation du contenu des feuilles.
extract_indexable_text()(noms de feuilles + 20 premières lignes,MAX_INDEX_CHARS = 5 000, 20 feuilles max) alimente le TF-IDF et la recherche sémantique ; la lecture binaire reste inchangée pour l'affichage. Un classeur chiffré/corrompu s'indexe par son seul nom (jamais d'exception). Au passage : BUG-089, un reindex manuel ne reconstruisait pas l'index inversé. Vérifié :TestXlsxSearchable(4) +TestXlsxIndexing, contre-preuve (neutraliser l'extraction → 3 tests échouent). - A6 — Outils IA sur classeur.
update_xlsx_cells(enveloppe du service existant),append_xlsx_rows,xlsx_to_markdown(contexte LLM, plafonné),list_xlsx_sheets— risque WRITE + confirmation pour les mutations, libellés i18n dansbackend/tools/labels.py, refresh viewer viaobsigate:file-written. Livré (v2.33.0) :backend/tools/spreadsheets.py. Vérifié :tests/test_spreadsheet_tools.py(17). - A7 — Navigation clavier & barre de formule.
Tab/Maj+Tab/Entrée/flèches, cellule active affichée (nom A1),Maj+Entréepour le multiligne, copier une plage, focus visible et compatible mobile (≥ 44 px,tests/e2e/mobile-editor.spec.js). Livré (v2.34.0). - A8 —
theadsticky + indicateur de troncature (R5) — livré 2026-09-28 (BUG-090). Ligne d'en-têtes figlée au défilement vertical (thead th { top: 0 };top: autosur les numéros de ligne, sans quoi ils s'empilent en haut à gauche) ;render_sheets()exposetotal_rows/total_cols(dimensions déclarées),max_rows/max_cols(plafonds) ettruncated— le bandeau « feuille tronquée » annonce le plafond atteint et non la taille élaguée (une feuille creuse rend 1×1 tout en couvrant 500 lignes) ; libellésxlsx.truncated_*FR/EN. Vérifié :TestXlsxTruncationNotice(4),xlsx-viewer.test.mjs(4 nouveaux), E2E surtest_vault/sample-xlsx-large.xlsx(520 lignes). - A9 — Chargement paresseux par feuille (côté API). Endpoint
GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=(XlsxSheetWindowResponse, exemple dansbackend/openapi_docs.py) : une fenêtre de 1 à 1 000 lignes (plafondMAX_WINDOW_ROWS,limit>1000→ 422),has_morepour paginer, valeurs calculées A12 incluses. Les numéros de ligne etdata-cellrestent les coordonnées A1 réelles de la feuille (_table(..., row_offset=offset)) : une fenêtre est indistinguishable d'un rendu complet et une édition dans la fenêtre cible la bonne cellule. Erreurs : 404 feuille inconnue / fichier absent, 415 non-.xlsx. Vérifié :TestXlsxSheetWindow(11), contre-preuve (neutraliser l'offset → 3 tests échouent), E2E « l'endpoint de fenêtre sert les lignes au-delà du plafond ». - A9bis — Chargement à la demande côté UI. Sous une feuille tronquée, un pied de page
« N lignes affichées sur M · Charger la suite » apparaît : cliquer — ou approcher du bas
du tableau (sentinelle de défilement, marge 120 px) — fetch la fenêtre suivante
(
limit=500) et l'insère dans la table. Les lignes ajoutées passent par le même pipeline d'édition que le rendu initial (setupCellfactorisé : contenteditable, dirty, Échap, collage monoligne, info-bulle valeurs calculées) et sont donc sauvegardables immédiatement. Un fetch échoué restore le libellé du pied de page (retry possible) et toast l'erreur ; feuille complète → pied de page masqué (class="done"). Vérifié :xlsx-viewer.test.mjs19/19 (5 nouveaux), contre-preuve (désactiverwireLazyRows→ 5 tests échouent), E2E « le bouton charger la suite ajoute les lignes cachées » sursample-xlsx-large.xlsx(A520 visible et éditable après clic). - A10 — Types et formats de saisie.
_coerce_xlsx_value()reconnait les booléens (true/vrai/oui/yeset leurs négatifs) et les dates FRJJ/MM/AAAA(+HH:MM), jour-first comme Excel en locale française :01/02/2026= 1ᵉʳ février. Une saisie ressemblant à une formule n'est jamais convertie (BUG-088 préservé) ; un code postal numérique ou une version restent ce qu'ils sont. Vérifié :TestXlsxValueCoercion(5), contre-preuve (neutraliser la coercion → 2 tests échouent). - A11 — Tests frontend + E2E.
tests/frontend/xlsx-viewer.test.mjs(dirty, Échap, collage, 1 PUT par feuille, bouton désactivé) ettests/e2e/xlsx-viewer.spec.js(ouverture, onglets, édition, sauvegarde, rechargement) ; intégration au CI. Vérifié : 35 tests JSDOM (le job CIlintlancenode xlsx-viewer.test.mjs) et 9 E2E chromium-desktop ; la couverture a grandi avec chaque sous-tâche (A5/A8 → P2). - A12 — Valeurs calculées. La valeur en cache s'affiche sous la formule dans un
<span class="xlsx-cached">. La 2ᵉ lecturedata_only=Truen'a lieu que si l'archive contient réellement un<f>…</f><v>…</v>(sonde déjà présente pour A1) : le cas courant reste à un seul chargement, et toute erreur retombe sur l'affichage formules seul. L'info-bulle est traduite côté client (xlsx.cached_value_titleFR/EN) — aucun texte d'interface n'est émis par le backend. Vérifié :TestXlsxCachedValues(3), contre-preuve (neutraliser la 2ᵉ lecture → 2 tests échouent).
P2 — Étendu (2-4 j) — 🟢 livré le 2026-09-28
- A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.
Livré (v2.35.0) : tout en manipulation d'affichage, le classeur n'est jamais réécrit
(info-bulle
xlsx.sort_applied). - A14 — CRUD de feuilles et de lignes/colonnes (renommer, insérer, supprimer, dupliquer).
Livré (v2.36.0) :
PUT …/xlsx/structure+ menu Structure, mêmes garde-fous que l'édition de cellules. Vérifié :tests/test_xlsx_structure.py(11). - A15 — Styles minimaux en écriture et lecture fidèle (gras, fond, format
devise/pourcentage/date, cellules fusionnées, volets figés) ; conserver
csv-tablecomme socle de rendu. Livré (v2.37.0) en lecture : couleurs, gras/italique/souligné, alignements, fusions, ancre de volets figés ; un format de nombre personnalisé est signalé en police mono (pas de rendu devise/pourcentage). L'application de styles depuis la visionneuse (écriture) reste hors périmètre. Vérifié :tests/test_xlsx_styles.py(9). - A16 — Formats additionnels.
.xlsm(keep_vba=True),.xls,.ods,.csvéditable comme tableur — dépendances à qualifier (xlrd/odfpy) ou conversion. Livré (v2.38.0) :.xlsméditable macros préservées,.xls/.odslecture seule (xlrd/odfpy),.csvéditable et réécrit RFC 4180. Vérifié :tests/test_xlsx_formats.py(12). - A17 — Vue « tableau de bord ». Détection des plages nommées, TCD et graphiques ; vue
résumée (KPI par feuille) et proposal d'actions IA sur ces plages. Livré (v2.39.0) :
panneau Tableau de bord (
GET …/xlsx/dashboard) — plages nommées avec portée, comptage graphiques/TCD par analyse des parties OPC, stats par feuille, 8 KPI ; le volet IA se limite à un conseil contextuel (pas d'appel IA dédié sur les plages). Vérifié :tests/test_xlsx_dashboard.py(8).
6. Règles de livraison (rappel AGENTS.md / DELIVERY_WORKFLOW.md)
- 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 commeBUG-NNNdansdocs/ISSUES_TODOLIST.mdau moment du démarrage. - Backend : docstrings,
response_modelpour tout endpoint ajouté, exemple dansbackend/openapi_docs.py, chemin utilisateur viaresolve_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.pyvert). - 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 |
| 2026-09-28 | A5 + A10 + A12 livrés : le contenu des cellules est indexé (recherche), la saisie est typée (booléens, dates FR), la valeur calculée s'affiche sous la formule. BUG-089 corrigé au passage (reindex manuel ≠ reconstruction de l'index inversé ; backend/search.py lisait l'index par valeur) |
| 2026-09-28 | A8 + A9 livrés (BUG-090) : la troncature d'une feuille est annoncée (bandeau + dimensions dans la réponse de lecture), les en-têtes restent visibles au défilement, et GET …/xlsx/sheet sert une fenêtre de lignes avec les vraies coordonnées A1 — les lignes au-delà du plafond redeviennent accessibles aux clients API. Défilement virtuel côté UI à suivre |
| 2026-09-28 | A9bis livré : « Charger la suite » + sentinelle de défilement sous une feuille tronquée ; les lignes ajoutées sont éditables et sauvegardables immédiatement (même pipeline que le rendu initial) |
| 2026-09-28 | A6 + A7 livrés (v2.33.0, v2.34.0) : l'assistant IA lit et modifie les classeurs (list_xlsx_sheets, xlsx_to_markdown, update_xlsx_cells, append_xlsx_rows) et la visionneuse gagne navigation clavier complète + barre de formule |
| 2026-09-28 | A13 + A14 livrés (v2.35.0, v2.36.0) : tri, filtre, recherche et export CSV côté affichage ; structure du classeur éditable (feuilles, lignes, colonnes) via PUT …/xlsx/structure |
| 2026-09-28 | A15 + A16 + A17 livrés (v2.37.0 → v2.39.0) : styles/fusions/volets figés rendus, formats .xlsm/.xls/.ods/.csv gérés, panneau Tableau de bord (plages nommées, graphiques/TCD, stats, KPI) — backlog #153 terminé |