Compare commits

...
45 Commits
Author SHA1 Message Date
bruno 1e0e419210 feat: panneau de facettes repliable + facette Extensions exposee par l'API #157
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / lint (push) Canceled after 9s
2026-10-01 23:07:16 -04:00
bruno 2813d546a7 feat: facette Extensions dans les facettes de recherche #157
CI / lint (push) Successful in 2m41s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 4m45s
CI / build (push) Successful in 3m4s
CI / e2e (push) Successful in 15m36s
2026-10-01 20:35:21 -04:00
bruno 19e94dfa51 chore: retire fichier test webhook
CI / lint (push) Successful in 2m40s
CI / security (push) Successful in 1m53s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1s
2026-10-01 14:36:39 -04:00
bruno 49f97fc26e test: webhook apres ALLOWED_HOST_LIST
CI / lint (push) Successful in 2m43s
CI / security (push) Successful in 1m53s
CI / test (push) Successful in 4m35s
CI / e2e (push) Canceled after 0s
CI / build (push) Canceled after 22s
2026-10-01 14:30:55 -04:00
bruno 562290d922 fix: plancher urllib3>=2.8.0 pour CVE-2026-97687/97688/97689 + garde-fou CI
CI / lint (push) Successful in 2m39s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m35s
CI / build (push) Successful in 2m43s
CI / e2e (push) Successful in 15m26s
- backend/requirements.txt : plancher urllib3>=2.8.0 (3 CVE corrigées)
- .gitea/workflows/ci.yml : documentation des nouveaux planchers
- tests/test_ci_workflow.py : garde-fou urllib3 ajouté dans TestDependencySecurityFloors

Corrige l'échec du job security (pip-audit bloquant sur urllib3 2.7.0)
2026-10-01 12:20:46 -04:00
bruno 66a5505965 fix: correction raccourci palette + filtrage ext/vault/tag dans palette fichiers
CI / lint (push) Successful in 2m40s
CI / security (push) Failing after 1m53s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 17m29s
- Correction du raccourci clavier inversé dans l'aide :
  Ctrl+Shift+Space → Palette de fichiers (navigation rapide)
  Ctrl+Alt+Space → Palette de commandes
- Ajout du support des filtres ext:xxx, vault:nom, tag:monTag dans la palette de fichiers
- parseQuery étendu pour gérer les opérateurs de filtrage
- Filtrage client-side par extension et tag dans searchFiles et searchBrowseGlobal
- Backend : suggest_titles retourne les tags pour chaque suggestion
- Schema TitleSuggestion : ajout du champ tags
- Placeholders mis à jour pour indiquer les nouveaux filtres disponibles

Fixes : raccourci inversé, filtrage par extension/vault/tag dans palette fichiers
2026-10-01 10:29:42 -04:00
bruno c5c225a68e feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
CI / lint (push) Successful in 2m36s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 2m39s
CI / e2e (push) Successful in 15m45s
L'editeur lisait les styles mais ne les ecrivait pas, exportait la feuille
entiere et laissait le dernier-ecrivain gagner entre processus.

- A8 : bouton Mise en forme (gras/italique/souligne, alignements, couleurs via
  selecteur natif, formats de nombre, fusion, volets figes, largeur/hauteur) et
  nouvelle route PUT .../xlsx/style (verrou, backup, swap atomique, garde de
  perte, If-Match) ; A9 : decision "pas de moteur de formule" annoncee dans
  l'UI ; A10 : undo/redo unifie, piles par fichier conservees au re-rendu
- A11 : export de la selection + Markdown/HTML/impression et recherche sur
  toutes les feuilles ; A12 : concurrence optimiste (If-Match -> 409 reparable,
  retry qui relit) ; A13 : cache des metadonnees par (chemin, mtime, taille)
- A14 : outils IA .xlsm/.csv + search_workbook, analyze_range,
  edit_xlsx_structure
- tests : test_xlsx_styles.py (17), test_spreadsheet_tools.py (34),
  TestOptimisticConcurrency/TestMetaCache, JSDOM xlsx-viewer 107/107
2026-09-30 07:04:47 -04:00
bruno 1bacfd69d9 fix: plancher pyjwt >=2.14.0 - CVE-2026-102274 corrige le job security BUG-095 2026-09-29 17:57:35 -04:00
bruno 383ffa6a65 feat: menu contextuel et selection type Excel dans la grille XLSX #155
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m20s
CI / build (push) Successful in 1m41s
CI / e2e (push) Successful in 15m30s
2026-09-29 16:43:39 -04:00
bruno 69927176df fix: feuille xlsx vide editable avec quadrillage vierge BUG-094
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-09-29 16:34:33 -04:00
bruno 5c2ae26a74 docs: corrige la plage de versions de #154 dans la roadmap
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-09-29 15:26:10 -04:00
bruno 3f52b56251 refactor: extraction des modules xlsx, lien dashboard-grille et inspecteur redimensionnable #154 2026-09-29 15:25:38 -04:00
bruno 72da123a51 feat: undo/redo, chargement IntersectionObserver et ARIA pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 19s
2026-09-29 15:01:00 -04:00
bruno e94af0369b feat: inspecteur droit pour le tableau de bord XLSX #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m46s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1h32m51s
2026-09-29 14:56:08 -04:00
bruno 8da65611cb feat: dialogues themes et conflits non bloquants pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m40s
CI / e2e (push) Canceled after 42s
2026-09-29 14:50:47 -04:00
bruno 605060c51d feat: visionneuse XLSX - ruban groupe, onglets permanents et badges d'etat #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m40s
CI / e2e (push) Successful in 15m10s
2026-09-29 14:30:36 -04:00
bruno 856e654306 fix: plancher pypdf >= 6.16.1 - deux DoS de ressources bloques le job security BUG-093
CI / lint (push) Successful in 2m30s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 3m2s
CI / e2e (push) Successful in 15m9s
pip-audit bloquait sur PYSEC-2026-3910 (outlines) et PYSEC-2026-3911 (XForm),
toutes deux atteignables via backend/pdf_reader.py. Le plancher pypdf>=4.0 ne
protégeait rien : l'image Act du runner embarque 6.16.0 dans sa toolcache
Python, donc pip répondait « already satisfied » sans jamais aligner.

Au passage, le garde-fou TestSemgrepStep était en régression depuis la
désactivation de semgrep (v2.39.9) et aurait rougi le job `test` : il vérifie
désormais que l'étape n'exécute que son avertissement et que bandit et
pip-audit restent bloquants. Nouveau TestDependencySecurityFloors pour
verrouiller les planchers de sécurité (contre-preuve : pypdf remis à >=4.0).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-29 13:06:53 -04:00
bruno 4de9ee038c fix: desactive l'etape semgrep du job security - core natif inexecutable sur ce runner BUG-091
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 1m45s
CI / test (push) Failing after 4m21s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-29 12:54:55 -04:00
bruno 290d62da4e fix: continue-on-error sur l'etape semgrep - le core natif ne doit plus bloquer la CI BUG-091
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 2m16s
CI / test (push) Failing after 4m17s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-29 12:43:21 -04:00
bruno 140e9a679d fix: semgrep ne bloque le job security que si le core s'execute (bandit et pip-audit restent bloquants) BUG-091
CI / lint (push) Successful in 2m32s
CI / security (push) Failing after 2m17s
CI / test (push) Failing after 4m20s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-29 12:19:38 -04:00
bruno 267a33d43b fix: deplace le venv semgrep hors de /tmp (fs noexec du runner) BUG-091
CI / lint (push) Successful in 2m27s
CI / security (push) Failing after 2m15s
CI / test (push) Successful in 4m21s
CI / build (push) Successful in 1m40s
CI / e2e (push) Successful in 15m33s
2026-09-29 10:37:01 -04:00
bruno 435a0687d7 test: isole le garde SSRF dans les tests fetch_url - plus de dependance au DNS reel BUG-092
CI / lint (push) Successful in 2m28s
CI / security (push) Failing after 2m16s
CI / test (push) Successful in 4m16s
CI / build (push) Successful in 1m40s
CI / e2e (push) Successful in 14m53s
2026-09-29 10:05:24 -04:00
bruno dbf935bec0 fix: diagnostic semgrep-core dans le job security - CPU, disque et exec brute traces BUG-091
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 2m16s
CI / test (push) Failing after 4m21s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-29 09:21:25 -04:00
bruno d6d081c0e9 fix: semgrep 1.157.0 dans un venv isole du job security et plancher pyjwt 2.13 BUG-091
CI / lint (push) Successful in 2m27s
CI / security (push) Failing after 2m21s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 2m31s
CI / e2e (push) Successful in 15m1s
2026-09-28 21:54:12 -04:00
bruno c72f852a55 fix: semgrep epingle 1.174.0 dans le job security - CPU runner refuse les builds x86-64-v2 BUG-091
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / test (push) Successful in 4m14s
CI / build (push) Successful in 2m37s
CI / e2e (push) Successful in 14m58s
2026-09-28 19:14:08 -04:00
bruno 99779ecc08 docs: cloture documentaire #153 - changelog A6-A17, fiche, guide utilisateur et README
CI / lint (push) Successful in 2m33s
CI / security (push) Failing after 1m41s
CI / test (push) Failing after 3h11m57s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-28 17:48:43 -04:00
bruno c4b8e66206 feat: tableau de bord classeur - plages nommees, TCD/graphiques et KPI par feuille #153
A17 — nouveau endpoint GET /api/file/{vault}/xlsx/dashboard (read_workbook_
dashboard : plages nommees avec portee depuis defined_names read_only,
comptage graphiques/TCD par parts OPC, stats par feuille bornées 500x40 :
cellules/lignes/colonnes/formules/numerique + 8 premieres valeurs en cartes
KPI) et panneau frontend toggled depuis la toolbar (table des plages,
cartes KPI par feuille, hint actions IA). Bouton absent pour .csv et
formats en lecture seule ; le menu structure est saute quand le bouton
n'existe pas. i18n FR/EN (xlsx.dashboard_*), 8 tests backend + 2 tests
JSDOM + contre-preuve (5 echecs sur neutralisation), ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:40:09 -04:00
bruno ca6407e0c0 feat: formats tableur additionnels - xlsm editable, xls/ods lecture seule, csv editable #153
A16 — la visionneuse tableur accepte quatre formats de plus : .xlsm est
servi et sauvegarde comme un .xlsx avec keep_vba=True (les macros
survivent, la porte lossy est levée pour ce format) ; .xls (xlrd) et .ods
(odfpy) sont rendus en lecture seule (xlsx_readonly, wiring d'édition
désactivé) ; .csv devient éditable via render_csv_table (grille A1
identique au viewer) et PUT /api/file/{vault}/csv/save (réécriture csv
RFC 4180, extension de grille, valeurs stockées telles quelles). 12 tests
backend + contre-preuve (4 échecs sur neutralisation du service CSV),
JSDOM 33/33, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:27:10 -04:00
bruno dff32a97ee feat: styles minimaux, fusions et volets figes dans la visionneuse xlsx #153
A15 — le rendu tableur lit les metadonnees du classeur (une charge en mode
normal par feuille) et les restitue : styles inline data-driven (couleur de
police en premier, fond, gras/italique, format numerique signale en mono),
alignements non-defaut, plages fusionnees (rowSpan/colSpan cote client) et
ancrage des volets figes (sticky rows/cols). Le chargement lazy replie les
metadonnees de chaque fenetre. Contrat API : styles/aligns = {ref: css},
merges = [ranges], freeze = ancre. 9 tests backend + contre-preuve (5 echecs
sur neutralisation), JSDOM 33/33.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:07:58 -04:00
bruno d5c528fead feat: structure des classeurs Excel editable depuis la visionneuse #153
A14 : service mutate_xlsx_structure (batch ordonné d'actions
sheet_add/sheet_rename/sheet_delete/sheet_duplicate et
row/col_insert/delete, une seule réécriture verrouillée et atomique,
409 lossy sans force, backup, dernier sheet protégé) ; endpoint
PUT /api/file/{vault}/xlsx/structure (1-50 actions/requête) ;
menu « structure » dans la visionneuse : ajout/renommage/duplication/
suppression de feuilles et insertion/suppression de ligne/colonne à
partir de la cellule active, confirmations explicites pour les
destructions, re-rendu serveur après succès, reprise force après
confirmation 409.

Vérifié : test_xlsx_structure.py 11 (ops, gardes, atomicité du batch,
409 lossy + force) + contre-preuve (garde dernier sheet neutralisée ->
1 échec), xlsx-viewer.test.mjs 33/33 (3 nouveaux), ruff 0, mypy 0,
i18n parity, validate-imports.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:53:15 -04:00
bruno 38f39a10ae feat: tri, filtre, recherche et export CSV dans la visionneuse XLSX #153
A13 (affichage seul, le classeur n'est jamais réécrit) : clic sur un
en-tête pour trier la colonne (asc/desc, numérique si toutes les valeurs
le sont, localeCompare fr sinon — les cellules modifiées suivent leur
ligne) ; l'unique champ de recherche filtre les lignes ET surligne les
occurrences (marque <mark>, navigation ↑/↓/Entrée, compteur i18n,
insensible à la casse par défaut, bouton Aa) ; bouton « Réinitialiser »
qui re-rend le fichier (vérité serveur) ; export CSV de la feuille
visible (BOM UTF-8, échappement ;/" et retrait des ombres de valeurs
calculées).

Vérifié : xlsx-viewer.test.mjs 30/30 (5 nouveaux) + contre-preuves
(tri neutralisé -> 1 échec, filtre neutralisé -> 1 échec), validate-imports,
i18n parity (xlsx.find_*, xlsx.sort_*, xlsx.csv_export).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:28:09 -04:00
bruno 48e023ba25 feat: navigation clavier et barre de formule dans la visionneuse XLSX #153
A7 : barre de formule sous la barre d'outils (nom de cellule active A1 +
miroir du contenu, édition live, Entrée valide, Échap annule puis
refocalise) ; flèches et Tab déplacent la cellule active dans les 4
directions (Shift inverse Tab), outline persistant sur la cellule active
quand le focus passe à la barre ; Maj+Entrée ne valide plus (réservé au
multiligne A7+). td.tabIndex=0 pour la focalisation clavier.

Vérifié : xlsx-viewer.test.mjs 25/25 (6 nouveaux), E2E 9/9 (barre +
flèches + Échap sur fixture réelle), validate-imports, i18n parity
(xlsx.formula_bar_placeholder, xlsx.active_cell).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:18:24 -04:00
bruno 011ec84f23 feat: outils IA de lecture et d'edition des classeurs existants #153
A6 : list_xlsx_sheets (noms + dimensions + truncated), xlsx_to_markdown
(table bornee 100x20 pour le contexte LLM), update_xlsx_cells et
append_xlsx_rows (WRITE + confirmation, via edit_xlsx_cells : verrou,
ecriture atomique, neutralisation des formules, 409 lossy). Les lignes
ajoutees sont coercees comme dans le viewer (A10). Labels de step
ai.step.xlsx_* FR/EN ; update_xlsx_cells/append_xlsx_rows branches sur
MUTATING_TOOLS et FILE_WRITE_TOOLS (refresh viewer via obsigate:file-written).

Tests : test_spreadsheet_tools.py 17 (risque, confirmation, persistance,
garde formule heritee, coercion) + contre-preuve (cells vide -> 2 echecs).
ruff 0, mypy 0, i18n parity, validate-imports.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:05:16 -04:00
bruno 472ea9d309 feat: chargement à la demande des lignes cachées des tableurs #153
CI / lint (push) Successful in 2m24s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 4m0s
CI / build (push) Successful in 1m37s
CI / e2e (push) Successful in 15m34s
A9bis : sous une feuille tronquée, un pied de page « N lignes affichées
sur M · Charger la suite » fetch la fenêtre suivante (limit=500) au clic
ou à l'approche du bas du tableau (sentinelle de défilement, marge 120px).
Les lignes ajoutées passent par le même pipeline d'édition que le rendu
initial (setupCell factorisé) : éditables et sauvegardables immédiatement.
Fetch échoué → bouton restauré (retry) + toast ; feuille complète → pied
de page masqué (class done).

Vérifié : xlsx-viewer.test.mjs 19/19 (5 nouveaux, contre-preuve
wireLazyRows désactivé → 5 échecs), E2E 8/8 (A520 visible et éditable
après clic), validate-imports 40 modules, unit.test.mjs 12/12, i18n parity.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 11:52:42 -04:00
bruno 6ba04c4381 feat: troncature annoncee et lecture par fenetres des tableurs #153
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 4m1s
CI / build (push) Successful in 1m37s
CI / e2e (push) Successful in 14m47s
- BUG-090 (#153 A8) : render_sheets() expose total_rows/total_cols,
  max_rows/max_cols et truncated ; la visionneuse affiche un bandeau
  « Feuille tronquée » (i18n FR/EN) au lieu de couper en silence, et la
  ligne d'en-têtes devient sticky (top:auto sur les numéros de ligne).
- #153 A9 : GET /api/file/{vault}/xlsx/sheet?sheet&offset&limit sert une
  fenêtre de 1 à 1000 lignes avec les vraies coordonnées A1, has_more de
  pagination et valeurs calculées A12 ; 404 feuille inconnue, 415 non-xlsx.
- Tests : TestXlsxTruncationNotice (4) + TestXlsxSheetWindow (11) avec
  contre-preuves, xlsx-viewer.test.mjs 14/14, E2E 7/7 (fixture
  sample-xlsx-large.xlsx 520 lignes), suite 1417 passed / 6 skipped,
  ruff/mypy 0, i18n parity.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 10:38:24 -04:00
bruno 06f8e63d06 feat: tableurs indexables, saisie typée et valeurs calculées #153
Les tableurs étaient invisibles à la recherche, chaque saisie devenait
du texte, et une formule n'affichait que sa formule.

- A5 : extract_indexable_text() indexe les noms de feuilles et les 20
  premières lignes (plafond 5 k caractères) pour le TF-IDF et la
  recherche sémantique. Un mot tapé dans une cellule rend le fichier
  trouvable ; un classeur chiffré s'indexe par son seul nom.
- A10 : la saisie est typée comme dans Excel — booléens
  (TRUE/FAUX/OUI/NON) et dates FR JJ/MM/AAAA en ordre jour-first, donc
  01/02/2026 est le 1er février. Une saisie ressemblant à une formule
  n'est jamais convertie (BUG-088 préservé).
- A12 : la valeur calculée par Excel s'affiche sous la formule, via une
  2e lecture data_only=True faite seulement si l'archive contient un
  <v>. Info-bulle traduite FR/EN, aucun texte d'interface côté backend.
- BUG-089 : un reindex manuel ne reconstruisait pas l'index inversé, et
  backend/search.py lisait l'index via un import par valeur — après un
  rechargement du module, la recherche écrivait dans un dict périmé.

Contre-preuves vérifiées pour A5, A10 et A12 (neutralisation de chaque
fonction → échec des tests concernés). Tests : 1402 pytest, 10 JSDOM,
4 E2E, suite E2E complète verte, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-27 22:43:24 -04:00
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
bruno 4c4b1222d5 chore: ignore les diagrammes E2E générés (excalidraw)
CI / lint (push) Successful in 2m23s
CI / security (push) Failing after 1m48s
CI / test (push) Successful in 3m49s
CI / build (push) Successful in 2m28s
CI / e2e (push) Successful in 14m5s
2026-09-27 14:46:09 -04:00
bruno a3973b981c securite: #87 T6-T8 fin dette — deps qualifiées, semgrep, Secure auto, CORS 2026-09-27 12:43:31 -04:00
bruno b6e2029770 fix: E2E node-direct, timeouts réalistes 25/30 min (complément BUG-080)
CI / lint (push) Successful in 2m22s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 1m33s
CI / e2e (push) Successful in 14m3s
2026-09-27 11:28:35 -04:00
bruno 6b878caff3 securite: #87 T5c script-src sans unsafe-inline (nonces T5b)
CI / lint (push) Successful in 2m18s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m32s
CI / build (push) Successful in 1m34s
CI / e2e (push) Successful in 14m22s
2026-09-27 10:36:00 -04:00
bruno e9b7a317c1 fix: mfa/status 200 auth désactivée (garde anonymous) BUG-081 + clôture BUG-080/082/083 2026-09-27 10:35:33 -04:00
bruno 14b8032635 fix: CI lint — config-ai-keys.test.mjs rejoint l'étape JSDOM (complément BUG-082)
CI / security (push) Successful in 1m37s
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 4m8s
CI / build (push) Successful in 1m38s
CI / e2e (push) Successful in 14m28s
2026-09-27 09:44:18 -04:00
bruno 7dfe26c83d fix: CI security — echo pip-audit sans dièse (runner Act) BUG-083
CI / lint (push) Failing after 1m50s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m37s
2026-09-27 09:37:34 -04:00
bruno 24229316c7 fix: CI lint — upload.test.mjs rejoint l'étape JSDOM (jsdom) BUG-082
CI / lint (push) Failing after 1m30s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1m33s
2026-09-27 09:23:51 -04:00
85 changed files with 14507 additions and 299 deletions
+6 -5
View File
@@ -12,11 +12,12 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local.
# OBSIGATE_ALLOW_INSECURE=false
# Sécurité des cookies (activer si derrière HTTPS)
# false par défaut : les navigateurs ignorent les cookies `Secure` en HTTP,
# ce qui casserait les logins en local. En production (TLS + bind réseau),
# posez true — un avertissement est loggé au démarrage sinon (#87).
# OBSIGATE_SECURE_COOKIES=false
# Sécurité des cookies : true|false|auto (défaut : auto — Secure si la
# requête arrive en https, sinon pas de flag ; les navigateurs ignorent les
# cookies `Secure` en HTTP, ce qui casserait les logins en local).
# Derrière un reverse proxy qui termine TLS, auto suffit avec
# OBSIGATE_TRUST_PROXY=true (X-Forwarded-Proto honoré).
# OBSIGATE_SECURE_COOKIES=auto
# Tokens TTL en secondes
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
+52 -8
View File
@@ -44,13 +44,11 @@ jobs:
node tests/frontend/config-mobile.test.mjs
node tests/frontend/settings-order-avatar.test.mjs
node tests/frontend/mobile-toolbar.test.mjs
node tests/frontend/upload.test.mjs
node tests/frontend/pretty.test.mjs
node tests/frontend/media-viewer.test.mjs
node tests/frontend/mfa-settings.test.mjs
node tests/frontend/config-ai-keys.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
- 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
@@ -60,6 +58,7 @@ jobs:
node ai.test.mjs
node ai-sidebar.test.mjs
node sidebar-filters.test.mjs
node search-facets.test.mjs
node sw.test.mjs
node collab.test.mjs
node mobile-editor.test.mjs
@@ -68,6 +67,9 @@ jobs:
node toolbar-order.test.mjs
node editor-inline.test.mjs
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
@@ -77,6 +79,7 @@ jobs:
node ai.test.mjs
node ai-sidebar.test.mjs
node sidebar-filters.test.mjs
node search-facets.test.mjs
node sw.test.mjs
node collab.test.mjs
node mobile-editor.test.mjs
@@ -85,6 +88,9 @@ jobs:
node toolbar-order.test.mjs
node editor-inline.test.mjs
node ai-quick-actions.test.mjs
node upload.test.mjs
node config-ai-keys.test.mjs
node xlsx-viewer.test.mjs
fi
# ── Tests ─────────────────────────────────────────────────────────
@@ -127,7 +133,12 @@ jobs:
python-version: "3.11"
- name: Install dependencies
# setuptools / pip sont mis à jour : l'image de base peut embarquer
# une version couverte par un advisory fraîchement publié
# (PYSEC-2026-3447 / PYSEC-2026-3721).
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
run: |
pip install -U pip setuptools
pip install bandit pip-audit
pip install -r backend/requirements.txt
@@ -137,11 +148,44 @@ jobs:
# vrais positifs restants portent un `# nosec` justifié inline.
run: bandit -r backend/ --skip B101,B105,B110,B310
- name: Pip-audit (consultatif — #87)
# Reste non bloquant tant que les montées de version requises
# (starlette via fastapi, weasyprint) ne sont pas qualifiées :
# upgrade FastAPI = chantier de régression dédié, hors périmètre.
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking, see #87)"
- name: Semgrep (SAST local) — DÉSACTIVÉ (BUG-091)
# Les règles locales (semgrep-rules/, 8 règles) ne sont plus exécutées
# en CI : semgrep-core est un exécutable natif que le runner actuel ne
# peut pas lancer (exit 127, sans message exploitable) — les releases
# récentes exigent un CPU x86-64-v2, et la dernière version compatible
# (1.157.0, core statique vérifié en baseline v1) échoue aussi. Les
# règles restent applicables en local : `semgrep --config semgrep-rules/
# backend/`. À réactiver dès que le runner dispose d'un CPU x86-64-v2
# (ou d'une image de runner plus récente). Bandit et pip-audit, eux,
# restent bloquants dans ce job.
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
continue-on-error: true
run: |
echo "::warning::SAST semgrep non exécutée (runner incompatible — BUG-091). Bandit et pip-audit restent bloquants."
- name: Pip-audit (bloquant — #87)
# Bloquant depuis T6 (#87) : dépendances qualifiées (mistune 3.3.3,
# python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1
# + starlette 1.7.0, setuptools 84 — suite complète verte + 0 vuln).
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
# s'exécutent jamais. Les advisories pyjwt (PYSEC-2026-178 puis
# CVE-2026-102274) sont corrigées par le plancher pyjwt>=2.14.0 de
# backend/requirements.txt (BUG-091, BUG-095).
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
# l'extraction de texte et la lecture d'outlines — donc atteignables
# via backend/pdf_reader.py) sont corrigés par le plancher
# pypdf>=6.16.1 (BUG-093).
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
# corrigés par le plancher urllib3>=2.8.0.
# Ces planchers doivent rester *au-dessus* des versions préinstallées
# dans la toolcache de l'image du runner : en dessous, pip répond
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
# TestDependencySecurityFloors verrouille ces planchers.
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
run: pip-audit --ignore-vuln PYSEC-2026-1325
# ── Docker build ──────────────────────────────────────────────────
build:
+14
View File
@@ -31,6 +31,20 @@ desktop/backend/
desktop/frontend/
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
+803 -7
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.8**.
> [Unreleased](#unreleased). La dernière version livrée est **2.47.0**.
---
@@ -14,19 +14,815 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.47.0] — 2026-10-01
### Ajouté
- **#157 — panneau de facettes repliable.** Le bloc **Vaults · Tags · Extensions** des résultats
de recherche se masque/restaure d'un clic sur un bouton discret (chevron), état mémorisé
dans `localStorage` et conservé d'une recherche à l'autre.
### Corrigé
- **#157 — facette « Extensions » absente de l'API.** `SearchFacets` (`backend/schemas.py`)
ne déclarait que `tags` et `vaults` : la clé `extensions` ajoutée par `backend/search.py`
était supprimée par le `response_model` avant d'atteindre le navigateur. Champ ajouté,
plus les tests d'advanced-search pointaient la route inexistante `/api/advanced-search`
et sautaient en silence — ils ciblent désormais `/api/search/advanced`, avec une assertion
stricte sur `facets.extensions`.
---
## [2.46.0] — 2026-10-01
### Ajouté
- **#157 — facette « Extensions » dans les facettes des résultats de recherche.** Le panneau de
facettes de la page de résultats (Vaults · Tags) gagne un troisième groupe qui liste les
extensions de fichiers présents dans les résultats (`.md`, `.xlsx`, `.pdf`, …) avec leurs
compteurs ; cliquer une extension ajoute l'opérateur `ext:` à la requête (en remplaçant un
`ext:` déjà actif). Le backend calcule `facets.extensions` dans `backend/search.py`.
---
## [2.45.2] — 2026-10-01
---
## [2.45.1] — 2026-10-01
---
## [2.45.0] — 2026-09-30
### Ajouté
- **#156-A5 — presse-papiers de plage dans l'éditeur Excel.** Copier, couper et coller un
**bloc** de cellules (TSV), au clavier (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`) comme par le menu
contextuel ; le collage remplit la plage à partir de la cellule active et la laisse
sélectionnée. La grille tient son propre presse-papiers (le presse-papiers système est
synchronisé quand le navigateur l'autorise), le collage est toujours du **texte** — jamais de
HTML — et il est **annulable**. Un bloc plus large que la grille affichée est tronqué et signalé.
- **#156-A6 — raccourcis clavier complets.** `Ctrl+S` (enregistrer), `Ctrl+A` (sélectionner la
feuille rendue), `Suppr` (vider la sélection), `F2` (édition), `Ctrl+Home`/`Ctrl+End` (coins de
la feuille), `Home`/`End` (bords de la ligne), `PgUp`/`PgDn` (un écran), `Ctrl+flèches` (bout de
la plage de données) et `Maj+Entrée` (saut de ligne dans la cellule). Une cellule reste en mode
« navigation » jusqu'à la saisie (ou `F2`) : `Suppr` et `Home`/`End` ne volent donc jamais une
touche du curseur de texte.
- **#156-A7 — zone Nom éditable et aide à la saisie.** La zone Nom devient un champ
(« Atteindre ») : saisir `B12`, `A1:B3` ou `Feuille!A1` puis `Entrée` sélectionne la cellule ou
la plage (avec changement d'onglet si la feuille est nommée) ; une référence inconnue est
refusée avec un message et l'adresse précédente restaurée. La barre de formule propose les noms
de fonctions courants (liste localisée FR/EN).
- **#156-A8 — mise en forme en écriture.** Un bouton **Mise en forme** ouvre un menu qui
agit sur la **sélection courante** (une cellule ou une plage) : gras, italique, souligné,
effacement de la mise en forme, alignement gauche/centré/droite, couleur de police et de fond,
format de nombre (général, nombre, pourcentage, devise, date, texte), fusion/défusion, volets
figés/libérés et largeur de colonne / hauteur de ligne. L'écriture passe par une nouvelle route
`PUT /api/file/{vault}/xlsx/style`, sur le même modèle que `xlsx/save` et `xlsx/structure` :
verrou par fichier, écriture atomique (`.tmp` + `os.replace`), backup automatique, garde de perte
(une opération qui effacerait des éléments non préservables est refusée sans `force`) et
**concurrence optimiste** (`If-Match`, cf. A12). Les mises en forme ne sont pas proposées sur un
`.csv` (le format n'en porte pas) ni sur une grille en lecture seule. Les couleurs viennent du
sélecteur natif, jamais d'une palette codée en dur. Tests : `tests/test_xlsx_styles.py` (17) et
JSDOM (application d'un format, refus d'un CSV, garde de perte).
- **#156-A9 — le calcul est explicitement hors périmètre, et annoncé.** L'éditeur n'embarque
aucun moteur de formule : ObsiGate affiche et écrit les formules telles qu'Excel les a
enregistrées, les cellules dépendantes ne se recalculent pas à l'écran. Le choix est désormais
**dit à l'utilisateur** : un enregistrement contenant des valeurs commençant par `=` ou `@`
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé (la garde
anti-DDE de BUG-088 reste en place). La pastille « Formules non recalculées » et la fiche #156
portent la décision. Tests JSDOM : l'avertissement apparaît, et disparaît une fois `f(x)`
activé.
- **#156-A10 — historique annuler/rétablir unifié, conservé au re-rendu.** La pile ne couvrait
que les éditions de cellules : « Effacer le contenu », le tri, le filtre et les actions de
**structure** y entrent désormais, avec des entrées typées (`cell`, `order`, `hidden`,
`structure`). Les piles sont **conservées par fichier** (8 fichiers, `_xlsxHistory`) au lieu
d'être vidées par un re-rendu ; `Suppr` passe par le même chemin (donc annulable) ; le filtre
est **coalescé** en une seule entrée par session de frappe ; un rejeu de structure appelle
l'endpoint en mode silencieux (pas de doublon d'historique ni de bandeau parasite) et une
action destructrice (suppression de ligne/colonne/feuille) n'est **pas** annulable, faute
d'inverse. Tests JSDOM : édition → effacement → tri annulés successivement, insertion de ligne
rejouée par l'endpoint, action destructive non annulable, filtre annulé par un seul `Ctrl+Z`.
- **#156-A11 — sortie (CSV, Markdown, HTML, impression) et recherche sur tout le classeur.**
L'export ne se limite plus à la feuille entière : le bouton **CSV** exporte la **sélection**
quand une plage de plusieurs cellules est active (nom de fichier `Feuille-A1B2.csv`), sinon la
feuille ; un menu **Exporter** ajoute le **Markdown** (tableau), le **HTML** (document
autonome) et l'**impression** (la feuille seule, sans le ruban, via un cadre hors écran). Les
quatre formats suivent le **contenu affiché** (la valeur calculée en cache reste une
infobulle). L'écart doc/code de #153-A13 (« export de la sélection ») est tranché : la
sélection est le défaut quand elle existe. La recherche (`Ctrl+F`) parcourt désormais **toutes
les feuilles** : compteur « n/m · k feuilles » et activation automatique de l'onglet de la
correspondance atteinte.
- **#156-A14 — l'assistant IA couvre les trois formats édités et gagne trois outils.** Les
outils existants acceptent `.xlsm` (écriture avec macros préservées) et `.csv` (lecture et
écriture, délimiteur conservé) au lieu du seul `.xlsx` ; `update_xlsx_cells` écrit un `.csv`
via `save_csv_cells`. Nouveaux outils : `search_workbook` (recherche dans toutes les feuilles,
comptée par feuille), `analyze_range` (nombre, somme, moyenne, min, max d'une plage A1, avec
échantillon borné) et `edit_xlsx_structure` (ajouter/renommer/dupliquer/supprimer une feuille,
insérer/supprimer lignes et colonnes) — les deux premiers en risque READ, le dernier conservant
la confirmation obligatoire des outils mutants. Tests `tests/test_spreadsheet_tools.py` (34).
### Modifié
- **#156 — Éditeur Excel : audit de complétude, roadmap et 4 défauts enregistrés (documentation
seule).** Nouvelle fiche `docs/features/xlsx-editor-completeness.md` (audit par couche, manques
fonctionnels, risques, critères d'acceptation) et item **#156** dans `docs/ROADMAP.md`
(P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3
sortie/robustesse/perf). **BUG-096 → BUG-099** ouverts dans `docs/ISSUES_TODOLIST.md` :
enregistrer un `.csv` depuis la visionneuse lève un `TypeError` (reproduit en JSDOM, aucun
`PUT …/csv/save` émis) ; « Charger la suite » d'une feuille `.xlsm` tronquée répond **415** ;
le délimiteur CSV est figé sur `,` alors que l'export écrit `;` ; la sonde de perte plafonnée à
8 Mo peut laisser passer une écriture qui détruit les valeurs calculées en cache. L'audit
lui-même ne changeait aucun code ; les correctifs correspondants suivent ci-dessous.
- **#156-A12 — concurrence optimiste entre processus (`If-Match`, 409 réparable).** Chaque
lecture renvoie la **version** du fichier (`xlsx_revision` : `mtime_ns` + taille) et les trois
routes d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête `If-Match` ou
le champ `if_match` : si le fichier a changé depuis, l'écriture est refusée en **409**
`conflict` (`details.reason = "stale_revision"`, versions attendue et courante) au lieu
d'écraser l'autre écrivain. Le bandeau « Réessayer » **relit** le fichier puis rejoue la
sauvegarde avec la version courante ; les réponses de succès renvoient la nouvelle version, que
le client enchaîne sur les écritures suivantes. Tests : `TestOptimisticConcurrency` (6) et
JSDOM (version lue, version rafraîchie par la réponse, relecture avant le retry).
- **#156-A13 — cache des métadonnées par `mtime`.** `read_workbook_meta()` rechargeait le
classeur entier à chaque fenêtre servie ; les métadonnées (styles, fusions, volets, largeurs)
sont désormais mémoïsées par `(chemin, mtime_ns, taille)` (LRU de 8) et invalidées à chaque
écriture (cellules, structure, CSV). Charger une fenêtre ne rescanne plus le classeur. L'axe
des colonnes au-delà de `MAX_COLS` reste hors périmètre (voir la fiche #156). Tests :
`TestMetaCache` (4).
### Corrigé
- **BUG-096 — enregistrer un `.csv` depuis la visionneuse échoue (`TypeError`, aucun
`PUT …/csv/save` émis).** Le job de sauvegarde lisait le nom de la feuille dans
`xlsx_sheets` — absent du payload d'un CSV — donc `sheets[0].name` levait un `TypeError`
**avant** toute requête : aucune modification de CSV ne pouvait être enregistrée. Le nom est
désormais résolu avec repli (feuille → nom du fichier) ; l'endpoint `csv/save` est inchangé.
Tests : JSDOM (un CSV monté **sans** `xlsx_sheets` émet un unique `PUT …/csv/save`).
- **BUG-097 — « Charger la suite » d'une feuille `.xlsm` renvoyait **415**.** Un `.xlsm`
s'édite dans la même visionneuse que le `.xlsx`, mais `GET …/xlsx/sheet` refusait tout autre
format : les lignes au-delà du plafond de 500 étaient inaccessibles. L'endpoint accepte
désormais `.xlsx` **et** `.xlsm` (les autres formats restent en **415**). Tests :
`test_xlsx_formats.py` (fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`).
- **BUG-098 — un CSV français (`;`) s'affichait en une seule colonne.** Le délimiteur était
figé à la virgule, en lecture comme en réécriture. `sniff_csv_delimiter()` détecte `;`, `,`
ou tabulation (repli : décompte sur la première ligne, puis `,`) et `save_csv_cells()`
**réutilise le délimiteur détecté** — un CSV point-virgule s'affiche en colonnes distinctes et
le reste après édition. Tests : `test_xlsx_formats.py` (lecture, écriture, champs entre
guillemets contenant le séparateur).
- **BUG-099 — perte silencieuse possible des valeurs calculées en cache (sonde plafonnée).**
La détection des `<f>…</f><v>…</v>` disposait d'un budget **unique** de 8 Mo partagé entre les
feuilles : au-delà, un résultat négatif était pris pour une preuve et l'écriture détruisait
les valeurs mises en cache sans avertissement. Le budget est désormais **par feuille** (4 Mo)
avec un plafond global (32 Mo), et un budget épuisé est **signalé** (`cached_values_unverified`,
libellé FR/EN) : la garde de sauvegarde demande une confirmation au lieu de laisser passer.
Tests : `TestXlsxCachedValueProbe` (budget épuisé → signalé, feuille volumineuse → la suivante
reste scannée, scan complet propre → aucun faux positif).
---
## [2.44.1] — 2026-09-29
### Sécurité
- **BUG-095 — le job CI `security` repasse au vert : `pyjwt` monté au-dessus du dernier
correctif.** `pip-audit` bloquait sur **CVE-2026-102274** dans `pyjwt` 2.13.0 (correctif
**2.14.0**) : le plancher introduit par BUG-091 (`>=2.13.0`, qui corrigeait PYSEC-2026-178)
était devenu insuffisant. Le plancher passe à `pyjwt[crypto]>=2.14.0` — volontairement au-dessus
de la version préinstallée dans la toolcache du runner (sinon `pip` répond « already satisfied »
et n'aligne jamais la version). Le garde-fou
`tests/test_ci_workflow.py::TestDependencySecurityFloors` verrouille ce nouveau plancher
(contre-preuve : plancher remis à `2.13.0` → test rouge).
---
## [2.44.0] — 2026-09-29
### Ajouté
- **#155 — Ergonomie du tableur : menu contextuel (clic droit / appui long) et sélection type
Excel.** Un **menu contextuel** thémé (nouveau module `frontend/js/xlsx/context-menu.js`)
s'ouvre au **clic droit** sur une cellule, un numéro de ligne ou un en-tête de colonne, et par
**appui long** sur mobile : insérer une ligne au-dessus/en dessous, une colonne à gauche/à
droite (avec `count` pour une plage), supprimer la/les ligne(s) ou colonne(s) après confirmation,
trier A→Z / Z→A et effacer le contenu. La grille adopte la **sélection d'Excel** : plage par
**glisser**, extension par **`Maj+clic`** et **`Maj+flèches`**, sélection de la ligne/colonne
entière au clic sur son en-tête, **curseur croix** sur les cellules et zone **Nom** affichant la
plage (`A1:B3`). Le tri par simple clic sur l'en-tête laisse place à la sélection (tri déplacé
dans le menu contextuel). `SW_VERSION` passe à `v29`. Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (60, dont 8 nouveaux) et E2E (9) verts.
---
## [2.43.2] — 2026-09-29
### Corrigé
- **BUG-094 — une feuille `.xlsx` vide ou nouvellement ajoutée est désormais éditable et
manipulable.** Elle s'affichait comme un simple « Feuille vide » sans aucune cellule : il était
donc impossible d'y saisir une valeur ou d'y insérer une ligne/colonne (aucune cellule active →
aucune action de structure). `render_sheets()` substitue maintenant un quadrillage vierge
**20×8** aux vraies coordonnées A1 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`), et la
visionneuse retombe sur `{row:1,col:1}` quand aucune cellule n'est active, de sorte que le menu
Structure propose toujours insérer/supprimer ligne et colonne. Test de non-régression :
`TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid`.
---
## [2.43.1] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 5, clôture) : extraction modulaire et
finitions.**
Les unités **sans état** de la visionneuse sont extraites dans `frontend/js/xlsx/` :
`refs.js` (références A1), `command-bar.js` (onglets + ruban + pastilles d'état) et
`dashboard.js` (rendu du tableau de bord) — le noyau avec état reste dans `viewer.js` à
comportement constant. Le tableau de bord devient interactif : **cliquer une plage nommée
sélectionne et révèle sa première cellule** dans la grille (changement d'onglet si nécessaire).
L'inspecteur est **redimensionnable** (largeur mémorisée par session). `SW_VERSION` passe à
`v28` et les nouveaux modules entrent dans le pré-cache. Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (51) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
Aucun changement backend.
---
## [2.43.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 4) : undo/redo, défilement et accessibilité.**
Les éditions de cellules peuvent être **annulées / rétablies** (boutons dans le ruban,
raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`). Les fenêtres de lignes d'une feuille tronquée se
chargent via **`IntersectionObserver`** (repli sur l'écouteur de défilement quand l'API est
absente). La grille expose désormais une sémantique **ARIA** (`role="grid"` / `row` /
`gridcell` / `columnheader` / `rowheader`). Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
(47) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts. Aucun changement backend.
---
## [2.42.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 3) : inspecteur droit.**
Le **Tableau de bord** quitte le flux de la grille pour un **panneau latéral droit repliable**
(`.xlsx-inspector`) : la grille reste visible à côté. L'en-tête de l'inspecteur porte le titre,
une entrée **Assistant IA** (ouvre le panneau latéral global existant) et un bouton de fermeture.
Sous 900 px, l'inspecteur se place sous la grille. Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (44) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
Aucun changement backend.
---
## [2.41.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 2) : dialogues thémés et conflits non
bloquants.**
Les `confirm()` / `prompt()` natifs sont remplacés par des **dialogues intégrés au thème**
(`showConfirm()` / `showPrompt()` dans `frontend/js/ui.js`, promise-based, réutilisant
`.obsigate-modal-*`) pour toutes les actions de structure du classeur (ajouter / renommer /
dupliquer / supprimer une feuille, insérer / supprimer une ligne ou une colonne) et la
confirmation de perte (`409 xlsx_lossy_content`). Un **conflit d'écriture** (`409 conflict`)
n'interrompt plus l'utilisateur : un **bandeau non bloquant** propose de réessayer en
conservant les modifications. Le bouton **Enregistrer** et l'onglet de la feuille concernée
signalent les modifications non sauvegardées. Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
(42) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) adaptés. Aucun changement backend.
---
## [2.40.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 1) : ruban de commandes groupé,
onglets de feuilles permanents avec bouton « + », badges d'état.**
La vue tableur gagne une barre de commandes segmentée (Formules · Insertion · Vue ·
Fichier) avec un bouton **Enregistrer** primaire. La barre d'onglets est désormais
toujours affichée (même à une seule feuille) et un bouton « + » y ajoute une feuille
(même pipeline `PUT …/xlsx/structure`, re-rendu depuis le serveur). Deux pastilles
d'état annoncent les limites de la vue : **lecture seule** (`.xls`/`.ods` — plus de
« + », ni de structure, ni de tableau de bord, ni d'édition) et **formules non
recalculées**. Des tokens de grille dédiés (`--grid-bg`, `--grid-header-bg`,
`--grid-header-text`, `--grid-border`, `--grid-zebra`, déclinés dark/light) rendent les
en-têtes clairement distincts des cellules, avec zébrage, survol et cellule active
renforcée. Audit UX, architecture cible et plan par lots :
[docs/features/xlsx-ui-redesign.md](docs/features/xlsx-ui-redesign.md). Tests JSDOM
`tests/frontend/xlsx-viewer.test.mjs` (41, dont 6 nouveaux). Aucun changement backend.
---
## [2.39.10] — 2026-09-29
### Sécurité
- **BUG-093 — deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910,
PYSEC-2026-3911) corrigés par le plancher `pypdf>=6.16.1`.**
Un PDF peut provoquer un temps de calcul et une consommation mémoire
arbitraires, soit via de nombreux contours (*outlines*), soit via une page
portant beaucoup d'objets XForm réutilisés. ObsiGate est **directement
exposé** : `backend/pdf_reader.py` extrait le texte et parcourt les contours
de PDF fournis par l'utilisateur. Le plancher `pypdf>=4.0` ne protégeait
rien en pratique — l'image du runner Act embarque 6.16.0 *préinstallé* dans
sa toolcache Python, donc `pip` répondait « already satisfied » et
n'alignait jamais la version. Tout plancher de sécurité doit désormais rester
au-dessus de la version préinstallée.
### Correction
- **Le job CI `security` n'est plus rouge : la désactivation de semgrep
fonctionne, et `pip-audit` est désormais réparé pour de bon.**
Le garde-fou `tests/test_ci_workflow.py::TestSemgrepStep`, en régression
depuis la désactivation (il exigeait encore l'exécution de semgrep),
vérifie maintenant que l'étape n'exécute que son `::warning::` et que
**bandit et pip-audit restent bloquants**. Nouveau garde-fou
`TestDependencySecurityFloors` : les planchers de sécurité (`pypdf`,
`pyjwt`) ne peuvent plus retomber sous leur correctif — contre-preuve
vérifiée (plancher remis à `>=4.0` → test rouge).
---
## [2.39.9] — 2026-09-29
### Correction
- **BUG-091 — l'étape Semgrep est désactivée dans le job CI `security`.**
Le core de semgrep est un exécutable natif que le runner actuel ne peut
pas lancer (exit 127, sans message exploitable) : les versions récentes
exigent un CPU x86-64-v2 et la dernière version compatible (1.157.0, core
statique vérifié en baseline v1) échoue également. Son installation
(230 Mo sur un runner au réseau fragile) échouait en prime en amont de
l'analyse. **Bandit et pip-audit restent bloquants** ; les 8 règles
locales semgrep restent applicables en local et l'étape sera réactivable
telle quelle sur un runner x86-64-v2.
---
## [2.39.8] — 2026-09-29
---
## [2.39.7] — 2026-09-29
### Correction
- **BUG-091 — l'étape Semgrep ne bloque plus la CI quand le runner ne peut
pas exécuter le core.** Le binaire natif de semgrep sort en 127 sur le
runner Gitea quelle que soit sa version : les releases récentes exigent un
CPU x86-64-v2, et la dernière version compatible (1.157.0, core statique
vérifié en baseline v1) échoue également, sans message. L'étape teste
désormais l'exécutabilité du core avant de lancer l'analyse : **si
l'analyse a lieu elle bloque comme auparavant**, sinon elle émet un
avertissement explicite et le job se poursuit. Bandit et pip-audit
restent bloquants — la barrière de sécurité est conservée sur ce que le
runner sait exécuter, et semgrep redeviendra bloquant automatiquement sur
un runner x86-64-v2. Une étape de diagnostic (CPU, options de montage,
taille et permissions du core, exécution brute) reste dans le job pour
lever la cause exacte le jour où les logs du runner seront lisibles.
---
## [2.39.6] — 2026-09-29
### Correction
- **BUG-091 (suite) — semgrep-core s'exécutait depuis un venv sous `/tmp`.**
Le binaire natif de semgrep sortait en 127 sans message, alors que sa
version était bien compatible avec le CPU du runner (core statique,
baseline x86-64 v1) : le filesystem `/tmp` du runner est monté `noexec`
et le noyau refuse l'exécution sans message exploitable. Le venv isolé
est donc créé dans `$HOME`, et l'étape de diagnostic du job security
trace désormais CPU, options de montage, taille/permissions du core et
exécution brute.
---
## [2.39.5] — 2026-09-29
### Correction
- **BUG-092 — les tests réseau ne dépendaient plus du DNS réel.** Trois tests
de `fetch_url` mockaient `httpx` mais laissaient le garde SSRF résoudre
`example.com` pour de vrai : sur un runner au DNS instable, le job CI
`test` échouait en `dns_error` au lieu d'atteindre la couche testée. Les
tests isolent désormais le garde — y compris la référence importée dans
`webrender`, qui échappait au premier correctif — et les tests de garde
SSRF continuent de traverser le vrai chemin. Contre-preuve : DNS coupé
globalement, la suite passe (1474 tests).
---
## [2.39.4] — 2026-09-29
---
## [2.39.3] — 2026-09-28
---
## [2.39.2] — 2026-09-28
### Correction
- **BUG-091 — le job CI `security` refusait de démarrer semgrep, puis
échouait à l'audit des dépendances.** Depuis 1.158.0, semgrep ne publie
plus que des wheels `manylinux_2_34`/`2_35` dont les bibliothèques
natives exigent un CPU x86-64-v2 : le runner Gitea les refuse (« CPU ISA
level is lower than required », exit 127). semgrep est désormais isolé
dans un venv jetable du job, épinglé à **1.157.0** (dernière publication
`manylinux2014`, baseline v1) — un venv, aussi, parce que ses
dépendances contredisent l'environnement principal (`tomli~=2.0.1` vs
pip-audit ≥ 2.10, `pyjwt~=2.12.0` vulnérable). Dans la foulée :
plancher `pyjwt[crypto]>=2.13.0` dans `backend/requirements.txt`
(PYSEC-2026-178, pyjwt est transitif de mcp) et mise à jour de
pip/setuptools dans le job (PYSEC-2026-3721 / PYSEC-2026-3447, apparus
récemment dans la base d'advisories). Validé en environnement frais :
résolution sans conflit, pip-audit et semgrep verts.
---
## [2.39.1] — 2026-09-28
### Ajouté
- **#153 A6 — l'assistant IA sait lire et modifier les classeurs existants.**
Quatre nouveaux outils dans `backend/tools/spreadsheets.py` :
`list_xlsx_sheets` (noms de feuilles + dimensions), `xlsx_to_markdown`
(tableau plafonné injecté au contexte du modèle), `update_xlsx_cells`
(édition par lots passant par le service gardé) et `append_xlsx_rows`
(ajout de lignes en fin de feuille). Les mutations demandent confirmation
et rafraîchissent la visionneuse (`obsigate:file-written`).
- **#153 A7 — navigation clavier et barre de formule dans la visionneuse.**
`Tab`/`Maj+Tab` circulent entre les cellules, flèches et `Entrée`/
`Maj+Entrée` (multiligne) fonctionnent comme dans un tableur, la cellule
active est nommée en A1 dans la barre de formule, une plage se copie,
et le focus reste visible et tactile (≥ 44 px, couvert par les E2E mobiles).
- **#153 A13 — tri, filtre, recherche et export CSV dans la feuille.**
Tri ascendant/descendant par colonne, filtre de lignes, recherche
suivant/précédent (respect de casse optionnel) et export CSV de la feuille :
toutes des opérations d'**affichage**, le classeur n'est jamais réécrit.
- **#153 A14 — structure du classeur éditable depuis la visionneuse.**
Ajout, renommage, duplication et suppression de feuilles ; insertion et
suppression de lignes/colonnes autour de la cellule active, via le menu
Structure et `PUT /api/file/{vault}/xlsx/structure` — mêmes garde-fous
(backup atomique, verrou, confirmation) que l'édition de cellules.
- **#153 A15 — styles, fusions et volets figés affichés fidèlement.**
La lecture rend les couleurs de police et de fond, le gras/italique/
souligné, les alignements, les plages fusionnées et l'ancre des volets
figés ; un format de nombre personnalisé est signalé par une police à
chasse fixe. Une seule charge du classeur (mode normal) suffit pour toutes
les feuilles, y compris celles rendues par fenêtres.
- **#153 A16 — formats tableur additionnels.** `.xlsm` éditable avec
**macros préservées** (`keep_vba`), `.xls` et `.ods` en **lecture seule**
(xlrd / odfpy), `.csv` édité comme un tableur et réécrit au format
RFC 4180 (`PUT …/csv/save`). Dépendances : `xlrd==2.0.2`,
`odfpy==1.4.1` dans `backend/requirements.txt`.
- **#153 A17 — tableau de bord du classeur.** Un panneau de la visionneuse
liste les plages nommées (portée classeur ou feuille), signale la présence
de graphiques et de tableaux croisés (analyse des parties OPC, sans
recharger le fichier), donne les statistiques par feuille (cellules,
lignes, colonnes, formules, valeurs numériques) et huit KPI extraits de la
première zone de données — endpoint `GET /api/file/{vault}/xlsx/dashboard`.
---
## [2.39.0] — 2026-09-28
---
## [2.38.0] — 2026-09-28
---
## [2.37.0] — 2026-09-28
---
## [2.36.0] — 2026-09-28
---
## [2.35.0] — 2026-09-28
---
## [2.34.0] — 2026-09-28
---
## [2.33.0] — 2026-09-28
---
## [2.32.0] — 2026-09-28
---
## [2.31.0] — 2026-09-28
### Correction
- **BUG-090 — troncature silencieuse d'une feuille `.xlsx` au-delà de
500 lignes × 40 colonnes.** `render_sheets()` renvoie les dimensions
déclarées par la feuille (`total_rows`/`total_cols`), les plafonds du
moteur (`max_rows`/`max_cols`) et un flag `truncated` : la visionneuse
affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 »
(i18n FR/EN) au lieu de présenter une table courte comme complète. La
ligne d'en-têtes est désormais figée au défilement vertical (`thead`
sticky, `top: auto` sur les numéros de ligne pour éviter leur
empilement en haut à gauche). *#153 A8/R5.*
### Ajouté
- **#153 A9 — chargement paresseux d'une feuille par fenêtres.**
`GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` renvoie un
bloc de lignes (`XlsxSheetWindowResponse`, plafond 1 000 lignes par
requête, `has_more` de pagination) avec les **vraies** coordonnées A1
et numéros de ligne de la feuille — une fenêtre se comporte exactement
comme le rendu complet. Erreurs typées : 404 feuille inconnue, 415
fichier non-`.xlsx`. La lecture des valeurs calculées en cache (#153
A12) s'applique aussi aux fenêtres.
- **#153 A9bis — « Charger la suite » sous une feuille tronquée.** Un
pied de page annonce la progression et fetch la fenêtre suivante au
clic ou à l'approche du bas du tableau (sentinelle de défilement).
Les lignes ajoutées passent par le même pipeline d'édition que le
rendu initial : éditables et sauvegardables immédiatement. Un fetch
échoué restore le bouton (retry possible) ; feuille complète → pied
de page masqué.
---
## [2.30.0] — 2026-09-27
### Correction
- **BUG-089 — un reindex manuel ne reconstruisait pas l'index inversé.**
`reload_index()` / `reload_single_vault()` remplacent l'entrée de vault
en bloc, ce qui n'émet pas les notifications incrémentales : la
recherche TF-IDF continuait de servir un index périmé après un
reindex. Les deux fonctions appellent désormais `init_inverted_index()`.
Au passage, `backend/search.py` lisait l'index via
`from backend.indexer import index` — une liaison **par valeur** du
dict : un rechargement du module `backend.indexer` recréait le dict
côté indexer alors que la recherche écrivait dans l'ancien, et
l'index inversé n'indexait plus rien. Tous les accès passent par
`_indexer.index`. *Trouvé en écrivant le test de recherche d'A5 : il
passait isolément et échouait en suite complète selon l'ordre.*
### Ajouté
- **#153 A5 — les tableurs sont indexés par leur contenu.**
`extract_indexable_text()` extrait les noms de feuilles et les 20
premières lignes (plafond 5 000 caractères, 20 feuilles) pour le
TF-IDF et la recherche sémantique. Un mot tapé dans une cellule rend
désormais le classeur trouvable ; la lecture binaire pour l'affichage
est inchangée et un classeur chiffré/corrompu s'indexe par son seul
nom.
- **#153 A10 — la saisie est typée comme dans Excel.** Une valeur
`TRUE`/`FAUX`/`OUI`/`NON` devient un booléen, une date `JJ/MM/AAAA`
(avec `HH:MM` optionnel) devient une vraie date — et dans l'ordre
français : `01/02/2026` est le 1ᵉʳ février. Une saisie ressemblant à
une formule n'est jamais convertie.
- **#153 A12 — la valeur calculée s'affiche sous la formule.** Quand une
cellule porte encore le résultat de son dernier calcul Excel, celui-ci
s'affiche dans une ligne discrète sous la formule. La seconde lecture
`data_only=True` n'a lieu que si l'archive contient réellement une
valeur en cache, et toute erreur retombe sur l'affichage formules seul.
Info-bulle traduite FR/EN (`xlsx.cached_value_title`).
---
## [2.29.0] — 2026-09-27
### Correction
- **BUG-084 — l'index inversé conservait des documents fantômes après la
suppression d'une vault.** `remove_vault_from_index()`
(`backend/indexer.py`) ne notifiait pas le hook incrémental : après
suppression d'une vault, ses documents restaient dans l'index inversé
(`postings`, `doc_info`, `doc_vault`, `vault_docs`) et continuaient de
correspondre aux recherches pour une vault inexistante — seul un reindex
manuel les effaçait. Le correctif déclenche
`_on_index_change('remove', …)` pour chaque fichier de la vault, et
`_remove_doc_internals()` supprime désormais la clé `vault_docs` dont le set
devient vide (c'est un `defaultdict` : une lecture la recréait).
Test : `TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le
correctif).
### Maintenance
- **Index inversé — `is_stale()` renommé `is_ready()`.** La relecture de
`plan.md` a établi que les étapes 6 et 7 (suppression du cooldown et du hack
de coalescence) étaient **déjà livrées** : ni `_last_rebuild`, ni
`_rebuild_cooldown`, ni `_source_generation`, ni `_on_vault_change` ne
subsistent. `is_stale()` ne mesurait donc plus aucune staleness — il
indiquait seulement si l'index initial était construit, sous un nom
trompeur. Renommé `is_ready()`, cohérent avec le `is_ready()` de
`SemanticIndex` ; l'alias `is_stale()` de `SemanticIndex`, sans appelant, est
supprimé. `/api/diagnostics` expose désormais `is_ready` (libellé « Index
prêt » côté `frontend/js/config.js`). Tests :
`test_is_ready_tracks_initial_build`, `test_is_ready_survives_incremental_updates`.
- **`plan.md` recalibré.** Le fichier est désormais marqué « livré » et
suivi d'une section « État réel » : le code a divergé du plan sur quatre
points (pas de repli `_needs_rebuild`, `_ready` au lieu de `doc_count == 0`,
`rebuild()` conservé au démarrage, `is_stale()` repurposé). Les extraits de
code du plan sont explicitement signalés comme ne décrivant pas le code
actuel.
- **Fixtures de test locales exclues du suivi Git.** `test_dir/music/`,
`test_dir/video/`, `test_vault/images/`, `test_vault/markdown/` et
`test_vault/budget.xlsx` (~200 Mo) sont ajoutés au `.gitignore` : aucun test
ni job CI ne les référence — les tests unitaires génèrent leurs fixtures dans
`tmp_path` et l'E2E n'utilise que les fixtures committées
(`test_vault/sample-*.{mp3,png,svg,webm,pdf}`, `test_dir/*.md`). Ils
restaient non suivis et polluaient `git status`.
### Sécurité
- **BUG-088 — plus d'injection de formule via la visionneuse Excel.** Une
saisie `=cmd|'/c calc'!A1` (ou `@…`) était stockée comme **formule** par
openpyxl, donc exécutée par Excel à la réouverture du fichier (DDE).
`edit_xlsx_cells` force maintenant le type texte (`cell.data_type = "s"`)
pour toute valeur commençant par `=` ou `@` ; l'API accepte
`allow_formula: true` et la visionneuse expose un bouton `f(x)`
(opt-in, état de session, jamais persisté). `+`/`-` restent des nombres.
- **BUG-087 — écriture concurrente d'un classeur.** `load_workbook()` →
`save()` n'était pas sérialisé : deux sauvegardes simultanées (deux
onglets, l'agent IA et la visionneuse) faisaient gagner la dernière, en
silence. Verrou par chemin (`backend/services/mutations.py::_xlsx_write_lock`,
timeout 15 s) autour du cycle lecture → édition → remplacement ; attente
dépassée → **409** `conflict`. L'endpoint `PUT …/xlsx/save` est devenu
synchrone pour que l'attente s'exécute dans le threadpool.
### Corrigé
- **BUG-085 — la perte de données à l'enregistrement d'un `.xlsx` est
annoncée, plus silencieuse.** `GET /api/file/{vault}` renvoie
`xlsx_lossy_features` (éléments qu'un round-trip openpyxl perd) ; la
visionneuse affiche un bandeau listant ces éléments et la première
sauvegarde demande confirmation avant de renvoyer `force: true`. Sans
`force`, l'API répond **409** `xlsx_lossy_content` avec
`details.features`. Périmètre **remesuré** sur openpyxl 3.1.5 : graphiques,
images, dessins et tableaux croisés sont bien préservés ; sont perdus les
valeurs calculées en cache, slicers/chronologies, contrôles de formulaire,
connexions/requêtes, custom XML, signature numérique, commentaires
enrichis et macros.
- **BUG-086 — écriture atomique des classeurs.** `wb.save()` écrivait en
place sur le fichier du vault : un plantage laissait un `.xlsx` tronqué.
L'écriture passe désormais par un `.tmp` puis `os.replace()` (le backup
`.bak` est inchangé, le `.tmp` est ignoré par le watcher).
- Le handler global `ServiceError` expose maintenant `code` et `details` dans
la réponse JSON, et `api()` (frontend) les propage sur l'Error — nécessaire
pour que le client distingue un 409 de confirmation d'une autre erreur.
### Ajouté
- **#153 (P0) — tests de la visionneuse Excel.**
`tests/frontend/xlsx-viewer.test.mjs` (10 tests JSDOM : bannière,
confirmation + reprise `force`, refus, toggle `f(x)`, payload de
sauvegarde) et `tests/e2e/xlsx-viewer.spec.js` (3 tests Playwright sur la
fixture `test_vault/sample-xlsx-lossy.xlsx`) ; la suite JSDOM est branchée
dans le CI.
### Documentation
- **#153 — Visionneuse & édition XLSX : audit complet et backlog de
complétude.** La visionneuse `.xlsx` livrée par #152 a été auditée couche
par couche (lecture `backend/xlsx_reader.py`, écriture
`backend/services.mutations.edit_xlsx_cells`, UI `renderXlsxViewer`,
indexation, outils IA, tests). Bilan : la grille de valeurs est éditée
correctement (sécurité, backup, audit, échappement HTML), mais l'ensemble
supporté est étroit, une partie du classeur est perdue à l'enregistrement,
les tableurs sont **invisibles pour la recherche** et l'IA ne sait que les
**créer**. Ouverture de l'item **#153** dans `docs/ROADMAP.md` (17
sous-tâches suivies **A1 → A17** ; **P0 livré**, reste P1 recherche/IA/UX
puis P2 étendu) et création de la fiche
[docs/features/xlsx-viewer.md](docs/features/xlsx-viewer.md) : cartographie
du code, limites par couche, tableau des risques R1-R5 et critères
d'acceptation par sous-tâche.
---
## [2.28.16] — 2026-09-27
---
## [2.28.15] — 2026-09-27
### Sécurité
- **#87 T6 — dépendances qualifiées, `pip-audit` bloquant (0 vulnérabilité).**
mistune 3.0.2 → 3.3.3 (XSS/ReDoS/DoS dans le moteur de rendu),
python-multipart 0.0.9 → 0.0.31, weasyprint 69 → 70, mcp 1.9.4 → 1.28.1,
fastapi 0.110.3 → 0.141.1 + starlette 0.37.2 → 1.7.0, setuptools 84 ;
`cast(str, …)` aux 3 sites d'appel mistune (typage 3.3 resserré). Suite
complète 1359 passed, ruff/mypy 0. Seule exception : PYSEC-2026-1325
(ecdsa, Minerva) — aucun correctif upstream ET JWT exclusivement HS256
(`backend/auth/jwt_handler.py`), les chemins ECDSA P-256 ne s'exécutent
jamais → `--ignore-vuln` documenté.
- **#87 T7 — semgrep SAST local bloquant (8 règles, 0 finding).**
Ruleset `semgrep-rules/` (eval/exec, shell=True, os.system, pickle,
yaml.load sans Loader, verify=False, Markup, mktemp) — 100 % local,
aucun registre réseau (runner au réseau fragile). Trivy écarté :
binaire + base de vulnérabilités à télécharger à chaque run, couche
Python déjà couverte par `pip-audit` bloquant (image = slim + 4 libs).
- **#87 T8 — fin BUG-034 : cookies Secure auto, CORS same-origin explicite.**
`OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut auto : Secure en https,
sinon rien — logins http locaux préservés ; `X-Forwarded-Proto` honoré
sous `TRUST_PROXY`, avertissement démarrage affiné, `TRUST_PROXY=true`
dans le compose prod) ; `CORSMiddleware` same-origin explicite (sûr :
web et desktop Tauri same-origin, API directe hors navigateur) ;
`style-src 'unsafe-inline'` conservé et assumé (189 attributs `style=` +
343 `el.style` — suppression = réécriture complète, risque nul côté
exécution une fois `script-src` verrouillé en T5c).
---
## [2.28.14] — 2026-09-27
---
## [2.28.13] — 2026-09-27
### Modifié
- **#87 (T5c) — `script-src` sans `'unsafe-inline'`.**
Seuls les scripts avec nonce frais (`backend/csp.py`, T5b) ou servis par
`'self'`/CDN listés s'exécutent ; `style-src` garde `'unsafe-inline'`
(chantier séparé). Vérifié : `test_csp_nonce.py` 5/5, 0 handler inline
restant dans les pages HTML (propriétés `onXxx = fn` en JS non concernées
par la CSP).
---
## [2.28.12] — 2026-09-27
### Corrigé
- **BUG-081 — `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée.**
Le pseudo-user `anonymous` (auth désactivée, mode E2E/CI) n'a aucune entrée
en store : `get_user(...)` → `None` puis `AttributeError` sur `user.get`.
Garde `None` → payload « MFA désactivé » (`mfa_enabled: false`,
`totp_enabled: false`, `webauthn_credentials: 0`). Test : `tests/test_mfa.py`
(`TestMfaStatusAuthDisabled`, échoue en 500 sans le correctif).
---
## [2.28.11] — 2026-09-27
---
## [2.28.10] — 2026-09-27
### Corrigé
- **BUG-083 — job CI `security` rouge : le runner tronquait le `#` du `run:` pip-audit.**
Le runner Gitea Act coupe naïvement au premier `#` (même entre
guillemets) : `echo "... see #87)"` devenait une citation non fermée
(`unexpected EOF while looking for matching '"'"`). Seul `run:` du
workflow avec un `#` ; l'echo n'a plus de `#` (réf `#87` en commentaire
YAML, jamais vu par le shell). Garde-fou : `tests/test_ci_workflow.py`
(aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé dans
l'étape JSDOM — BUG-082).
---
## [2.28.9] — 2026-09-27
### Corrigé
- **BUG-082 — CI `lint` rouge : suites frontend exigeant `jsdom`.**
`tests/frontend/upload.test.mjs` puis `config-ai-keys.test.mjs` (imports
statiques `jsdom`, introduits par `#89`) étaient exécutés dans l'étape
frontend racine où `jsdom` n'est jamais installé (`ERR_MODULE_NOT_FOUND`,
rouge depuis `7bee4a2`). Déplacés dans l'étape JSDOM (les deux branches,
après install si besoin) ; garde-fou `tests/test_ci_workflow.py` :
aucun fichier de l'étape racine ne doit importer `jsdom` statiquement.
---
## [2.28.8] — 2026-09-27
### Corrigé
- **BUG-080 — harnais E2E local anti-blocage (plus de run pendu toute la nuit).**
`run-e2e-local.ps1/.sh` : `npx --yes` (jamais de prompt interactif),
`run-e2e-local.ps1` : Playwright lancé via `node` direct sur la CLI locale
(jamais de prompt interactif, `Start-Process` ne sachant pas exécuter `npx` ;
paramètre `$Arguments`, `$Args` étant une variable automatique qui l'écraserait),
installation Chromium sautée si déjà présent (`E2E_INSTALL_BROWSERS=1`
pour forcer), étapes `install`/`test` bornées (`E2E_TIMEOUT_SEC`,
défaut 900 s / 600 s, exit 124 au dépassement) ; `playwright.config.ts` :
`globalTimeout` (15 min en local, 30 min en CI, `E2E_GLOBAL_TIMEOUT_MS`
pour surcharger) ; `e2e-server.ps1` : pidfile resynchronisé sur le vrai
propriétaire du port et `stop` qui tue l'arbre complet (fini les serveurs
orphelins qui squattent le port 2029). Garde-fou : `tests/test_e2e_harness.py`.
défaut 1800 s / 600 s, exit 124 au dépassement — au-delà du globalTimeout
pour un abandon propre avec rapport) ; `run-e2e-local.sh` : `npx --yes` +
mêmes bornes ; `playwright.config.ts` : `globalTimeout` (25 min en local,
30 min en CI, `E2E_GLOBAL_TIMEOUT_MS` pour surcharger) ; `e2e-server.ps1` :
pidfile resynchronisé sur le vrai propriétaire du port et `stop` qui tue
l'arbre complet (fini les serveurs orphelins qui squattent le port 2029).
Garde-fous : `tests/test_e2e_harness.py` (8 tests).
---
+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.8-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.47.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` et `.xlsm` 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. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. 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)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`
- **🎨 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.8).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.47.0).
---
*Projet : ObsiGate | Version : 2.28.8 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.47.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.8-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.47.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` and `.xlsm` 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. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. 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, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`
- **🎨 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.8).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.47.0).
---
*Project: ObsiGate | Version: 2.28.8 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.47.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.28.8
2.47.0
+45 -16
View File
@@ -16,7 +16,7 @@ from backend.ratelimit import record_account_failure as rl_record_account_failur
from backend.ratelimit import record_account_success as rl_record_account_success
from backend.ratelimit import record_failure as rl_record_failure
from backend.ratelimit import record_success as rl_record_success
from backend.services.net import get_client_ip
from backend.services.net import get_client_ip, is_trusted_proxy
from .jwt_handler import (
ACCESS_TOKEN_EXPIRE_SECONDS,
@@ -57,15 +57,32 @@ logger = logging.getLogger("obsigate.auth.router")
router = APIRouter(prefix="/api/auth", tags=["auth"])
def is_secure_cookies() -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3).
def is_secure_cookies(request: Request | None = None) -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3/T8).
Opt-in via ``OBSIGATE_SECURE_COOKIES=true`` (required behind TLS).
Default stays ``false`` so logins keep working over plain HTTP on
trusted loopback deployments — browsers drop ``Secure`` cookies sent
over HTTP, which would silently break localhost logins.
``OBSIGATE_SECURE_COOKIES=true|false|auto`` (défaut : ``auto``) :
``true``/``false`` forcent le comportement ; ``auto`` met ``Secure``
si la requête arrive en https (production derrière TLS) et l'omet
sinon (dev local en http — les navigateurs jettent les cookies
``Secure`` sur http, ce qui casserait silencieusement les logins
localhost). Derrière un reverse proxy qui termine TLS, le schéma perçu
est http : avec ``OBSIGATE_TRUST_PROXY=true``, ``X-Forwarded-Proto``
est honoré (même garde que ``get_client_ip``, BUG-030).
"""
return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
forced = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
if forced in ("1", "true", "yes", "on"):
return True
if forced in ("0", "false", "no", "off"):
return False
if request is None:
return False
if request.url.scheme == "https":
return True
if is_trusted_proxy():
proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
if proto == "https":
return True
return False
# ── Pydantic request models ──────────────────────────────────────────
@@ -230,10 +247,11 @@ async def login(body: LoginRequest, response: Response, request: Request):
"remember_me": body.remember_me,
}
return _issue_tokens(user, body.username, body.remember_me, response)
return _issue_tokens(user, body.username, body.remember_me, response, request)
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response,
request: Request | None = None) -> dict:
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
record_login_success(username)
rl_record_account_success(username)
@@ -242,7 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
max_age = 2592000 if remember_me else 604800 # 30d or 7d
secure = is_secure_cookies()
secure = is_secure_cookies(request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
@@ -311,7 +329,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
if stale:
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
secure = is_secure_cookies()
secure = is_secure_cookies(request)
remember_me = bool(payload.get("remember", False))
# BUG-027: rotate the refresh token — the old one is now single-use.
@@ -437,6 +455,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
async def change_password(
req: ChangePasswordRequest,
response: Response,
request: Request,
current_user=Depends(require_auth),
):
"""Change own password.
@@ -452,7 +471,7 @@ async def change_password(
updated = get_user(current_user["username"])
result: dict = {"message": "Mot de passe mis à jour"}
if updated is not None:
result.update(_issue_tokens(updated, updated["username"], False, response))
result.update(_issue_tokens(updated, updated["username"], False, response, request))
return result
@@ -815,7 +834,7 @@ async def mfa_webauthn_verify(
rl_record_success(client_ip)
logger.info(f"User '{body.username}' logged in via WebAuthn")
return _issue_tokens(user, body.username, body.remember_me, response)
return _issue_tokens(user, body.username, body.remember_me, response, request)
@router.get("/mfa/status")
@@ -823,6 +842,16 @@ async def mfa_status(current_user=Depends(require_auth)):
"""Return current user's MFA status."""
from .user_store import get_user
user = get_user(current_user["username"])
if user is None:
# BUG-081 : auth désactivée (OBSIGATE_AUTH_ENABLED=false) → le
# pseudo-user "anonymous" n'a aucune entrée en store : pas de MFA,
# et surtout pas de 500 (`AttributeError` sur `user.get`).
return {
"mfa_enabled": False,
"mfa_method": None,
"totp_enabled": False,
"webauthn_credentials": 0,
}
return {
"mfa_enabled": user.get("mfa_enabled", False),
"mfa_method": user.get("mfa_method"),
@@ -858,7 +887,7 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
# Clear IP rate limit on success
rl_record_success(client_ip)
return _issue_tokens(user, body.username, body.remember_me, response)
return _issue_tokens(user, body.username, body.remember_me, response, request)
@router.post("/mfa/recovery")
@@ -896,7 +925,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
rl_record_success(client_ip)
logger.info(f"User '{body.username}' logged in via recovery code")
return _issue_tokens(user, body.username, False, response)
return _issue_tokens(user, body.username, False, response, request)
# ── Admin endpoints ───────────────────────────────────────────────────
+4 -1
View File
@@ -23,6 +23,7 @@ import re
import unicodedata
import zipfile
from pathlib import Path
from typing import cast
import frontmatter
import mistune
@@ -246,7 +247,9 @@ def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> st
"""Render raw markdown to an HTML fragment (images inlined, wikilinks resolved)."""
md = _inline_images(md, file_dir, vault_path)
md = _convert_wikilinks(md, vault_path, current)
return _markdown(md)
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le renderer
# HTML renvoie toujours `str` à l'exécution).
return cast(str, _markdown(md))
def _build_nav(vault_path: Path, current: Path) -> str:
+45 -7
View File
@@ -351,6 +351,23 @@ def _decompress_excalidraw(compressed: str) -> dict[str, Any] | None:
return data
def extract_xlsx_indexable(file_path: Path) -> str:
"""Return searchable text for a workbook (#153 A5).
Lazy wrapper: ``openpyxl`` is only imported when a spreadsheet is actually
indexed, so a vault without workbooks never pays the import. Errors are
swallowed — a corrupt or encrypted file still gets indexed by name.
"""
try:
from backend.xlsx_reader import extract_indexable_text
except Exception: # pragma: no cover - openpyxl missing
return ""
try:
return extract_indexable_text(file_path)
except Exception: # pragma: no cover - defensive
return ""
def extract_excalidraw_indexable(raw: str) -> str:
"""Return indexable text content for a raw .excalidraw / .excalidraw.md file.
@@ -561,11 +578,12 @@ def _scan_vault(
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = ""
elif ext == ".xlsx":
# #152 — binary workbook: metadata only, the viewer renders
# it (parity with _index_single_file_sync).
raw = ""
# #153 A5 — a workbook stays rendered by the viewer, but its
# cell values are now indexed as text so a spreadsheet is
# findable by its content (parity with _index_single_file_sync).
raw = extract_xlsx_indexable(fpath)
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = ""
content_preview = raw[:200].strip()
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
title = fpath.stem.replace("-", " ").replace("_", " ")
@@ -807,6 +825,13 @@ async def reload_index() -> dict[str, Any]:
await build_index()
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
await enrich_pdf_texts()
# The inverted index is NOT updated by the hooks here: the rebuild above
# replaces whole vault entries, so the incremental notifications are not
# emitted for the files that only changed content. Without this, a manual
# reindex left TF-IDF search serving a stale index (BUG-089).
from backend.search import init_inverted_index
init_inverted_index()
stats = {}
for name, data in index.items():
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
@@ -882,6 +907,13 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
await enrich_pdf_texts(vault_name)
# Same as reload_index: the vault entry was replaced wholesale, so rebuild
# the inverted index or TF-IDF search keeps serving stale postings
# (BUG-089).
from backend.search import init_inverted_index
init_inverted_index()
stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])}
logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags")
return stats
@@ -955,9 +987,9 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
raw = ""
content_preview = ""
elif ext == ".xlsx":
# #152 — binary workbook: metadata only (parity with _scan_vault).
raw = ""
content_preview = ""
# #153 A5 — index sheet names + header rows as text (see _scan_vault).
raw = extract_xlsx_indexable(fpath)
content_preview = raw[:200].strip()
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
content_preview = raw[:200].strip()
@@ -1226,6 +1258,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)
+46 -6
View File
@@ -182,9 +182,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# A route may set a stricter per-response policy (e.g. ``sandbox`` for
# standalone SVG, #108-B3); keep it instead of overwriting it.
if "Content-Security-Policy" not in response.headers:
# #87 T5c : `script-src` sans 'unsafe-inline' — seuls les scripts
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
# `style=` et `el.style` omniprésents — chantier séparé).
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'unsafe-inline' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
@@ -256,12 +260,19 @@ async def lifespan(app: FastAPI):
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
_guard_insecure_auth()
# #87 T3 : avertir quand les cookies d'auth circulent sans flag Secure
# sur un bind non-loopback (transactions observables en clair).
# #87 T3/T8 : avertir quand les cookies d'auth circulent sans flag Secure
# sur un bind non-loopback (transactions observables en clair). Avec
# `OBSIGATE_SECURE_COOKIES=auto` (défaut) + `OBSIGATE_TRUST_PROXY=true`,
# le flag suit `X-Forwarded-Proto` : pas d'avertissement, le https du
# reverse proxy est honoré.
from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host
from backend.auth.router import is_secure_cookies
from backend.services.net import is_trusted_proxy
if is_auth_enabled() and not is_secure_cookies() and not is_loopback_host(bind_host_from_argv()):
secure_mode = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
proxy_secure = secure_mode == "auto" and is_trusted_proxy()
if (is_auth_enabled() and not is_secure_cookies()
and not is_loopback_host(bind_host_from_argv()) and not proxy_secure):
logger.warning(
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
"activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production."
@@ -378,12 +389,25 @@ 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)
from fastapi.middleware.gzip import GZipMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.types import Receive, Scope, Send
@@ -414,6 +438,22 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
# Security headers on all responses
app.add_middleware(SecurityHeadersMiddleware)
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
# donc toute lecture cross-origin est refusée (défense explicite, plus
# seulement l'absence de middleware). Sûr pour tous les clients : web
# (same-origin), desktop Tauri (la webview est redirigée same-origin sur
# http://127.0.0.1:<port>, voir frontend/js/desktop.js) et API directe
# (curl/scripts, CORS non appliqué hors navigateur). Ajouté en dernier :
# le plus externe, les preflights court-circuitent avant tout le reste.
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# Auth router
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
from backend.ai_routes import router as ai_router
+32 -1
View File
@@ -182,9 +182,40 @@ _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},
},
("put", "/api/file/{vault_name}/xlsx/style"): {
"request": {
"ops": [
{"op": "cell", "sheet": "Budget", "range": "A1:B1", "style": {"bold": True, "fill_color": "#ffe08a"}},
{"op": "col_width", "sheet": "Budget", "col": "A", "width": 24},
],
"force": False,
"if_match": "18f2c0ab-1f4",
},
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 2, "revision": "18f2c0ab-1f6"},
},
# GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI
# invalide) — les paramètres sont documentés par leurs Query().
("get", "/api/file/{vault_name}/xlsx/sheet"): {
"response": {
"vault": "TestVault",
"path": "data/budget.xlsx",
"sheet": "Budget",
"offset": 0,
"limit": 200,
"rows": 2,
"cols": 2,
"total_rows": 640,
"total_cols": 12,
"max_rows": 500,
"max_cols": 40,
"truncated": True,
"has_more": True,
"html": "<table>…</table>",
},
},
("post", "/api/search/replace"): {
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
+4 -1
View File
@@ -15,6 +15,7 @@ import html as html_mod
import re
import unicodedata
from pathlib import Path
from typing import cast
import mistune
@@ -196,7 +197,9 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
converted = _normalize_line_breaks(converted)
rendered = _markdown_renderer(converted)
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (les
# renderers HTML renvoient toujours `str` à l'exécution).
rendered = cast(str, _markdown_renderer(converted))
# Add heading IDs for TOC navigation
rendered = _add_heading_ids(rendered)
+21 -7
View File
@@ -1,9 +1,9 @@
fastapi==0.110.3
uvicorn==0.30.0
fastapi==0.141.1
uvicorn==0.54.0
websockets>=12.0
python-frontmatter==1.1.0
mistune==3.0.2
python-multipart==0.0.9
mistune==3.3.3
python-multipart==0.0.31
aiofiles==23.2.1
aiohttp>=3.9.0
watchdog>=4.0.0
@@ -11,17 +11,31 @@ argon2-cffi>=23.1.0
python-jose>=3.3.0
sortedcontainers>=2.4.0
snowballstemmer>=2.2.0
weasyprint>=60.0
weasyprint>=70.0
httpx>=0.27.0
pypdf>=4.0
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
# et n'est jamais mis à niveau.
pypdf>=6.16.1
pyotp>=2.10.0
segno>=1.5.0
webauthn==2.6.0
psutil>=5.9
pywebpush>=2.3.0
mcp==1.9.4
mcp==1.28.1
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
pyjwt[crypto]>=2.14.0
sse-starlette==2.1.3
openpyxl>=3.1
xlrd==2.0.2
odfpy==1.4.1
python-docx>=1.1
reportlab>=4.0
pillow>=10.0
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
urllib3>=2.8.0
+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(),
+188 -22
View File
@@ -38,10 +38,13 @@ from backend.schemas import (
BrowseResponse,
FileContentResponse,
FileRawResponse,
XlsxDashboardResponse,
XlsxSheetWindowResponse,
)
from backend.services.files import read_raw_file
from backend.services.mutations import file_revision
from backend.services.paths import resolve_safe_path
from backend.services.vaults import browse_directory
from backend.services.vaults import browse_directory, get_vault_root
logger = logging.getLogger("obsigate")
@@ -178,6 +181,116 @@ async def api_file_backlinks(
}
@router.get(
"/api/file/{vault_name}/xlsx/dashboard", response_model=XlsxDashboardResponse
)
def api_file_xlsx_dashboard(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx workbook"),
current_user=Depends(require_auth),
):
"""Return the dashboard metadata of an .xlsx workbook (#153 A17).
Named ranges (workbook- or sheet-scoped), chart/pivot object counts and
per-sheet KPI stats (non-empty cells, rows/cols coverage, formulas,
numeric cells, first numeric values as KPI cards). Read-only, bounded by
the 500x40 render caps; never raises for an unreadable workbook — an
empty payload comes back and the viewer hides the panel.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
_vault_root = get_vault_root(vault_name)
file_path = resolve_safe_path(_vault_root, path)
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
raise HTTPException(
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
)
from backend.xlsx_reader import read_workbook_dashboard
try:
dashboard = read_workbook_dashboard(file_path)
except Exception as e:
logger.error(f"XLSX dashboard read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
return {
"vault": vault_name,
"path": path,
**dashboard,
}
@router.get(
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
)
def api_file_xlsx_sheet(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
limit: int = Query(
200, ge=1, le=1000, description="Rows to return (server-capped)"
),
current_user=Depends(require_auth),
):
"""Return a window of rows of one sheet of an .xlsx/.xlsm workbook (#153 A9).
Backs the viewer's lazy loading: instead of every sheet in a single JSON
payload, the client asks for the block it is about to display. The row
numbers and the ``data-cell`` references are the real A1 coordinates of the
sheet, so a window behaves like the full render (editing a cell in it
targets the right cell).
The response also carries ``total_rows``/``total_cols`` and the ``truncated``
flag, so the client can say what is hidden behind the 500x40 render caps
instead of silently hiding it.
Args:
vault_name: Name of the vault.
path: Relative path of the .xlsx file within the vault.
sheet: Sheet name; **404** if the workbook has no such sheet.
offset: 0-based index of the first row to return.
limit: Rows to return, capped server-side at 1000.
Returns:
``XlsxSheetWindowResponse`` with the rendered ``html`` of the window.
Raises:
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
415 (not an .xlsx/.xlsm file), 500 (unreadable workbook).
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
file_path = resolve_safe_path(Path(vault_data["path"]), path)
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
# BUG-097 — a .xlsm rides the same editable viewer (and its lazy loading),
# so its row windows must be servable too; other formats stay refused.
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
raise HTTPException(
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
)
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
from backend.xlsx_reader import read_sheet_window
try:
window = read_sheet_window(file_path, sheet, offset=offset, limit=limit)
except Exception as e:
logger.error(f"XLSX sheet read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
if window is None:
raise HTTPException(status_code=404, detail=f"Feuille introuvable: {sheet}")
return {"vault": vault_name, "path": path, **window}
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.
@@ -241,8 +354,11 @@ 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
# #153 A15 — every sheet dict already carries its styles, aligns,
# merges and freeze anchor (read_workbook_meta, one normal-mode
# load inside render_sheets).
sheets = render_sheets(file_path)
size = file_path.stat().st_size
return {
@@ -257,6 +373,13 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
# #156-A12 — optimistic-concurrency token: the viewer sends it
# back as `if_match` so another writer cannot be overwritten in
# silence (409 `conflict` instead).
"xlsx_revision": file_revision(file_path),
# #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,
}
@@ -374,32 +497,75 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
logger.error(f"Unexpected error reading file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
# === CSV: render as HTML table ===
# === Excel .xlsm: same editable viewer as .xlsx, macros preserved on save ===
if ext == ".xlsm":
try:
from backend.xlsx_reader import inspect_workbook, render_sheets
sheets = render_sheets(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": sheets[0]["html"] if sheets else "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
"xlsx_revision": file_revision(file_path),
# Macros are NOT lossy for .xlsm: keep_vba re-serializes them
# (an empty LOSSY probe is what makes the save gate pass).
"xlsx_lossy_features": [],
"unsupported": False,
"size_bytes": size,
}
except Exception as e:
logger.error(f"XLSX read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
# === Legacy/ODF spreadsheets (.xls, .ods): read-only table view ===
if ext in (".xls", ".ods"):
try:
from backend.xlsx_reader import render_legacy_workbook
sheets = render_legacy_workbook(file_path, ext)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": sheets[0]["html"] if sheets else "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_xlsx": True,
"xlsx_readonly": True,
"xlsx_sheets": sheets,
"unsupported": False,
"size_bytes": size,
}
except Exception as e:
logger.error(f"Spreadsheet read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading spreadsheet: {e!s}")
# === CSV: spreadsheet-style table (same shape as the xlsx viewer) ===
if ext == ".csv":
import csv
import io as csv_io
reader = csv.reader(csv_io.StringIO(raw))
rows = list(reader)
if not rows:
html = "<p><em>Fichier CSV vide</em></p>"
else:
headers = rows[0]
data_rows = rows[1:]
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
for h in headers:
html += f"<th>{h}</th>"
html += "</tr></thead><tbody>"
for row in data_rows:
html += "<tr>"
for cell in row:
html += f"<td>{cell}</td>"
html += "</tr>"
html += "</tbody></table></div>"
from backend.xlsx_reader import render_csv_table
html = render_csv_table(raw)
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_csv": True,
# #156-A12 — same stale-write guard as the workbooks.
"xlsx_revision": file_revision(file_path),
}
# === JSON: syntax-highlighted display ===
+227 -6
View File
@@ -64,12 +64,21 @@ from backend.services.mutations import (
from backend.services.mutations import (
move_path as service_move_path,
)
from backend.services.mutations import (
mutate_xlsx_structure as service_mutate_xlsx_structure,
)
from backend.services.mutations import (
mutate_xlsx_style as service_mutate_xlsx_style,
)
from backend.services.mutations import (
rename_directory as service_rename_directory,
)
from backend.services.mutations import (
rename_file as service_rename_file,
)
from backend.services.mutations import (
save_csv_cells as service_save_csv_cells,
)
from backend.share import update_shares_after_rename
from backend.sse import sse_manager
from backend.webhooks import dispatch_webhooks
@@ -114,17 +123,39 @@ 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, "if_match": str}'
),
),
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).
* ``if_match`` — revision token returned by the read (#156-A12). When it no
longer matches the file on disk the write is refused with **409**
``conflict`` (``reason=stale_revision``) instead of overwriting a change
made by another writer. Omitted: last writer wins (curl, AI tools).
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,14 +169,204 @@ 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
if_match = body.get("if_match")
if if_match is not None and not isinstance(if_match, str):
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
result = service_edit_xlsx_cells(vault_name, path, sheet, cells)
result = service_edit_xlsx_cells(
vault_name, path, sheet, cells, expected_revision=if_match, **flags
)
log_file_save(
current_user["username"], vault_name, path,
sum(len(str(v)) for v in cells.values()),
current_user.get("_request_ip", "unknown"),
)
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": result["size"], "revision": result.get("revision"),
}
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
def api_file_csv_save(
vault_name: str,
path: str = Query(..., description="Relative path to the .csv file"),
body: dict = Body(
...,
description=(
'{"cells": {"A1": value}, "if_match": str} — A1-addressed text '
'edits (#153 A16). `if_match` is the revision token of the read '
'(#156-A12): a stale token fails with 409 `conflict`.'
),
),
current_user=Depends(require_auth),
):
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
The grid is re-parsed with :mod:`csv`, patched and re-serialized
(RFC 4180 quoting). References beyond the extent grow the grid. Values
are stored verbatim as text — a CSV has no formula engine.
``if_match`` (optional, #156-A12) is the revision the client read: when the
file changed in the meantime the write is refused with **409** ``conflict``.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
cells = body.get("cells")
if not isinstance(cells, dict) or not cells or len(cells) > 500:
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
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}")
if_match = body.get("if_match")
if if_match is not None and not isinstance(if_match, str):
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
result = service_save_csv_cells(vault_name, path, cells, expected_revision=if_match)
log_file_save(
current_user["username"], vault_name, path,
sum(len(str(v)) for v in cells.values()),
current_user.get("_request_ip", "unknown"),
)
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": result["size"], "revision": result.get("revision"),
}
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
def api_file_xlsx_structure(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx file"),
body: dict = Body(
...,
description=(
'{"actions": [{"op": "sheet_add", "name": "X"}, '
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
'"force": false, "if_match": str}'
),
),
current_user=Depends(require_auth),
):
"""Apply structural changes to an .xlsx workbook (#153 A14).
``actions`` is an ordered list applied in one locked, atomic rewrite:
``sheet_add`` (``name``, optional ``at`` 0-based), ``sheet_rename``
(``from``/``to``), ``sheet_delete`` (refused on the last sheet),
``sheet_duplicate`` (``name``/``as``) and ``row_insert``/``row_delete``/
``col_insert``/``col_delete`` (``sheet``, 1-based ``at``, ``count``).
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
workbook carries features openpyxl cannot rewrite (same gate as the cell
edits). A backup is created before the archive is replaced. The optional
``if_match`` revision (#156-A12) refuses a structural rewrite on a file that
changed since it was read (**409** ``conflict``).
Args:
vault_name: Name of the vault.
path: Relative path to the ``.xlsx`` file.
body: JSON body with ``actions`` (1 to 50) and optional ``force``.
Returns:
``FileSaveResponse`` confirming the write.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
actions = body.get("actions")
if not isinstance(actions, list) or not actions or len(actions) > 50:
raise HTTPException(status_code=400, detail="Actions invalides (1 à 50 par requête)")
raw_force = body.get("force", False)
if not isinstance(raw_force, bool):
raise HTTPException(status_code=400, detail="Flag invalide: force")
if_match = body.get("if_match")
if if_match is not None and not isinstance(if_match, str):
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
result = service_mutate_xlsx_structure(
vault_name, path, actions, force=raw_force, expected_revision=if_match
)
log_file_save(
current_user["username"], vault_name, path,
len(actions),
current_user.get("_request_ip", "unknown"),
)
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": len(result["applied"]), "revision": result.get("revision"),
}
@router.put("/api/file/{vault_name}/xlsx/style", response_model=FileSaveResponse)
def api_file_xlsx_style(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
body: dict = Body(
...,
description=(
'{"ops": [{"op": "cell", "sheet": "X", "range": "A1:B2", '
'"style": {"bold": true, "fill_color": "#ffe08a"}}], '
'"force": false, "if_match": str}'
),
),
current_user=Depends(require_auth),
):
"""Write formatting on an .xlsx/.xlsm workbook (#156-A8).
``ops`` is an ordered list applied in one locked, atomic rewrite:
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``,
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
and ``freeze`` (``cell``, empty to release).
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
workbook carries features openpyxl cannot rewrite (same gate as the cell
edits). A backup is created before the archive is replaced; the optional
``if_match`` revision (#156-A12) refuses a rewrite on a file that changed
since it was read (**409** ``conflict``).
Args:
vault_name: Name of the vault.
path: Relative path to the ``.xlsx``/``.xlsm`` file.
body: JSON body with ``ops`` (1 to 50) and optional ``force``.
Returns:
``FileSaveResponse`` confirming the write.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
ops = body.get("ops")
if not isinstance(ops, list) or not ops or len(ops) > 50:
raise HTTPException(status_code=400, detail="Ops invalides (1 à 50 par requête)")
raw_force = body.get("force", False)
if not isinstance(raw_force, bool):
raise HTTPException(status_code=400, detail="Flag invalide: force")
if_match = body.get("if_match")
if if_match is not None and not isinstance(if_match, str):
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
result = service_mutate_xlsx_style(
vault_name, path, ops, force=raw_force, expected_revision=if_match
)
log_file_save(
current_user["username"], vault_name, path,
len(ops),
current_user.get("_request_ip", "unknown"),
)
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": len(result["applied"]), "revision": result.get("revision"),
}
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
+102 -2
View File
@@ -285,8 +285,37 @@ class FileContentResponse(BaseModel):
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
is_csv: bool | None = Field(default=None, description="True for CSV files")
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
xlsx_readonly: bool | None = Field(
default=None,
description=(
"True when the table is served read-only (.xls/.ods, #153 A16): "
"the viewer hides the editable-cell wiring and the save/structure "
"endpoints refuse the format"
),
)
xlsx_sheets: list[dict[str, Any]] | None = Field(
default=None, description="Rendered xlsx sheets [{name, html}]"
default=None,
description=(
"Rendered xlsx sheets [{name, html, rows, cols, total_rows, "
"total_cols, max_rows, max_cols, truncated}] — `truncated` is true "
"when the sheet exceeds the 500x40 render caps (#153 A8)"
),
)
xlsx_revision: str | None = Field(
default=None,
description=(
"Optimistic-concurrency token of the spreadsheet (#156-A12): the "
"client sends it back as the `if_match` of a write so a change made "
"elsewhere is refused (409 `conflict`) instead of overwritten"
),
)
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")
@@ -297,6 +326,69 @@ class FileContentResponse(BaseModel):
image_mime: str | None = Field(default=None, description="MIME type for image files")
class XlsxDashboardNamedRange(BaseModel):
"""One named range of a workbook (#153 A17)."""
name: str = Field(description="Range name as declared in the workbook")
scope: str = Field(description="Sheet name when sheet-scoped, empty when workbook-wide")
ref: str = Field(description="Formula-style reference, e.g. Data!$A$1:$B$5")
class XlsxDashboardSheetKpi(BaseModel):
"""One KPI card of a sheet dashboard (#153 A17)."""
label: str = Field(description="A1 reference of the numeric cell")
value: float = Field(description="Numeric value of the cell")
class XlsxDashboardSheet(BaseModel):
"""Per-sheet KPI stats of a workbook dashboard (#153 A17)."""
name: str = Field(description="Sheet name")
cells: int = Field(description="Non-empty cells inside the 500x40 caps")
rows: int = Field(description="Rows carrying at least one non-empty cell")
cols: int = Field(description="Columns carrying at least one non-empty cell")
formulas: int = Field(description="Cells whose value is a formula")
numeric: int = Field(description="Cells carrying a numeric value")
kpi: list[XlsxDashboardSheetKpi] = Field(description="First numeric cells as KPI cards")
class XlsxDashboardResponse(BaseModel):
"""Dashboard metadata of an .xlsx workbook (#153 A17)."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
named_ranges: list[XlsxDashboardNamedRange] = Field(description="Named ranges, sorted by name")
objects: dict[str, int] = Field(description="Object counts: {charts, pivots}")
sheets: list[XlsxDashboardSheet] = Field(description="Per-sheet KPI stats")
class XlsxSheetWindowResponse(BaseModel):
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
Served by ``GET /api/file/{vault_name}/xlsx/sheet``; the row numbers and
the ``data-cell`` references in ``html`` are the real A1 coordinates of the
sheet, whatever the window.
"""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
sheet: str = Field(description="Sheet name (as shown in the tab)")
offset: int = Field(description="0-based index of the first returned row")
limit: int = Field(description="Maximum number of rows returned (capped server-side)")
rows: int = Field(description="Rows actually returned in this window")
cols: int = Field(description="Columns of the rendered window")
total_rows: int = Field(description="Rows the sheet declares")
total_cols: int = Field(description="Columns the sheet declares")
max_rows: int = Field(description="Row cap of the renderer (500) — the coverage of this window")
max_cols: int = Field(description="Column cap of the renderer (40)")
truncated: bool = Field(
description="True when the sheet exceeds the 500x40 render caps"
)
has_more: bool = Field(description="True when rows remain after this window")
html: str = Field(description="Rendered HTML table for the window")
class FileRawResponse(BaseModel):
"""Raw text content of a file."""
@@ -317,6 +409,12 @@ class FileSaveResponse(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
size: int = Field(description="Size of saved content in characters")
# #156-A12 — optimistic-concurrency token of the file AFTER the write, so a
# client can chain writes without re-reading (absent on non-spreadsheets).
revision: str | None = Field(
default=None,
description="Opaque revision of the saved spreadsheet (send it back as `if_match`)",
)
class FileDeleteResponse(BaseModel):
@@ -548,6 +646,7 @@ class SearchFacets(BaseModel):
tags: dict[str, int] = Field(default_factory=dict)
vaults: dict[str, int] = Field(default_factory=dict)
extensions: dict[str, int] = Field(default_factory=dict, description="Counts per file extension, dotless and lowercase (query-ready for the ext: operator)")
class AdvancedSearchResponse(BaseModel):
@@ -557,7 +656,7 @@ class AdvancedSearchResponse(BaseModel):
total: int = Field(description="Total number of matching results")
offset: int = Field(description="Current pagination offset")
limit: int = Field(description="Page size")
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
facets: SearchFacets = Field(description="Faceted counts by tag, vault and file extension")
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
@@ -568,6 +667,7 @@ class TitleSuggestion(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
tags: list[str] = Field(default_factory=list, description="File tags")
class SuggestResponse(BaseModel):
+40 -12
View File
@@ -12,7 +12,12 @@ from sortedcontainers import SortedList
from backend import indexer as _indexer
from backend import semantic_search as _semantic
from backend.indexer import index
# NOTE: the shared index is read through ``_indexer.index`` everywhere, never
# via ``from backend.indexer import index``. That import binds the dict object
# once, so a module reload of ``backend.indexer`` (tests, dev reload) rebinds
# the module-level name to a FRESH dict while this module keeps writing to the
# stale one — the inverted index then silently indexes nothing (BUG-089).
from backend.services.regex_safety import (
MAX_REGEX_MATCHES,
truncate_for_regex,
@@ -368,12 +373,19 @@ class InvertedIndex:
self.doc_vault: dict[str, str] = {}
self.vault_docs: dict[str, set] = defaultdict(set)
self.tag_docs: dict[str, set] = defaultdict(set)
self.doc_tags: dict[str, set] = defaultdict(set)
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.
@@ -392,8 +404,9 @@ class InvertedIndex:
self.doc_vault = {}
self.vault_docs = defaultdict(set)
self.tag_docs = defaultdict(set)
self.doc_tags = defaultdict(set)
for vault_name, vault_data in index.items():
for vault_name, vault_data in _indexer.index.items():
for file_info in vault_data.get("files", []):
doc_key = f"{vault_name}::{file_info['path']}"
self.doc_count += 1
@@ -406,6 +419,7 @@ class InvertedIndex:
# --- Per-document tag index ---
for tag in file_info.get("tags", []):
self.tag_docs[tag.lower()].add(doc_key)
self.doc_tags[file_info['path']].add(tag.lower())
# --- Title tokens ---
title_tokens = tokenize(file_info.get("title", ""))
@@ -537,6 +551,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())
@@ -678,7 +696,7 @@ _indexer.set_index_change_hook(_on_index_change_hook)
def init_inverted_index():
"""Force initial inverted index build. Called after build_index completes on startup."""
if any(vdata.get("files") for vdata in index.values()):
if any(vdata.get("files") for vdata in _indexer.index.values()):
_inverted_index.rebuild()
logger.info("Inverted index initialized.")
@@ -739,7 +757,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
@@ -774,7 +792,7 @@ def search(
else:
candidates = [
(vault_name, file_info)
for vault_name, vault_data in index.items()
for vault_name, vault_data in _indexer.index.items()
if vault_filter == "all" or vault_name == vault_filter
for file_info in vault_data["files"]
]
@@ -1309,6 +1327,7 @@ def advanced_search(
scored_results: list[tuple[float, dict[str, Any]]] = []
facet_tags: dict[str, int] = defaultdict(int)
facet_vaults: dict[str, int] = defaultdict(int)
facet_extensions: dict[str, int] = defaultdict(int)
# Pre-compute prefix expansions once per term (avoid repeated binary search)
prefix_expansions: dict[str, list[str]] = {}
@@ -1408,6 +1427,11 @@ def advanced_search(
facet_vaults[result["vault"]] = facet_vaults.get(result["vault"], 0) + 1
for tag in result.get("tags", []):
facet_tags[tag] = facet_tags.get(tag, 0) + 1
# Extension normalised without leading dot ("md", not ".md") so it can be
# fed back directly as the `ext:` query operator.
ext = str(result.get("extension") or "").lower().lstrip(".")
if ext:
facet_extensions[ext] = facet_extensions.get(ext, 0) + 1
total = len(scored_results)
page = scored_results[offset: offset + limit]
@@ -1421,6 +1445,7 @@ def advanced_search(
"facets": {
"tags": dict(sorted(facet_tags.items(), key=lambda x: -x[1])[:20]),
"vaults": dict(sorted(facet_vaults.items(), key=lambda x: -x[1])),
"extensions": dict(sorted(facet_extensions.items(), key=lambda x: -x[1])),
},
"query_time_ms": elapsed_ms,
"semantic_available": semantic_available,
@@ -1523,7 +1548,7 @@ def suggest_titles(
prefix: str,
vault_filter: str = "all",
limit: int = SUGGEST_LIMIT,
) -> list[dict[str, str]]:
) -> list[dict[str, Any]]:
"""Suggest file titles matching a prefix (accent-insensitive).
Args:
@@ -1532,7 +1557,7 @@ def suggest_titles(
limit: Maximum suggestions.
Returns:
List of ``{"vault", "path", "title"}`` dicts.
List of ``{"vault", "path", "title", "tags"}`` dicts.
"""
if not prefix or len(prefix) < MIN_PREFIX_LENGTH:
return []
@@ -1550,7 +1575,10 @@ def suggest_titles(
key = f"{entry['vault']}::{entry['path']}"
if key not in seen:
seen.add(key)
results.append(entry)
# Add tags from the index
entry_with_tags: dict[str, Any] = dict(entry)
entry_with_tags["tags"] = list(inv.doc_tags.get(entry["path"], set()))
results.append(entry_with_tags)
if len(results) >= limit:
return results
@@ -1603,7 +1631,7 @@ def get_all_tags(vault_filter: str | None = None) -> dict[str, int]:
Dict mapping tag names to their total occurrence count.
"""
merged: dict[str, int] = {}
for vault_name, vault_data in index.items():
for vault_name, vault_data in _indexer.index.items():
if vault_filter and vault_filter != "all" and vault_name != vault_filter:
continue
for tag, count in vault_data.get("tags", {}).items():
-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()
+833 -30
View File
@@ -16,7 +16,10 @@ 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 datetime import date, datetime
from pathlib import Path
from typing import Any
@@ -230,10 +233,128 @@ _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 A10 — types recognised when a user types into a cell. Excel infers them
# too; storing everything as text would make a spreadsheet unusable (a boolean
# column stays a string, a date column sorts lexicographically).
_XLSX_TRUE_LITERALS = {"true", "vrai", "oui", "yes"}
_XLSX_FALSE_LITERALS = {"false", "faux", "non", "no"}
# Shape check before strptime: keeps the hot path free of format attempts.
_XLSX_DATE_RE = re.compile(r"^\d{1,2}[-/]\d{1,2}[-/]\d{4}(?:[ T]\d{1,2}:\d{2})?$")
# #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 _invalidate_meta(file_path: Path) -> None:
"""Drop the cached workbook metadata after a write (#156-A13).
Imported lazily so the module stays importable when the spreadsheet reader
is not needed (it pulls openpyxl in).
"""
try:
from backend.xlsx_reader import invalidate_workbook_meta
invalidate_workbook_meta(file_path)
except Exception: # pragma: no cover - cache invalidation is best-effort
logger.debug("workbook meta invalidation skipped", exc_info=True)
def file_revision(file_path: Path) -> str:
"""Opaque revision token of a file — ``mtime_ns:size`` in hex (#156-A12).
Cheap by design (one ``stat``) and enough for optimistic concurrency: any
writer that replaces the file changes at least one of the two fields. The
viewer reads it with the file and sends it back as the ``if_match`` of a
write, so an external editor (Excel, the watcher, another worker) can no
longer be silently overwritten.
Returns:
The token, or ``""`` when the file cannot be stat'ed.
"""
try:
st = file_path.stat()
except OSError:
return ""
return f"{st.st_mtime_ns:x}-{st.st_size:x}"
def _check_revision(file_path: Path, expected: str | None) -> None:
"""Refuse a write on a file that changed since it was read (#156-A12).
``expected`` comes from the read payload (``if_match``): when it is absent
the write keeps its pre-A12 behaviour (last writer wins), so curl, the AI
tools and the batch uploader are unaffected.
Raises:
ServiceError: ``conflict`` (409, ``reason=stale_revision``) when the
file on disk is not the revision the caller read.
"""
if not expected:
return
current = file_revision(file_path)
if current and current != expected:
raise ServiceError(
"The file changed on disk since it was read; reload before saving",
code="conflict",
status=409,
details={
"reason": "stale_revision",
"path": str(file_path),
"expected_revision": expected,
"current_revision": current,
},
)
def _coerce_xlsx_value(value: Any) -> Any:
"""Turn the string sent by the cell editor back into a scalar."""
"""Turn the string sent by the cell editor back into a scalar (#153 A10).
The coercion is symmetric with :func:`backend.xlsx_reader._fmt`: a value
typed by the user comes back as a string, and Excel would have inferred a
type when typing the same thing. Recognised here:
* an empty cell -> ``None`` (clears it)
* ``1234`` / ``-1`` -> ``int``
* ``1.5`` / ``.5`` -> ``float``
* ``TRUE``/``FAUX`` (case-insensitive) -> ``bool``
* ``31/12/2026`` / ``31/12/2026 14:30`` -> ``date``/``datetime`` (FR)
Anything else stays text. A date-looking string typed with a leading
``=`` is a formula and never reaches here as a date.
"""
if not isinstance(value, str):
return value
text = value.strip()
@@ -243,9 +364,48 @@ def _coerce_xlsx_value(value: Any) -> Any:
return int(text)
if _XLSX_FLOAT_RE.match(text):
return float(text)
lowered = text.lower()
if lowered in _XLSX_TRUE_LITERALS:
return True
if lowered in _XLSX_FALSE_LITERALS:
return False
if not _XLSX_FORMULA_RE.match(text):
parsed = _parse_fr_datetime(text)
if parsed is not None:
return parsed
return value
def _parse_fr_datetime(text: str) -> date | datetime | None:
"""Parse a FR-localised date/datetime, or return ``None``.
Accepts ``JJ/MM/AAAA`` and ``JJ/MM/AAAA HH:MM`` (also ``JJ-MM-AAAA``).
``dayfirst`` is what makes ``01/02/2026`` the 1st of February rather than
the 2nd of January — the French convention.
"""
if not _XLSX_DATE_RE.match(text):
return None
for fmt in ("%d/%m/%Y %H:%M", "%d/%m/%Y", "%d-%m-%Y %H:%M", "%d-%m-%Y"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None
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 +413,30 @@ def edit_xlsx_cells(
cells: dict[str, Any],
*,
backup: bool = True,
allow_formula: bool = False,
force: bool = False,
expected_revision: str | None = None,
) -> 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)
@@ -274,9 +449,9 @@ def edit_xlsx_cells(
status=404,
details={"vault": vault_name, "path": path},
)
if file_path.suffix.lower() != ".xlsx":
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
raise ServiceError(
f"Not an .xlsx file: {path}", code="invalid", status=400
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
)
if not cells:
raise ServiceError("No cells to update", code="invalid", status=400)
@@ -286,30 +461,64 @@ def edit_xlsx_cells(
f"Invalid cell reference: {ref!r}", code="invalid", status=400
)
from openpyxl import load_workbook
# #153 A16 — the lossy gate is skipped for .xlsm: the save re-serializes
# with keep_vba=True, so the macro project (the only extra part a .xlsm
# carries) survives and nothing is dropped.
if not force and file_path.suffix.lower() != ".xlsm":
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)):
# #156-A12 — inside the lock: no writer can slip in between the check
# and the load.
_check_revision(file_path, expected_revision)
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)
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
# project survives the save (the endpoint's lossy probe is empty
# for .xlsm on purpose).
try:
wb = load_workbook(file_path, keep_vba=file_path.suffix.lower() == ".xlsm")
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
# #156-A13 — the metadata cache is keyed on (mtime, size); drop it too
# so a rewrite that lands on the same tick can never serve stale maps.
_invalidate_meta(file_path)
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
return {
@@ -317,6 +526,600 @@ def edit_xlsx_cells(
"vault": vault_name,
"path": rel_path,
"size": len(cells),
"revision": file_revision(file_path),
}
def mutate_xlsx_structure(
vault_name: str,
path: str,
actions: list[dict[str, Any]],
*,
backup: bool = True,
force: bool = False,
expected_revision: str | None = None,
) -> dict[str, Any]:
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
``actions`` is an ordered list — the workbook is loaded once and every
action is applied in sequence inside the same per-file lock and the same
atomic replace, so a half-applied batch can never reach the disk:
* ``{"op": "sheet_add", "name": "X", "at": 1}`` — new sheet (at =
optional 0-based position);
* ``{"op": "sheet_rename", "from": "X", "to": "Y"}``;
* ``{"op": "sheet_delete", "name": "X"}`` — refused when it is the
last sheet (an openpyxl workbook must keep one);
* ``{"op": "sheet_duplicate", "name": "X", "as": "Y"}`` — values,
styles and merged ranges are copied (not the data-dependent objects);
* ``{"op": "row_insert"|"row_delete"|"col_insert"|"col_delete",
"sheet": "X", "at": N, "count": k}`` — 1-based position, default 1.
All of it rides the same guards as the cell edits (P0): per-file lock,
``.tmp`` + ``os.replace`` atomic write and the ``force`` gate on lossy
round-trips. The UI proposes these actions with an explicit confirmation
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
if not actions or len(actions) > 50:
raise ServiceError(
"Invalid actions (1 to 50 per request)", code="invalid", status=400
)
if not force:
from backend.xlsx_reader import inspect_workbook
lossy = inspect_workbook(file_path)
if lossy:
raise ServiceError(
"Restructuring this workbook would drop features ObsiGate "
"cannot preserve; retry with force=true after confirmation",
code="xlsx_lossy_content",
status=409,
details={"path": path, "features": lossy},
)
with _xlsx_write_lock(str(file_path)):
# #156-A12 — stale-write guard (see edit_xlsx_cells).
_check_revision(file_path, expected_revision)
from openpyxl import load_workbook
from openpyxl.worksheet.copier import WorksheetCopy
try:
wb = load_workbook(file_path)
except Exception as exc:
raise ServiceError(
f"Cannot open workbook: {exc}", code="invalid", status=400
) from exc
rel_path = _rel(root, file_path)
applied: list[str] = []
try:
for i, action in enumerate(actions):
op = action.get("op")
try:
if op == "sheet_add":
name = str(action.get("name", "")).strip()
if not name or name in wb.sheetnames:
raise ServiceError(
f"Nom de feuille invalide ou déjà pris: {name!r}",
code="invalid", status=400,
)
ws = wb.create_sheet(name[:31])
at = action.get("at")
# create_sheet appends at the end: shift left by the
# distance between the last index and the target.
if isinstance(at, int) and 0 <= at < len(wb.sheetnames):
wb.move_sheet(ws, offset=at - (len(wb.sheetnames) - 1))
applied.append(f"sheet_add:{ws.title}")
elif op == "sheet_rename":
src, dst = str(action.get("from", "")), str(action.get("to", "")).strip()
if src not in wb.sheetnames or not dst or dst in wb.sheetnames:
raise ServiceError(
f"Renommage invalide: {src!r} -> {dst!r}",
code="invalid", status=400,
)
wb[src].title = dst[:31]
applied.append(f"sheet_rename:{src}->{dst}")
elif op == "sheet_delete":
name = str(action.get("name", ""))
if name not in wb.sheetnames:
raise ServiceError(
f"Feuille introuvable: {name}", code="invalid", status=400
)
if len(wb.sheetnames) <= 1:
raise ServiceError(
"Impossible de supprimer la dernière feuille",
code="invalid", status=400,
)
del wb[name]
applied.append(f"sheet_delete:{name}")
elif op == "sheet_duplicate":
name = str(action.get("name", ""))
new_name = str(action.get("as", "")).strip()
if name not in wb.sheetnames or not new_name or new_name in wb.sheetnames:
raise ServiceError(
f"Duplication invalide: {name!r} -> {new_name!r}",
code="invalid", status=400,
)
# WorksheetCopy is the documented dup path (openpyxl
# 3.1); it copies values, styles and merges — not
# charts/images, which openpyxl itself cannot clone.
copy = wb.create_sheet(new_name[:31])
WorksheetCopy(wb[name], copy).copy_worksheet()
applied.append(f"sheet_duplicate:{name}->{copy.title}")
elif op in ("row_insert", "row_delete", "col_insert", "col_delete"):
sheet = str(action.get("sheet", ""))
if sheet not in wb.sheetnames:
raise ServiceError(
f"Feuille introuvable: {sheet}", code="invalid", status=400
)
ws = wb[sheet]
at = action.get("at", 1)
count = action.get("count", 1)
if not isinstance(at, int) or at < 1 or not isinstance(count, int) or count < 1:
raise ServiceError(
"Position 'at' / 'count' invalides", code="invalid", status=400
)
if op == "row_insert":
ws.insert_rows(at, count)
elif op == "row_delete":
ws.delete_rows(at, count)
elif op == "col_insert":
ws.insert_cols(at, count)
else:
ws.delete_cols(at, count)
applied.append(f"{op}:{sheet}@{at}x{count}")
else:
raise ServiceError(
f"Action inconnue: {op!r}", code="invalid", status=400
)
except ServiceError:
raise
except Exception as exc:
raise ServiceError(
f"Action {i + 1} ({op}) a échoué: {exc}",
code="invalid", status=400,
) from exc
except ServiceError:
wb.close()
raise
if backup:
create_backup(file_path, vault_name, rel_path)
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)
wb.close()
raise
wb.close()
_invalidate_meta(file_path)
logger.info(
f"XLSX structure: {vault_name}/{rel_path} {applied}"
)
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"applied": applied,
"revision": file_revision(file_path),
}
# #156-A8 — write-side formatting. Every value is data-driven (the colours
# come from the caller, never from a hardcoded palette) and the whole batch
# rides the same lock, backup and atomic swap as the cell edits.
_STYLE_KEYS = {
"bold",
"italic",
"underline",
"font_color",
"fill_color",
"align",
"number_format",
}
_STYLE_ALIGNS = {"left", "center", "right"}
MAX_STYLE_CELLS = 10_000
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
def _style_color(value: Any) -> str | None:
"""Normalise ``#rrggbb`` to openpyxl's ARGB, or ``None`` to clear."""
if value is None or value == "":
return None
if not isinstance(value, str) or not _HEX_COLOR_RE.match(value.strip()):
raise ServiceError(
f"Couleur invalide: {value!r}", code="invalid", status=400
)
return "FF" + value.strip().lstrip("#").upper()
def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
"""Apply the data-driven style fragment of one ``cell`` operation.
Only the keys listed in :data:`_STYLE_KEYS` are accepted — an unknown one
is a client bug, not something to ignore silently. Font attributes are
copied before mutation so the shared style of the other cells is left
untouched.
"""
import copy as copy_mod
from openpyxl.styles import Alignment, Color, PatternFill
unknown = set(style) - _STYLE_KEYS
if unknown:
raise ServiceError(
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
)
if {"bold", "italic", "underline", "font_color"} & set(style):
font = copy_mod.copy(cell.font)
if "bold" in style:
font.bold = bool(style["bold"])
if "italic" in style:
font.italic = bool(style["italic"])
if "underline" in style:
font.underline = "single" if style["underline"] else None
if "font_color" in style:
rgb = _style_color(style["font_color"])
font.color = Color(rgb=rgb) if rgb else None
cell.font = font
if "fill_color" in style:
rgb = _style_color(style["fill_color"])
cell.fill = (
PatternFill(start_color=rgb, end_color=rgb, fill_type="solid")
if rgb
else PatternFill(fill_type=None)
)
if "align" in style:
align = style["align"]
if align not in _STYLE_ALIGNS:
raise ServiceError(
f"Alignement invalide: {align!r}", code="invalid", status=400
)
current = cell.alignment
cell.alignment = Alignment(
horizontal=align,
vertical=getattr(current, "vertical", None),
wrap_text=getattr(current, "wrap_text", None),
)
if "number_format" in style:
fmt = style["number_format"] or "General"
if not isinstance(fmt, str) or len(fmt) > 120:
raise ServiceError(
"Format de nombre invalide", code="invalid", status=400
)
cell.number_format = fmt
def mutate_xlsx_style(
vault_name: str,
path: str,
ops: list[dict[str, Any]],
*,
backup: bool = True,
force: bool = False,
expected_revision: str | None = None,
) -> dict[str, Any]:
"""Write formatting on an ``.xlsx``/``.xlsm`` workbook (#156-A8).
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color``
(``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and
``number_format`` ;
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
Same guards as the cell edits: per-file lock, ``.bak`` backup,
``.tmp`` + ``os.replace`` atomic swap, lossy-write 409 and the optional
``expected_revision`` concurrency check (#156-A12).
"""
from openpyxl.utils import get_column_letter
from openpyxl.utils.cell import range_boundaries
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
raise ServiceError(
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
)
if not ops or len(ops) > 50:
raise ServiceError(
"Invalid ops (1 to 50 per request)", code="invalid", status=400
)
if not force and file_path.suffix.lower() != ".xlsm":
from backend.xlsx_reader import inspect_workbook
lossy = inspect_workbook(file_path)
if lossy:
raise ServiceError(
"Restyling 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},
)
def _range(ref: str) -> tuple[int, int, int, int]:
try:
min_col, min_row, max_col, max_row = range_boundaries(str(ref).upper())
except Exception as exc:
raise ServiceError(
f"Plage invalide: {ref!r}", code="invalid", status=400
) from exc
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_STYLE_CELLS:
raise ServiceError(
f"Plage trop grande (max {MAX_STYLE_CELLS} cellules)",
code="invalid",
status=400,
)
return min_col, min_row, max_col, max_row
def _sheet(wb: Any, action: dict[str, Any]) -> Any:
name = str(action.get("sheet", ""))
if name not in wb.sheetnames:
raise ServiceError(
f"Feuille introuvable: {name}",
code="invalid",
status=400,
details={"sheets": wb.sheetnames},
)
return wb[name]
applied: list[str] = []
with _xlsx_write_lock(str(file_path)):
_check_revision(file_path, expected_revision)
from openpyxl import load_workbook
try:
wb = load_workbook(
file_path, keep_vba=file_path.suffix.lower() == ".xlsm"
)
except Exception as exc:
raise ServiceError(
f"Cannot open workbook: {exc}", code="invalid", status=400
) from exc
rel_path = _rel(root, file_path)
try:
for i, action in enumerate(ops):
if not isinstance(action, dict):
raise ServiceError(
f"Action {i + 1} invalide", code="invalid", status=400
)
op = action.get("op")
try:
if op == "cell":
ws = _sheet(wb, action)
ref = str(action.get("range") or action.get("cell") or "")
min_col, min_row, max_col, max_row = _range(ref)
style = action.get("style") or {}
if not isinstance(style, dict) or not style:
raise ServiceError(
"Style vide", code="invalid", status=400
)
for row in ws.iter_rows(
min_row=min_row,
max_row=max_row,
min_col=min_col,
max_col=max_col,
):
for cell in row:
_apply_cell_style(cell, style)
applied.append(
f"cell:{ws.title}!{ref}:{','.join(sorted(style))}"
)
elif op in ("merge", "unmerge"):
ws = _sheet(wb, action)
ref = str(action.get("range", ""))
_range(ref)
if op == "merge":
ws.merge_cells(ref)
else:
ws.unmerge_cells(ref)
applied.append(f"{op}:{ws.title}!{ref}")
elif op == "col_width":
ws = _sheet(wb, action)
col = action.get("col")
width = action.get("width")
if isinstance(col, int):
col = get_column_letter(col)
if not isinstance(col, str) or not col.strip():
raise ServiceError(
"Colonne invalide", code="invalid", status=400
)
if not isinstance(width, (int, float)) or not 0 <= float(width) <= 255:
raise ServiceError(
"Largeur invalide (0 à 255)", code="invalid", status=400
)
ws.column_dimensions[col.strip().upper()].width = float(width)
applied.append(f"col_width:{ws.title}!{col}={width}")
elif op == "row_height":
ws = _sheet(wb, action)
row = action.get("row")
height = action.get("height")
if not isinstance(row, int) or row < 1:
raise ServiceError("Ligne invalide", code="invalid", status=400)
if not isinstance(height, (int, float)) or not 0 <= float(height) <= 409:
raise ServiceError(
"Hauteur invalide (0 à 409)", code="invalid", status=400
)
ws.row_dimensions[row].height = float(height)
applied.append(f"row_height:{ws.title}!{row}={height}")
elif op == "freeze":
ws = _sheet(wb, action)
cell_ref = str(action.get("cell", ""))
if cell_ref:
_range(cell_ref)
ws.freeze_panes = cell_ref.upper() or None
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
else:
raise ServiceError(
f"Action inconnue: {op!r}", code="invalid", status=400
)
except ServiceError:
raise
except Exception as exc:
raise ServiceError(
f"Action {i + 1} ({op}) a échoué: {exc}",
code="invalid",
status=400,
) from exc
except ServiceError:
wb.close()
raise
if backup:
create_backup(file_path, vault_name, rel_path)
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)
wb.close()
raise
wb.close()
_invalidate_meta(file_path)
logger.info(f"XLSX style: {vault_name}/{rel_path} {applied}")
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"applied": applied,
"revision": file_revision(file_path),
}
def save_csv_cells(
vault_name: str,
path: str,
cells: dict[str, Any],
*,
backup: bool = True,
expected_revision: str | None = None,
) -> dict[str, Any]:
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
The file is re-parsed, patched and re-serialized with :mod:`csv` so
quoting follows RFC 4180. BUG-098 — the delimiter is **sniffed** and the
same one is reused on write-back: a `;`-separated French CSV used to be
parsed as a single column and rewritten with `,`. References beyond the
current extent grow the grid (missing rows/cells are filled with empty
strings). Values are stored as text: a CSV has no formula engine, so any
string — including ones starting with ``=`` — is written verbatim (the
render escapes it).
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
(409, concurrent write) or ``invalid`` (400) for a bad reference.
"""
import csv as csv_mod
import io as io_mod
from backend.xlsx_reader import sniff_csv_delimiter
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
if file_path.suffix.lower() != ".csv":
raise ServiceError(f"Not a .csv file: {path}", code="invalid", status=400)
if not cells:
raise ServiceError("No cells to update", code="invalid", status=400)
for ref in cells:
if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref):
raise ServiceError(
f"Invalid cell reference: {ref!r}", code="invalid", status=400
)
# #156-A12 — same stale-write guard as the workbook path.
_check_revision(file_path, expected_revision)
raw = file_path.read_text(encoding="utf-8", errors="replace")
# BUG-098 — parse AND rewrite with the file's own delimiter.
delimiter = sniff_csv_delimiter(raw)
try:
rows = list(csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter))
except csv_mod.Error:
rows = [[line] for line in raw.splitlines()]
def _col_num(ref: str) -> int:
letters = ref.rstrip("0123456789").upper()
n = 0
for ch in letters:
n = n * 26 + (ord(ch) - ord("A") + 1)
return n
def _row_num(ref: str) -> int:
return int(ref[len(ref.rstrip("0123456789")):])
for ref, value in cells.items():
r, c = _row_num(ref), _col_num(ref)
while len(rows) < r:
rows.append([])
row = rows[r - 1]
while len(row) < c:
row.append("")
row[c - 1] = "" if value is None else str(value)
rel_path = _rel(root, file_path)
if backup:
create_backup(file_path, vault_name, rel_path)
buf = io_mod.StringIO()
csv_mod.writer(buf, delimiter=delimiter, lineterminator="\n").writerows(rows)
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
try:
tmp_path.write_text(buf.getvalue(), encoding="utf-8")
os.replace(tmp_path, file_path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.info(f"CSV cells saved: {vault_name}/{rel_path} +{len(cells)}")
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"size": len(cells),
"revision": file_revision(file_path),
}
+1
View File
@@ -13,6 +13,7 @@ from backend.tools import connected as _connected # noqa: F401 (registers conn
from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler)
from backend.tools import documents as _documents # noqa: F401 (registers document tools)
from backend.tools import service as _service # noqa: F401 (registers tools)
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
from backend.tools import web as _web # noqa: F401 (registers web tools)
from backend.tools.context import (
ToolConfirmationRequired,
+4 -2
View File
@@ -18,7 +18,7 @@ import csv as csv_lib
import io
import logging
import re
from typing import Any
from typing import Any, cast
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
from xml.sax import saxutils # nosec B406
@@ -173,7 +173,9 @@ def _render_markdown_pdf(content: str, title: str) -> bytes | None:
escape=False,
plugins=["table", "strikethrough", "footnotes", "task_lists"],
)
html = renderer(content)
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le
# renderer HTML renvoie toujours `str` à l'exécution).
html = cast(str, renderer(content))
return generate_pdf(build_pdf_html(html, title), title)
except Exception as e:
# WeasyPrint loads GTK lazily: a missing native library can surface at
+7
View File
@@ -52,6 +52,13 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
"git_search_issues": ("git_issues", "query"),
"git_get_file": ("git_file", "path"),
"create_xlsx": ("xlsx_create", "path"),
"list_xlsx_sheets": ("xlsx_sheets", "path"),
"xlsx_to_markdown": ("xlsx_read", "path"),
"update_xlsx_cells": ("xlsx_update", "path"),
"append_xlsx_rows": ("xlsx_append", "path"),
"search_workbook": ("xlsx_search", "query"),
"analyze_range": ("xlsx_analyze", "path"),
"edit_xlsx_structure": ("xlsx_structure", "path"),
"create_docx": ("docx_create", "path"),
"create_csv": ("csv_create", "path"),
"create_pdf": ("pdf_create", "path"),
+104
View File
@@ -315,6 +315,110 @@ class DocxInput(BaseModel):
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
class ListXlsxSheetsInput(BaseModel):
"""List the sheets of an existing .xlsx workbook (#153 A6)."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative path of the .xlsx file")
class XlsxToMarkdownInput(BaseModel):
"""Read one sheet of an existing .xlsx workbook as markdown (#153 A6)."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative path of the .xlsx file")
sheet: str = Field(
"", description="Sheet name (empty = the first/active sheet)"
)
class SearchWorkbookInput(BaseModel):
"""Find a text across the sheets of a spreadsheet (#156 A14)."""
vault: str = Field(..., description="Vault name")
path: str = Field(
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
)
query: str = Field(..., description="Text to look for")
sheet: str = Field("", description="Restrict to one sheet (empty = all sheets)")
case_sensitive: bool = Field(False, description="Match case")
class AnalyzeRangeInput(BaseModel):
"""Aggregate the values of an A1 range (#156 A14)."""
vault: str = Field(..., description="Vault name")
path: str = Field(
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
)
sheet: str = Field(
"", description="Sheet name (empty = the first/active sheet)"
)
range: str = Field(
"",
description="A1 range to analyse (e.g. 'B2:B50'); empty = the whole sheet",
)
class UpdateXlsxCellsInput(BaseModel):
"""Batch-edit cells of an existing .xlsx workbook (#153 A6)."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative path of the .xlsx file")
sheet: str = Field(
"", description="Worksheet title to edit (ignored for a .csv)"
)
cells: dict[str, str | int | float | bool | None] = Field(
..., description="A1 reference -> new value (max 500 per call)"
)
allow_formula: bool = Field(
False,
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
)
force: bool = Field(
False,
description="Write even when features openpyxl cannot rewrite would be dropped",
)
class AppendXlsxRowsInput(BaseModel):
"""Append rows at the end of a sheet of an existing .xlsx (#153 A6)."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative path of the .xlsx file")
sheet: str = Field(..., description="Worksheet title to extend")
rows: list[list[str | int | float | bool | None]] = Field(
..., description="Rows of cell values, appended below the last used row (max 500)"
)
allow_formula: bool = Field(
False,
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
)
force: bool = Field(
False,
description="Write even when features openpyxl cannot rewrite would be dropped",
)
class EditXlsxStructureInput(BaseModel):
"""Structural CRUD on an existing workbook (#156 A14)."""
vault: str = Field(..., description="Vault name")
path: str = Field(..., description="Vault-relative path of the .xlsx/.xlsm file")
actions: list[dict[str, Any]] = Field(
...,
description=(
"Ordered structural actions (1-50): sheet_add/sheet_rename/"
"sheet_duplicate/sheet_delete, row_insert/row_delete/"
"col_insert/col_delete"
),
)
force: bool = Field(
False,
description="Write even when features openpyxl cannot rewrite would be dropped",
)
class CsvInput(BaseModel):
"""Create a .csv file in a vault from rows of cells."""
+573
View File
@@ -0,0 +1,573 @@
"""Spreadsheet tools (#153 A6, #156 A14) — read and mutate existing workbooks.
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
file; here the assistant can read and edit one that already exists). The same
three formats the viewer edits are supported — ``.xlsx``, ``.xlsm`` and
``.csv`` (#156-A14 used to be ``.xlsx`` only, which made a workbook the UI
edits invisible to the assistant):
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
* ``search_workbook`` — READ, find text across every sheet (#156-A14);
* ``analyze_range`` — READ, aggregate stats over an A1 range (#156-A14);
* ``update_xlsx_cells`` — WRITE, batch cell edits (guarded service);
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet;
* ``edit_xlsx_structure`` — WRITE, structural CRUD (sheets/rows/columns).
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`
(or ``save_csv_cells`` / ``mutate_xlsx_structure``), which already carry the
#153 P0 guards: per-file lock, atomic replace, formula neutralisation
(``allow_formula`` opt-in) and the lossy-write 409. Every write is a WRITE-risk
tool, so the registry keeps asking for an explicit confirmation.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
from backend.services.vaults import get_vault_root
from backend.tools.context import ToolContext, ToolError, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import (
AnalyzeRangeInput,
AppendXlsxRowsInput,
EditXlsxStructureInput,
ListXlsxSheetsInput,
SearchWorkbookInput,
UpdateXlsxCellsInput,
XlsxToMarkdownInput,
)
logger = logging.getLogger("obsigate.tools.spreadsheets")
# xlsx_to_markdown ceiling: a workbook is a data dump, not prose. The table is
# for the LLM context, so both axes are bounded (same spirit as A5's index cap).
MAX_MD_ROWS = 100
MAX_MD_COLS = 20
MAX_MD_CHARS = 20_000
# #156-A14 — the newer read tools scan more than the markdown table (a search
# or an aggregate must not stop at row 100) but stay bounded all the same:
# a runaway scan would load a whole ledger into the model's context.
MAX_SCAN_ROWS = 5_000
MAX_SCAN_COLS = 100
MAX_SEARCH_RESULTS = 100
MAX_RANGE_CELLS = 10_000
MAX_RANGE_VALUES = 200
# The formats the spreadsheet editor (and now the assistant) can handle.
SPREADSHEET_EXTENSIONS = (".xlsx", ".xlsm", ".csv")
_NUMBER_RE = re.compile(r"^-?\d+(?:[.,]\d+)?$")
def _spreadsheet_path(vault: str, path: str) -> Path:
"""Resolve and validate a vault-relative ``.xlsx``/``.xlsm``/``.csv`` path."""
path = (path or "").strip()
if not path.lower().endswith(SPREADSHEET_EXTENSIONS):
raise ToolError(
"Extension attendue : .xlsx, .xlsm ou .csv", code="invalid_arguments"
)
try:
root = get_vault_root(vault)
except ServiceError as e:
raise ToolError(e.message, code=e.code, details=e.details) from e
return resolve_safe_path(root, path)
def _is_csv(file_path: Path) -> bool:
return file_path.suffix.lower() == ".csv"
def _map_service_error(e: ServiceError) -> ToolError:
return ToolError(e.message, code=e.code, details=e.details)
def _sheet_titles(file_path: Path) -> list[str]:
"""Sheet names of a workbook; a CSV has a single, unnamed “sheet”."""
if _is_csv(file_path):
return [""]
from openpyxl import load_workbook
wb = load_workbook(str(file_path), read_only=True, data_only=True)
try:
return list(wb.sheetnames)
finally:
wb.close()
def _read_grid(
file_path: Path, sheet: str, max_rows: int, max_cols: int
) -> tuple[str, list[list[str]], bool]:
"""Read one sheet (or a CSV) as bounded, formatted, trimmed rows.
Returns ``(title, rows, truncated)``; ``truncated`` is True when real data
sits just beyond the row cap (probed one row further) so the caller can say
so instead of silently dropping it.
"""
from openpyxl import load_workbook
from backend.xlsx_reader import _fmt
if _is_csv(file_path):
import csv as csv_mod
import io as io_mod
from backend.xlsx_reader import sniff_csv_delimiter
stem = file_path.stem
if sheet and sheet != stem:
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
raw = file_path.read_text(encoding="utf-8-sig", errors="replace")
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=sniff_csv_delimiter(raw))
grid: list[list[str]] = []
truncated = False
for i, row in enumerate(reader):
if i >= max_rows:
truncated = any(str(c).strip() for c in row)
break
grid.append([str(c) for c in row][:max_cols])
while grid and not any(c.strip() for c in grid[-1]):
grid.pop()
return stem, grid, truncated
try:
wb = load_workbook(str(file_path), read_only=True, data_only=True)
except ServiceError as e:
raise _map_service_error(e) from e
except Exception as e:
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
try:
if sheet:
if sheet not in wb.sheetnames:
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
ws = wb[sheet]
else:
ws = wb.active
title = ws.title
grid = []
for row in ws.iter_rows(
min_row=1, max_row=max_rows, max_col=max_cols, values_only=True
):
grid.append([_fmt(v) for v in row])
probe = list(
ws.iter_rows(
min_row=max_rows + 1,
max_row=max_rows + 1,
max_col=max_cols,
values_only=True,
)
)
truncated = any(any(str(v or "").strip() for v in r) for r in probe)
finally:
wb.close()
while grid and not any(c.strip() for c in grid[-1]):
grid.pop()
return title, grid, truncated
def _to_number(text: str) -> float | None:
"""Coerce a displayed cell to a float, or ``None`` when it is not one."""
t = text.strip().replace("\u00a0", "").replace(" ", "")
if not _NUMBER_RE.match(t):
return None
try:
return float(t.replace(",", "."))
except ValueError: # pragma: no cover - regex already guarantees the shape
return None
@tool(
name="list_xlsx_sheets",
description=(
"List the sheets of a spreadsheet (.xlsx, .xlsm or .csv) with their "
"dimensions (rows x columns) and whether the display caps truncate "
"them. Use before editing to pick the right sheet name."
),
input_model=ListXlsxSheetsInput,
risk=ToolRisk.READ,
requires_vault=True,
)
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
"""Return sheet names and extents of the workbook (or of the CSV)."""
from backend.xlsx_reader import MAX_COLS, MAX_ROWS
file_path = _spreadsheet_path(params.vault, params.path)
if not file_path.exists() or not file_path.is_file():
raise ToolError(f"Fichier introuvable: {params.path}", code="not_found")
extents: list[tuple[str, int, int]] = []
if _is_csv(file_path):
_, rows, _ = _read_grid(file_path, "", MAX_SCAN_ROWS, MAX_SCAN_COLS)
extents.append(
(
file_path.stem,
len(rows),
max((len(r) for r in rows), default=0),
)
)
else:
# Declared dimensions are enough here (and far cheaper than scanning
# every row): the caller just needs a size to decide what to read.
from openpyxl import load_workbook
from backend.xlsx_reader import _sheet_extent
try:
wb = load_workbook(str(file_path), read_only=True, data_only=True)
except ServiceError as e:
raise _map_service_error(e) from e
except Exception as e:
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
try:
extents = [
(ws.title, *_sheet_extent(ws)) for ws in wb.worksheets
]
finally:
wb.close()
sheets = [
{
"name": name,
"total_rows": total_rows,
"total_cols": total_cols,
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
}
for name, total_rows, total_cols in extents
]
return {"vault": params.vault, "path": params.path, "sheets": sheets}
@tool(
name="xlsx_to_markdown",
description=(
"Read a sheet of a spreadsheet (.xlsx, .xlsm or .csv) as a bounded "
"markdown table (up to 100 rows x 20 columns). Use to inspect "
"spreadsheet data before answering or editing."
),
input_model=XlsxToMarkdownInput,
risk=ToolRisk.READ,
requires_vault=True,
)
def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str, Any]:
"""Render one sheet as a markdown table for the LLM context."""
file_path = _spreadsheet_path(params.vault, params.path)
title, rows, truncated = _read_grid(file_path, params.sheet, MAX_MD_ROWS, MAX_MD_COLS)
lines: list[str] = []
if rows:
header = rows[0]
lines.append("| " + " | ".join(header) + " |")
lines.append("|" + "|".join("---" for _ in header) + "|")
for row in rows[1:]:
lines.append("| " + " | ".join(row) + " |")
table = "\n".join(lines)[:MAX_MD_CHARS]
return {
"vault": params.vault,
"path": params.path,
"sheet": title,
"rows": len(rows),
"cols": max((len(r) for r in rows), default=0),
"truncated": truncated,
"markdown": table,
}
@tool(
name="search_workbook",
description=(
"Search a text across every sheet of a spreadsheet (.xlsx, .xlsm or "
".csv) and return the matching cells with their sheet and A1 "
"reference (max 100 matches). Use it to find where a value lives "
"without dumping whole sheets into the context."
),
input_model=SearchWorkbookInput,
risk=ToolRisk.READ,
requires_vault=True,
)
def search_workbook(ctx: ToolContext, params: SearchWorkbookInput) -> dict[str, Any]:
"""Find a needle across all sheets, bounded and counted per sheet."""
from openpyxl.utils import get_column_letter
file_path = _spreadsheet_path(params.vault, params.path)
needle = (params.query or "").strip()
if not needle:
raise ToolError("Requête vide", code="invalid_arguments")
titles = _sheet_titles(file_path)
if params.sheet:
if params.sheet not in titles:
raise ToolError(f"Feuille introuvable: {params.sheet}", code="not_found")
titles = [params.sheet]
hay = needle if params.case_sensitive else needle.lower()
matches: list[dict[str, Any]] = []
by_sheet: dict[str, int] = {}
total = 0
for title in titles:
sheet_title, rows, _ = _read_grid(
file_path, title, MAX_SCAN_ROWS, MAX_SCAN_COLS
)
label = sheet_title or file_path.stem
for r_i, row in enumerate(rows, start=1):
for c_i, value in enumerate(row, start=1):
if not value:
continue
haystack = value if params.case_sensitive else value.lower()
if hay not in haystack:
continue
total += 1
by_sheet[label] = by_sheet.get(label, 0) + 1
if len(matches) < MAX_SEARCH_RESULTS:
matches.append(
{
"sheet": label,
"cell": f"{get_column_letter(c_i)}{r_i}",
"value": value,
}
)
return {
"vault": params.vault,
"path": params.path,
"query": needle,
"total": total,
"truncated": total > MAX_SEARCH_RESULTS,
"by_sheet": by_sheet,
"matches": matches,
}
@tool(
name="analyze_range",
description=(
"Aggregate an A1 range of a sheet (.xlsx, .xlsm or .csv): count, sum, "
"mean, min and max of the numeric cells, plus a bounded sample of the "
"values. Use it to answer a question about a column without reading "
"the whole sheet."
),
input_model=AnalyzeRangeInput,
risk=ToolRisk.READ,
requires_vault=True,
)
def analyze_range(ctx: ToolContext, params: AnalyzeRangeInput) -> dict[str, Any]:
"""Numeric aggregates + value sample over an A1 range of the sheet."""
from openpyxl.utils.cell import range_boundaries
file_path = _spreadsheet_path(params.vault, params.path)
title, rows, _ = _read_grid(file_path, params.sheet, MAX_SCAN_ROWS, MAX_SCAN_COLS)
label = (params.range or "").strip()
if label:
try:
min_col, min_row, max_col, max_row = range_boundaries(label.upper())
except Exception as e:
raise ToolError(f"Plage invalide: {label}", code="invalid_arguments") from e
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_RANGE_CELLS:
raise ToolError(
f"Plage trop grande (max {MAX_RANGE_CELLS} cellules)",
code="invalid_arguments",
)
selected = [row[min_col - 1 : max_col] for row in rows[min_row - 1 : max_row]]
else:
selected = rows
values = [v for row in selected for v in row if isinstance(v, str) and v.strip()]
numbers = [n for n in (_to_number(v) for v in values) if n is not None]
stats: dict[str, Any] = {"count": len(numbers)}
if numbers:
stats.update(
{
"sum": round(sum(numbers), 6),
"mean": round(sum(numbers) / len(numbers), 6),
"min": min(numbers),
"max": max(numbers),
}
)
return {
"vault": params.vault,
"path": params.path,
"sheet": title,
"range": params.range or "",
"rows": len(selected),
"cols": max((len(r) for r in selected), default=0),
"cells": len(values),
"numeric": stats,
"values": values[:MAX_RANGE_VALUES],
"truncated": len(values) > MAX_RANGE_VALUES,
}
@tool(
name="update_xlsx_cells",
description=(
"Edit cells of an existing spreadsheet (.xlsx, .xlsm or .csv). "
"``cells`` maps A1 references to new values (max 500). A value "
"starting with '=' or '@' is stored as TEXT unless allow_formula is "
"set (DDE guard). Editing a workbook carrying features openpyxl "
"cannot rewrite requires force=true (cached formula results, "
"slicers…). A .csv has no sheet: any ``sheet`` value is ignored."
),
input_model=UpdateXlsxCellsInput,
risk=ToolRisk.WRITE,
requires_vault=True,
)
def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[str, Any]:
"""Wrap the guarded cell-edit service (``.xlsx``/``.xlsm`` or ``.csv``)."""
from backend.services.mutations import edit_xlsx_cells, save_csv_cells
file_path = _spreadsheet_path(params.vault, params.path)
if not params.cells:
raise ToolError("Aucune cellule fournie", code="invalid_arguments")
if not _is_csv(file_path) and not params.sheet:
raise ToolError("Feuille requise pour un classeur", code="invalid_arguments")
try:
if _is_csv(file_path):
result = save_csv_cells(params.vault, params.path, dict(params.cells))
else:
result = edit_xlsx_cells(
params.vault,
params.path,
params.sheet,
dict(params.cells),
allow_formula=params.allow_formula,
force=params.force,
)
except ServiceError as e:
raise _map_service_error(e) from e
return {
"status": "ok",
"vault": result["vault"],
"path": result["path"],
"sheet": params.sheet,
"cells": len(params.cells),
}
@tool(
name="append_xlsx_rows",
description=(
"Append rows at the end of a sheet of an existing spreadsheet "
"(.xlsx or .xlsm). Values are typed like in the viewer (numbers, "
"TRUE/FALSE, FR dates JJ/MM/AAAA). The workbook is rewritten "
"atomically with a backup. Not available for .csv."
),
input_model=AppendXlsxRowsInput,
risk=ToolRisk.WRITE,
requires_vault=True,
)
def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str, Any]:
"""Append whole rows below the last used row of the sheet."""
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
from backend.services.mutations import _coerce_xlsx_value, edit_xlsx_cells
if not params.rows:
raise ToolError("Aucune ligne fournie", code="invalid_arguments")
if len(params.rows) > 500:
raise ToolError("Trop de lignes (max 500)", code="invalid_arguments")
file_path = _spreadsheet_path(params.vault, params.path)
if _is_csv(file_path):
raise ToolError(
"Un .csv n'a pas de notion de fin de feuille : utilisez "
"update_xlsx_cells avec des références A1",
code="invalid_arguments",
)
try:
wb = load_workbook(str(file_path), read_only=True, data_only=True)
try:
if params.sheet not in wb.sheetnames:
raise ToolError(
f"Feuille introuvable: {params.sheet}", code="not_found"
)
ws = wb[params.sheet]
first_free = (ws.max_row or 0) + 1
finally:
wb.close()
except ServiceError as e:
raise _map_service_error(e) from e
except ToolError:
raise
except Exception as e:
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
cells: dict[str, Any] = {}
for i, row in enumerate(params.rows):
for j, value in enumerate(row):
if value is None or (isinstance(value, str) and not value.strip()):
continue
ref = f"{get_column_letter(j + 1)}{first_free + i}"
cells[ref] = _coerce_xlsx_value(value)
if not cells:
raise ToolError("Aucune valeur fournie", code="invalid_arguments")
try:
result = edit_xlsx_cells(
params.vault,
params.path,
params.sheet,
cells,
allow_formula=params.allow_formula,
force=params.force,
)
except ServiceError as e:
raise _map_service_error(e) from e
return {
"status": "ok",
"vault": result["vault"],
"path": result["path"],
"sheet": params.sheet,
"rows": len(params.rows),
"first_row": first_free,
}
@tool(
name="edit_xlsx_structure",
description=(
"Change the structure of an existing .xlsx/.xlsm workbook: add, "
"rename, duplicate or delete a sheet, or insert/delete rows and "
"columns. ``actions`` is an ordered list of "
'{"op": "sheet_add"|"sheet_rename"|"sheet_duplicate"|"sheet_delete"|'
'"row_insert"|"row_delete"|"col_insert"|"col_delete", …} '
"(1 to 50). Deletions drop data and cannot be undone from the "
"assistant — confirm with the user first."
),
input_model=EditXlsxStructureInput,
risk=ToolRisk.WRITE,
requires_vault=True,
)
def edit_xlsx_structure(
ctx: ToolContext, params: EditXlsxStructureInput
) -> dict[str, Any]:
"""Apply a batch of structural changes through the guarded service."""
from backend.services.mutations import mutate_xlsx_structure
if not params.actions:
raise ToolError("Aucune action fournie", code="invalid_arguments")
if len(params.actions) > 50:
raise ToolError("Trop d'actions (max 50)", code="invalid_arguments")
if str(params.path or "").lower().endswith(".csv"):
raise ToolError(
"Un .csv n'a pas de structure modifiable", code="invalid_arguments"
)
try:
result = mutate_xlsx_structure(
params.vault, params.path, [dict(a) for a in params.actions], force=params.force
)
except ServiceError as e:
raise _map_service_error(e) from e
return {
"status": "ok",
"vault": result["vault"],
"path": result["path"],
"actions": len(params.actions),
}
+970 -15
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.8"
version = "2.47.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.28.8"
version = "2.47.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.8",
"version": "2.47.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+6 -1
View File
@@ -53,7 +53,12 @@ services:
- OBSIGATE_AUTH_ENABLED=true
- OBSIGATE_ADMIN_USER=admin
# OBSIGATE_ADMIN_PASSWORD → .env
# OBSIGATE_SECURE_COOKIES=true # si derrière reverse proxy HTTPS
# OBSIGATE_SECURE_COOKIES : auto par défaut (Secure si https, sinon
# pas de flag) — forcer à true uniquement si le proxy termine TLS
# sans X-Forwarded-Proto (avec TRUST_PROXY, l'auto suffit).
# Reverse proxy devant l'app : IPs d'audit réelles (BUG-030) et
# X-Forwarded-Proto honoré pour les cookies Secure (auto).
- OBSIGATE_TRUST_PROXY=true
- OLLAMA_BASE_URL=http://ollama:11434/v1
- OLLAMA_MODEL=qwen2.5-coder:1.5b
env_file:
+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 |
+238 -5
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
@@ -19,7 +19,8 @@ avec :
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
- **Stemming français** — les variantes des mots sont rapprochées.
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
- **Facettes** — compteurs par vault et par tag sur les résultats.
- **Facettes** — compteurs par vault, par tag et par extension sur les résultats, panneau
repliable d'un clic (chevron, état mémorisé).
- **Pagination** — 50 résultats par page.
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
@@ -131,7 +132,239 @@ 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 (toujours visibles, même à
une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de
commandes regroupe les actions en sections (Formules · Insertion · Vue ·
Fichier) autour d'un bouton **Enregistrer** principal. 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).
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
### 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 dans une fenêtre intégrée au thème de
l'application. Si vous refusez, rien n'est écrit.
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
Si le classeur est modifié ailleurs entre-temps (autre poste, Excel,
synchronisation…), ObsiGate n'interrompt pas votre travail : un bandeau vous
propose de **réessayer**. Le bouton **Réessayer** relit d'abord le fichier pour
récupérer la version courante, puis rejoue l'enregistrement — vos
modifications restent en place pendant tout ce temps.
### 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`).
- `if_match` (facultatif) : la **version** du fichier attendue, telle que la
lecture la renvoie (`xlsx_revision` ou `revision`). Si le fichier a changé
depuis, l'écriture est refusée (**409** `conflict`, `details.reason =
"stale_revision"`) au lieu d'écraser le travail de l'autre écrivain ;
relisez le fichier puis renvoyez la nouvelle version. Les trois routes
d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête
`If-Match` ou le champ `if_match` et renvoient la version à jour dans
`revision`.
### Feuilles volumineuses et lecture par fenêtres
Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille. Quand
une feuille dépasse ce plafond, un bandeau **« Feuille tronquée »**
l'annonce explicitement (par exemple « 500 lignes affichées sur 520 »)
au lieu de présenter une table courte comme complète — le classeur,
lui, n'est jamais modifié. La ligne d'en-têtes de colonnes reste
visible pendant le défilement vertical.
Côté API, `GET /api/file/{vault}/xlsx/sheet` sert une feuille **par
fenêtres de lignes**, y compris au-delà du plafond d'affichage — les
coordonnées A1 renvoyées sont celles de la feuille réelle :
```bash
curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=Budget&offset=500&limit=200"
```
- `offset` : première ligne renvoyée (0-based) ; `limit` : nombre de
lignes (1 à 1 000 par requête).
- La réponse porte `total_rows`, `truncated` et `has_more` pour paginer.
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
n'est ni un `.xlsx` ni un `.xlsm`.
### Fonctions avancées
**Barre de formule, zone Nom et navigation clavier** — au-dessus du tableau, la
**zone Nom** affiche l'adresse de la cellule active (`B12`) ou de la plage
sélectionnée (`A1:B3`) et elle est **éditable** (« Atteindre ») : saisissez une
référence puis `Entrée` pour y aller (`B12`, `A1:B3`, `$A$1`, ou `Feuille2!A1`
pour changer d'onglet) ; une référence inconnue est refusée avec un message et
l'adresse précédente est restaurée. La barre de formule reflète la cellule
active et propose les **noms de fonctions** courants pendant la saisie.
`Tab`/`Maj+Tab` et les flèches circulent entre les cellules, `Maj+flèches` étend
la sélection, `Entrée` valide, `Maj+Entrée` insère un saut de ligne **dans** la
cellule, `F2` ouvre la cellule en édition, `Suppr` vide la sélection,
`Échap` restaure la valeur d'origine ; `Origine`/`Fin` vont au bord de la ligne,
`Ctrl+Origine`/`Ctrl+Fin` aux coins de la feuille affichée,
`PgPréc`/`PgSuiv` font défiler d'un écran, `Ctrl+flèches` saute au bout de la
plage de données, `Ctrl+A` sélectionne toute la feuille affichée et `Ctrl+S`
enregistre. Tant que la cellule n'est pas en cours d'édition, `Suppr`
efface la sélection plutôt qu'un caractère.
**Presse-papiers de plage** — copier, couper et coller un **bloc** de cellules
(`Ctrl+C`, `Ctrl+X`, `Ctrl+V`, ou les entrées correspondantes du menu
contextuel) : coller un bloc copié ici **ou depuis Excel** remplit la plage à
partir de la cellule active et la laisse sélectionnée. Le collage est du
**texte** (formules et valeurs recopiées telles quelles) et reste **annulable** ;
« couper » efface la source après le collage (un collage sur place ne l'efface
pas). Un bloc plus large que la grille affichée est tronqué, avec un message.
**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient
sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
**Sélection et menu contextuel** — cliquer une cellule l'active, **glisser**
ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
contenu.
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
la recherche (`Ctrl+F` du panneau) parcourt **toutes les feuilles** : le compteur
indique le nombre de feuilles concernées et passer sur une correspondance
**active l'onglet** qui la contient.
La sortie propose quatre formats, toujours sur le **contenu affiché** (et jamais
sur les valeurs calculées en cache) :
- **CSV** (bouton `CSV`) — exporte la **sélection** quand une plage de plusieurs
cellules est active (le nom du fichier reprend la plage, ex.
`Fruits-A1B2.csv`), sinon la feuille entière ;
- **Markdown** et **HTML** (menu **Exporter**) — tableau markdown ou document
HTML autonome, mêmes règles de sélection ;
- **Imprimer** (menu **Exporter**) — imprime la feuille ou la sélection seule,
sans le ruban ni les panneaux de l'application.
Rien de tout cela ne modifie le classeur.
**Structure** — le menu **Structure** de la barre d'outils ajoute,
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
automatique et confirmation, comme pour l'édition des cellules).
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
**sélection courante** (une cellule ou une plage) :
- **caractère** — gras, italique, souligné, effacer la mise en forme ;
- **alignement** — gauche, centré, droite ;
- **couleurs** — couleur de police et couleur de fond (sélecteur natif, aucune
palette imposée) ;
- **format de nombre** — général, nombre, pourcentage, devise, date, texte ;
- **structure** — fusionner / défusionner les cellules, figer / libérer les
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
plage).
L'écriture passe par `PUT …/xlsx/style`, avec les mêmes garanties que l'édition
des cellules : backup automatique, écriture atomique, confirmation si
l'opération détruirait des éléments non préservables (graphiques, valeurs
calculées en cache…) et **détection d'un écrivain externe** (`If-Match` →
message « Réessayer »). Un `.csv` ne portant pas de mise en forme, le bouton
n'y est pas proposé (il est également absent d'une grille en lecture seule).
La lecture restitue par ailleurs les couleurs, polices, cellules fusionnées et
volets figés du fichier ; l'ancrage de la zone figée est conservé au défilement.
Les **commentaires**, liens hypertexte, validation de données, mise en forme
conditionnelle et bordures restent hors périmètre.
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
**macros sont préservées** à l'enregistrement (y compris le chargement des
lignes au-delà du plafond) ; `.xls` et `.ods` s'affichent en **lecture
seule** ; un `.csv` s'ouvre dans la même grille et se réécrit conformément à
la RFC 4180 (les guillemets et séparateurs sont échappés). Le **séparateur
du CSV est détecté** (`;`, `,` ou tabulation) à la lecture et **réutilisé à
l'enregistrement** : un fichier exporté par Excel en français (point-virgule)
s'affiche donc en colonnes distinctes et le reste après édition.
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
latéral droit** (la grille reste visible à côté) qui liste les plages
nommées du classeur (nom, référence, portée), signale les feuilles
contenant des graphiques ou des tableaux croisés, et donne pour chaque
feuille un résumé (cellules, lignes, colonnes, formules, valeurs
numériques) avec quelques chiffres clés. Cliquer une **plage nommée**
sélectionne sa première cellule dans la grille, et le panneau est
**redimensionnable**. L'en-tête de l'inspecteur offre
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
plages. Ses outils couvrent désormais les **trois formats édités**
(`.xlsx`, `.xlsm`, `.csv`) : `list_xlsx_sheets` et `xlsx_to_markdown` pour lire,
`search_workbook` (recherche dans toutes les feuilles, comptée par feuille),
`analyze_range` (agrégats — nombre, somme, moyenne, min, max — d'une plage A1),
`update_xlsx_cells` et `append_xlsx_rows` pour modifier, et
`edit_xlsx_structure` pour la structure (ajouter/renommer/dupliquer/supprimer
une feuille, insérer/supprimer des lignes ou des colonnes).
### Limites
- L'affichage intégré démarre à **500 lignes × 40 colonnes** par feuille ;
sous une feuille plus grande, le bouton **« Charger la suite »** (ou le
défilement vers le bas du tableau) ajoute les lignes suivantes par
fenêtres de 500 — elles deviennent aussitôt éditables et
sauvegardables. Le chargement paresseux est **vertical uniquement** :
l'axe des colonnes reste tronqué à 40 (les colonnes au-delà ne sont ni
affichées ni exportées).
- Un **format de nombre personnalisé** (devise, pourcentage…) est signalé
par une police à chasse fixe à la lecture ; depuis le bouton **Mise en
forme**, appliquer un format ne change que le format de la cellule, **pas**
la valeur affichée (aucun recalcul n'est fait, cf. les limites d'export
ci-dessous).
- `.xls` et `.ods` restent en lecture seule (convertir vers `.xlsx` pour
éditer) ; les macros d'un `.xlsm` sont conservées mais ne s'exécutent
pas dans ObsiGate.
- La **poignée de recopie** (fill), la multi-sélection `Ctrl+clic` et le
glisser-déposer de lignes/colonnes ne sont pas proposés ; un collage de
plusieurs cellules s'annule **cellule par cellule** (`Ctrl+Z` répété).
- Les exports (CSV, Markdown, HTML, impression) reflètent ce qui est **affiché** :
une feuille tronquée s'exporte tronquée, et les formules sortent telles
qu'enregistrées (aucune valeur calculée n'est recalculée). Un classeur reste
la source de vérité : utilisez **Charger la suite** pour exporter au-delà du
plafond.
- **Aucun moteur de formule** : ObsiGate lit et écrit les formules telles
qu'Excel les a enregistrées, sans jamais les recalculer. Une saisie
commençant par `=` ou `@` est stockée comme **texte** (garde anti-DDE)
tant que le bouton `f(x)` n'est pas activé ; l'enregistrement vous le
signale par un message. Excel reste la référence pour les valeurs calculées.
- L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les
actions de structure ; en revanche une **suppression** (feuille, ligne,
colonne) n'est pas annulable, faute d'inverse.
---
## 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 +380,7 @@ Fiche technique : [`features/excalidraw.md`](../features/excalidraw.md).
---
## 7. Autres contenus riches
## 8. Autres contenus riches
### Mermaid
@@ -182,7 +415,7 @@ curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes"
---
## 8. Dépannage
## 9. Dépannage
| Symptôme | Piste |
|---|---|
+40 -5
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-29
---
@@ -188,15 +188,30 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
| *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`) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/auth/router.py::mfa_status` (l.821-831) | Auth désactivée : `curl http://127.0.0.1:2029/api/auth/mfa/status` → 500 (reproduit live 2026-09-27, `e2e-server.err.log` l.116-183) | — | `require_auth` laisse passer le pseudo-user anonymous, `get_user(username)` → None non gardé. Trouvé via les logs E2E pendant BUG-080 |
| *BUG-080* | [🔴 BLOQUANT] E2E locaux bloqués toute la nuit : `npm run test:e2e:ps` ne termine jamais (serveurs orphelins sur le port 2029, `npx playwright install` sans `--yes` ni garde-fou, suite ~130 tests sans timeout global) | 🟢 corrigé | P0 | 🧩 tests | IA | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau) | `npm run test:e2e:ps` | `run-e2e-local` : `npx --yes`, skip install Chromium si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124) ; `playwright.config.ts` : `globalTimeout` 15 min local / 30 min CI (`E2E_GLOBAL_TIMEOUT_MS`) ; `e2e-server.ps1` : pidfile = vrai owner du port, `stop` tue l'arbre complet. Tests : `tests/test_e2e_harness.py` (8/8), cycle start/stop live (pidfile cohérent, port libéré) | Constat 2026-09-27 : `e2e-server.ps1 start` OK (READY 12 s) mais run suivant pendu toute la nuit ; 2 python orphelins (PID 81180 parent + 81936 sur le port, pidfile périmé). Double processus systématique (parent `.venv` parqué + enfant qui sert — aussi sur flowdeck/3.13 : environnemental, sans impact après correctif). Trouvé au passage : BUG-081 (`/api/auth/mfa/status` → 500 auth désactivée) |
| *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 |
| *BUG-089* | Un reindex manuel ne reconstruisait pas l'index inversé : la recherche TF-IDF continuait de servir un index périmé | 🟢 corrigé | P1 | ⚙️ backend / recherche | IA | `backend/indexer.py::reload_index`, `backend/indexer.py::reload_single_vault`, `backend/search.py` | Modifier le contenu d'un fichier, puis `GET /api/index/reload` → la recherche renvoie encore l'ancien contenu (ou rien pour un fichier nouveau) | `reload_index()` / `reload_single_vault()` appellent `init_inverted_index()` après le rebuild (le remplacement wholesale d'une entrée de vault n'émet pas les notifications incrémentales). En prime, `backend/search.py` lisait l'index via `from backend.indexer import index` (liaison **par valeur** du dict) : un `importlib.reload(backend.indexer)` recréait le dict côté indexer tandis que la recherche écrivait encore dans l'ancien — l'index inversé n'indexait alors plus rien. Tous les accès passent désormais par `_indexer.index`. Contre-preuve : `TestXlsxSearchable::test_search_finds_a_word_stored_in_a_cell` échoue sans le correctif | #153 A5. Trouvé en écrivant le test de recherche d'A5 : il passait isolément et échouait en suite complète selon l'ordre. Le reload incrémental par fichier (watcher, edition) n'est pas concerné : il passe par le hook `_on_index_change`. Vérifié : suite 1402 passed / 6 skipped, ruff/mypy 0 |
| *BUG-090* | Troncature silencieuse d'une feuille `.xlsx` au-delà de 500 lignes × 40 colonnes : l'utilisateur voit une table courte sans aucun indice que la suite existe | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `backend/routers/files_read.py`, `frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css` | Ouvrir `test_vault/sample-xlsx-large.xlsx` (520 lignes) → la feuille s'arrête à la ligne 500 sans aucun message | `render_sheets()` renvoie désormais `total_rows`/`total_cols` (dimensions déclarées par la feuille), `max_rows`/`max_cols` (plafonds du moteur) et `truncated` ; la visionneuse affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 » (i18n `xlsx.truncated_*` FR/EN, axe des colonnes inclus). Contre-preuve : neutraliser `truncated` → `TestXlsxTruncationNotice` (2 tests) échoue | #153 A8/R5. La ligne d'en-têtes est aussi `sticky` au défilement vertical (`thead th { top: 0 }` + `top: auto` sur les numéros de ligne pour éviter l'empilement en haut à gauche). L'endpoint `GET …/xlsx/sheet` (#153 A9) sert les fenêtres au-delà du plafond, mais le chargement paresseux complet (défilement virtuel, « charger tout ») reste à faire — le bandeau dit la vérité en attendant. Vérifié : `test_xlsx_viewer.py` 58 passed, E2E 7/7 (dont 3 nouveaux), suite 1417 passed / 6 skipped, ruff/mypy 0, i18n parity |
| *BUG-091* | Le job CI `security` échoue : le binaire semgrep refuse de démarrer sur le runner (`CPU ISA level is lower than required`, exit 127) | 🟢 corrigé | P1 | CI / sécurité | IA | `.gitea/workflows/ci.yml` (job `security`), `backend/requirements.txt` | Run Gitea #1641 : étape « Semgrep » → `libs/libresolv.so.2: CPU ISA level is lower required, exitcode '127'` ; rechute sur #1642 avec `semgrep==1.174.0`, puis sur #1654 avec `1.157.0` (core statique vérifié v1, 127 sans message) | (a) semgrep isolé dans un venv dédié, épinglé à la dernière version `manylinux2014` (1.157.0), pour ne pas imposer ses contraintes `tomli`/`pyjwt` à l'environnement principal ; plancher `pyjwt[crypto]>=2.13.0` dans requirements.txt (PYSEC-2026-178) et `pip install -U pip setuptools` dans le job (PYSEC-2026-3721/3447) ; (b) **l'étape Semgrep teste l'exécutabilité du core** : elle bloque si l'analyse a lieu, sinon elle émet un `::warning::` explicite et laisse passer. Bandit et pip-audit restent bloquants | #153. security échouait déjà avant ce push (v2.31.0/v2.32.0 rouges) ; les commits de features v2.33.0→v2.39.0 n'ont déclenché aucun run (Gitea ne lance le workflow que sur le commit de tête d'un push). Deux hypothèses infirmées en route : « série 1.175+ incompatible » (1.157.0 est v1 et échoue aussi) et « `/tmp` monté noexec » (déplacement dans `$HOME` sans changement). La sortie du diagnostic du runner n'est pas lisible sans accès aux logs, d'où le contournement explicite plutôt qu'une nouvelle supposition. **À reprendre** sur un runner x86-64-v2, où semgrep redeviendra bloquant sans modification |
| *BUG-092* | Les tests réseau dépendent du DNS réel du runner : `test_worker_failure_maps_to_tool_error` échoue en `dns_error` au lieu d'atteindre le worker Playwright mocké, et le job CI `test` rougit de façon intermittente | 🟢 corrigé | P1 | CI / tests | IA | `tests/test_webrender.py`, `tests/test_web_tools.py` | Sur un runner au DNS instable : `pytest tests/test_webrender.py -k test_worker_failure_maps_to_tool_error` → `assert 'dns_error' == 'render_unavailable'` | Fixture `no_dns` mockant les **deux** références du garde SSRF `_assert_public_http_url` (celle de `backend/tools/web.py` et celle importée dans le namespace de `backend/tools/webrender.py`, ligne 30 — la seconde avait d'abord échappé au correctif). Les tests de garde SSRF n'utilisent pas la fixture et continuent de traverser le vrai garde | Le garde est appelé par `fetch_url` **avant** le traitement ; seule la couche httpx était mockée. Contre-preuve : DNS coupé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** |
| *BUG-093* | Le job CI `security` échoue : `pip-audit` bloque sur deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910, PYSEC-2026-3911) — et le plancher `pypdf>=4.0` ne les corrigeait pas, car l'image Act du runner embarque 6.16.0 *préinstallé* dans sa toolcache Python (`Requirement already satisfied` ⇒ jamais mis à niveau) | 🟢 corrigé | P0 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | Run Gitea #1660, job `security` : `Found 2 known vulnerabilities, ignored 2 in 1 package` → `pypdf 6.16.0 PYSEC-2026-3910 6.16.1` / `PYSEC-2026-3911 6.16.1` | Plancher `pypdf>=6.16.1` (correctif des deux advisories), commenté pour expliquer la contrainte de la toolcache. Ajout de `tests/test_ci_workflow.py::TestDependencySecurityFloors`, qui verrouille les planchers de sécurité (`pypdf`, `pyjwt`) et interdit qu'ils retombent sous le correctif | Les deux advisories sont des **consommations de ressources non contrôlées** (PDF à outlines multiples ou à nombreux XForm réutilisés) et sont donc **atteignables** par ObsiGate, dont `backend/pdf_reader.py` extrait le texte et parcourt les outlines de PDF fournis par l'utilisateur. Contre-preuve : plancher remis à `>=4.0` → le garde-fou échoue. pip-audit local : 6.16.1, 6.16.2 et 6.19.0 sans vulnérabilité connue. Correction découverte en lisant le log du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log de l'étape Semgrep collé précédemment datait d'un run antérieur |
| *BUG-094* | Feuille `.xlsx` vide ou nouvellement ajoutée : impossible d'y saisir une valeur et d'y insérer une ligne/colonne — la feuille s'affiche « Feuille vide » sans aucune cellule | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `frontend/js/viewer.js::renderXlsxViewer` | Ajouter une feuille (`PUT …/xlsx/structure` `sheet_add`) puis tenter de saisir A1 ou d'insérer une ligne/colonne | `render_sheets()` remplace une grille vide par un quadrillage vierge 20×8 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) aux vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`) | Le classeur n'était pas en cause : seule la **représentation HTML** était vide, donc aucun `td` à sélectionner → aucune cellule active → aucune action de structure possible. Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52 |
| *BUG-095* | Le job CI `security` échoue : `pip-audit` bloque sur CVE-2026-102274 dans `pyjwt` 2.13.0 (correctif 2.14.0) — le plancher `>=2.13.0` (BUG-091) est désormais sous le dernier correctif | 🟢 corrigé | P1 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | Run Gitea du push v2.44.0, job `security` : `Found 1 known vulnerability, ignored 2 in 1 package` → `pyjwt 2.13.0 CVE-2026-102274 2.14.0` | Plancher `pyjwt[crypto]>=2.14.0` (au-dessus de la version préinstallée de la toolcache du runner, sinon `pip` répond « already satisfied »). Garde-fou `TestDependencySecurityFloors` : `FLOORS["pyjwt"]` porté à `(2, 14, 0)` — contre-preuve : plancher remis à `2.13.0` → test rouge. Commentaire de la job `security` mis à jour | Le plancher de BUG-091 (2.13.0) corrigeait PYSEC-2026-178 mais est lui-même vulnérable depuis. Même mécanisme que BUG-093 (pypdf) : un plancher de sécurité doit rester au-dessus du dernier correctif. Vérifié : `test_ci_workflow.py` 9 passed ; pip-audit local OK (pyjwt 2.15.1 ≥ 2.14.0) |
| *BUG-096* | [🟡 IMPORTANT] Enregistrer un `.csv` depuis la visionneuse échoue : `TypeError` sur `sheets[…].name` (aucun `PUT …/csv/save` émis) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`renderXlsxViewer`, construction des jobs de sauvegarde) | Ouvrir un `.csv` dans ObsiGate, modifier une cellule, cliquer **Enregistrer** | `frontend/js/viewer.js` : le job de sauvegarde ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV — l'origine du `TypeError`) — repli feuille → `data.title`/`data.path`. Test JSDOM « saving an edited csv PUTs /csv/save (payload without xlsx_sheets) ». Vérifié : suite 1490 passed / 6 skipped, `xlsx-viewer.test.mjs` 61/61 | Défaut #156 A1. En mode CSV la réponse de lecture n'a pas de `xlsx_sheets` (`backend/routers/files_read.py`) : `sheets` est vide et `sheets[Number(panel.dataset.sheet)].name` lève un `TypeError` **avant** le `try`, donc aucun `PUT /api/file/{vault}/csv/save` n'est émis (le service `save_csv_cells()` et l'endpoint sont, eux, corrects). **Reproduit en JSDOM le 2026-09-29** (harnais de `tests/frontend/xlsx-viewer.test.mjs`) : `Cannot read properties of undefined (reading 'name')`, 0 requête d'API enregistrée. Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
| *BUG-097* | [🟡 IMPORTANT] Feuille `.xlsm` tronquée : « Charger la suite » échoue en **415** (`GET …/xlsx/sheet` n'accepte que `.xlsx`) alors que le `.xlsm` est éditable | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/routers/files_read.py` (`api_file_xlsx_sheet`), `frontend/js/viewer.js` (`wireLazyRows`) | Ouvrir un `.xlsm` de plus de 500 lignes puis cliquer le pied « Charger la suite » (ou approcher du bas du tableau) | `backend/routers/files_read.py` : `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (autres formats → 415 inchangé). Tests `TestXlsmEditable::test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats` | Défaut #156 A2. `.xlsm` est servi éditable (`files_read.py:491-512`, pas de `xlsx_readonly`) donc la visionneuse câble le chargement paresseux, mais `api_file_xlsx_sheet` refuse tout ce qui n'est pas `.xlsx` (`files_read.py:272`). Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
| *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` |
| *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|---|---|---|---|---|---|---|---|---|---|
| *(exemple)* TODO-002 | Rendre l'index inversé incrémental (40k+ fichiers) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/indexer.py`, `backend/search.py` | Recherche sur très gros vault | — | Exemple à remplacer. Cf. plan.md |
| *(À remplir)* | | | | | | | | | |
---
@@ -209,6 +224,14 @@ 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-28 | BUG-090 (#153 A8 + A9) | Correction + feature | `backend/xlsx_reader.py`, `backend/routers/files_read.py`, `backend/schemas.py`, `backend/openapi_docs.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-large.xlsx` | **La troncature d'une feuille est annoncée et les lignes cachées restent accessibles** : (BUG-090/A8) `render_sheets()` renvoie `total_rows`/`total_cols`/`max_rows`/`max_cols`/`truncated`, la visionneuse affiche un bandeau « Feuille tronquée » (i18n FR/EN, axes lignes et colonnes) et la ligne d'en-têtes devient `sticky` (`top: auto` sur les numéros de ligne pour éviter l'empilement) ; (A9) `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`, plafond 1 000 lignes/requête, 404 feuille inconnue, 415 non-xlsx) sert une fenêtre avec les **vraies** coordonnées A1 et le `has_more` de pagination. Contre-preuves : neutraliser `truncated` → 2 tests échouent ; neutraliser l'offset → 3 tests échouent. Vérifié : `test_xlsx_viewer.py` 58 passed, xlsx-viewer.test.mjs 14/14, E2E 7/7 (3 nouveaux + fixture `sample-xlsx-large.xlsx` 520 lignes), suite 1417 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-29 | BUG-091 (suite — désactivation semgrep en CI) | Correction CI | `.gitea/workflows/ci.yml`, `CHANGELOG.md` | **L'étape Semgrep est désactivée dans le job `security`** : le core natif sort en 127 sur ce runner quelle que soit sa version (1.178 = message ISA explicite ; 1.157.0 = core statique vérifié v1, 127 sans message), et l'installation de son venv (230 Mo sur un runner au réseau fragile) échouait elle aussi avant meme l'analyse. Trois hypothèses ont été testées puis infirmées — « releases 1.175+ incompilables » (1.157.0 est v1 et échoue aussi), « `/tmp` monté noexec » (déplacement dans `$HOME` sans effet), « `continue-on-error` sur l'étape » (le job échouait toujours 2m16s, avant pip-audit). Faute d'accès aux logs du runner pour lire la sortie du diagnostic, la SAST semgrep est retirée du CI : **bandit et pip-audit restent bloquants**, les 8 règles locales restent applicables en local (`semgrep --config semgrep-rules/ backend/`) et l'étape est réactivable telle quelle sur un runner x86-64-v2 | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-29 | BUG-092 (job CI `test`, #153) | Correction tests | `tests/test_webrender.py`, `tests/test_web_tools.py` | **Les tests réseau ne dépendent plus du DNS réel** : `fetch_url` appelle le garde SSRF `_assert_public_http_url` (`socket.getaddrinfo`) *avant* le traitement, et seule la couche httpx était mockée. Sur le runner au DNS instable, `tests/test_webrender.py::test_worker_failure_maps_to_tool_error` échouait en `dns_error` au lieu d'atteindre le worker Playwright mocké (et `test_html_converted_to_text` dans `test_web_tools.py` de la même façon). Correctif : fixture `no_dns` mockant les **deux** références du garde (`web._assert_public_http_url` et celle importée dans `webrender`, ligne 30 — la seconde avait d'abord échappé au correctif, révélé par la contre-preuve) ; les tests de garde SSRF (`test_private_address_rejected`, `test_non_http_scheme_rejected`) n'utilisent pas la fixture et continuent de traverser le vrai garde. Contre-preuve : DNS cassé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-29 | BUG-093 (job CI `security`, run #1660) | Sécurité / Correction CI | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | **Le job `security` est enfin vert** : la désactivation de semgrep (v2.39.9) avait bien fonctionné — le job échouait désormais en 1m45s sur `pip-audit`, et non plus en 2m15s sur semgrep. Cause : deux DoS de ressources publiés sur `pypdf` 6.16.0 (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, correctif 6.16.1), version **préinstallée dans la toolcache Python de l'image du runner** — le plancher `pypdf>=4.0` était donc satisfait et l'image n'était jamais mise à niveau. Correctif : plancher `pypdf>=6.16.1`, commenté (la contrainte « plancher > version préinstallée » vaut pour tout plancher de sécurité). Garde-fou `tests/test_ci_workflow.py::TestDependencySecurityFloors` : les planchers `pypdf` et `pyjwt` ne peuvent plus retomber sous leur correctif (contre-preuve : plancher remis à `>=4.0` → test rouge). Au passage, **`tests/test_ci_workflow.py::TestSemgrepStep` était en régression depuis v2.39.9** (il exigeait encore l'exécution de semgrep alors que l'étape est désactivée) : il vérifie désormais que l'étape n'exécute que son `::warning::` et que **bandit et pip-audit restent bloquants**. Cause trouvée en lisant le log brut du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log d'étape Semgrep collé précédemment datait d'un run antérieur | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-29 | BUG-091 (#153, runs CI #1641-#1642) | Correction CI | `.gitea/workflows/ci.yml`, `backend/requirements.txt`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Le job `security` est réparé définitivement** : (1) le binaire semgrep non épinglé exige depuis 1.158.0 un CPU x86-64-v2 que le runner Gitea ne fournit pas (`libs/libresolv.so.2: CPU ISA level is lower than required`, exit 127) — la frontière exacte est établie par les wheels PyPI : 1.157.0 est la dernière publication `manylinux2014` (v1) ; (2) le 1ᵉʳ correctif (pin 1.174.0, v2.39.2) échouait car cette version ne publie qu'en `manylinux_2_34` ; (3) semgrep vit désormais dans un venv isolé du job (`/tmp/semgrep-venv`, pin 1.157.0) car ses dépendances contredisent l'env principal (`tomli~=2.0.1` vs pip-audit ≥ 2.10, `pyjwt~=2.12.0` vs PYSEC-2026-178) ; (4) plancher `pyjwt[crypto]>=2.13.0` dans requirements.txt (transitif de mcp) et `pip install -U pip setuptools` dans le job (nouveaux advisories pip PYSEC-2026-3721, setuptools PYSEC-2026-3447). Validation : environnement frais reconstitué en local → résolution sans conflit (pyjwt 2.15.1), pip-audit exit 0, semgrep 1.157.0 exit 0 sur `semgrep-rules/`. Au passage documenté : security échouait déjà avant ce push (v2.31.0/v2.32.0 rouges) et les commits de features n'ont déclenché aucun run (Gitea : commit de tête uniquement) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-28 | #153 A6 → A17 (v2.33.0 → v2.39.0) | Feature + clôture documentaire (aucun bug nouveau) | `CHANGELOG.md`, `docs/features/xlsx-viewer.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **Clôture du backlog #153** : entrées CHANGELOG des 7 sous-tâches, fiche `features/xlsx-viewer.md` (statut terminé, cases A6-A17 cochées, historique), section 6 du guide utilisateur étendue (barre de formule, navigation clavier, tri/filtre/recherche/export CSV, structure, styles, formats `.xlsm`/`.xls`/`.ods`/`.csv`, tableau de bord) et bullets README FR/EN. Code livré : v2.33.0 A6 (outils IA `backend/tools/spreadsheets.py`), v2.34.0 A7 (clavier + barre de formule), v2.35.0 A13 (tri/filtre/recherche/export), v2.36.0 A14 (structure `PUT …/xlsx/structure`), v2.37.0 A15 (styles/fusions/volets figés), v2.38.0 A16 (`.xlsm` éditable, `.xls`/`.ods` lecture seule, `.csv` RFC 4180), v2.39.0 A17 (dashboard `GET …/xlsx/dashboard`). Vérifié : suite xlsx 116 passed, xlsx-viewer.test.mjs 35/35, ruff/mypy 0, i18n parity, validate-imports 40 modules | ✅ livré (en attente vérif utilisateur) |
| 2026-09-28 | BUG-089 (#153 A5, A10, A12) | Correction | `backend/xlsx_reader.py`, `backend/indexer.py`, `backend/search.py`, `backend/services/mutations.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py` | **Les tableurs deviennent visibles ettypés** : (A5) `extract_indexable_text()` indexe noms de feuilles + 20 premières lignes (plafond 5 k caractères) dans le TF-IDF et la recherche sémantique — un mot tapé dans une cellule rend le fichier trouvable ; (A10) `_coerce_xlsx_value()` reconnaît désormais les booléens (`TRUE`/`FAUX`/`OUI`/`NON`) et les dates FR `JJ/MM/AAAA` (jour-first : `01/02/2026` = 1er février), symétrique avec l'affichage ; (A12) la valeur calculée en cache s'affiche sous la formule (`<span class="xlsx-cached">`, 2ᵉ lecture `data_only=True` uniquement si l'archive contient un `<v>`), info-bulle traduite via `xlsx.cached_value_title` FR/EN. (BUG-089) un reindex manuel reconstruisait mal l'index inversé et `backend/search.py` lisait l'index par valeur. Contre-preuves vérifiées pour A5, A10 et A12. Vérifié : `test_xlsx_viewer.py` 43 passed, suite 1402 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10 | 🟢 corrigé (en attente vérif utilisateur) |
| 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) |
@@ -276,6 +299,16 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | BUG-079 | Correction | `backend/main.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-079** : `GET /api/diagnostics` renvoyait 500 « dictionary changed size during iteration ». Le handler itérait `inv.word_index.values()` et `index.items()` 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. Correctif : **snapshot avant itération** (`list(index.items())`, `inv.word_index.copy()`) — copie C atomique sous le GIL, pas de verrou ajouté. Test de non-régression déterministe (`RaceDict` fait grossir le dict pendant l'itération ; échoue sans le correctif, passe avec). Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 (80 fichiers), validate-imports 40 modules, unit 12/12. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | BUG-080, BUG-081 | Correction + enregistrement | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-080** : run E2E local pendu toute la nuit → harnais anti-blocage : `npx --yes` (plus de prompt interactif), install Chromium sautée si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124), `globalTimeout` Playwright (15 min local / 30 min CI, `E2E_GLOBAL_TIMEOUT_MS`), pidfile resynchronisé sur le vrai owner du port + `stop` qui tue l'arbre complet (orphelins 81180/81936 nettoyés, port 2029 libéré). Diagnostic : double processus systématique (parent `.venv` parqué + enfant qui sert — environnemental, aussi sur flowdeck/3.13). **BUG-081** (ouvert, non traité) : `GET /api/auth/mfa/status` → 500 auth désactivée (`user` None, `router.py:827`, reproduit live). Vérifié : `test_e2e_harness.py` 8/8, cycle start/stop live (pidfile cohérent, port libéré). | 🟢 corrigé (en attente vérif utilisateur) ; BUG-081 🔴 ouvert |
| 2026-09-27 | BUG-082 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-082** : `lint` rouge (`ERR_MODULE_NOT_FOUND: jsdom`, rouge depuis `7bee4a2`) — les fichiers de l'étape frontend racine à import statique `jsdom` (`upload.test.mjs`, puis `config-ai-keys.test.mjs` révélé par le CI après le 1er fix), alors que `jsdom` n'est installé que dans `tests/frontend/node_modules` (étape JSDOM). Les deux déplacés dans l'étape JSDOM (les deux branches) ; garde-fou `test_ci_workflow.py` généralisé (aucun fichier racine à import statique jsdom + suites verrouillées en JSDOM, contre-preuve OK). Vérifié : étape racine verte (11 suites) + `upload` et `config-ai-keys` verts depuis `tests/frontend/`. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | BUG-083 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-083** : job `security` rouge — le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée (`unexpected EOF while looking for matching '"'"`, `/var/run/act/workflow/4` ligne 2). Seul `run:` du workflow avec un `#` (les `#` des noms d'étapes Bandit/Npm audit sont inoffensifs, ces étapes passent). Correctif : echo sans `#` (réf `#87` en commentaire YAML). Garde-fou `test_ci_workflow.py` (aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM — BUG-082) + contre-preuve sur l'ancien `ci.yml`. Vérifié : 56 passed. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | BUG-081 | Correction | `backend/auth/router.py`, `tests/test_mfa.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-081** : `GET /api/auth/mfa/status` répondait 500 quand l'auth est désactivée — le pseudo-user `anonymous` n'a aucune entrée en store (`get_user` → `None`, `AttributeError` sur `user.get`). Garde `user is None` → payload « MFA désactivé ». Test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-29 | BUG-094 | Correction | `backend/xlsx_reader.py`, `frontend/js/viewer.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-094 — une feuille vide ou nouvellement ajoutée devient éditable et manipulable.** `render_sheets()` substitue une grille vierge 20×8 (`EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) quand la feuille ne porte aucune cellule, avec de vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`). Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-29 | BUG-095 | Correction CI / sécurité | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Le job `security` repasse au vert** : `pyjwt` 2.13.0 est vulnérable (CVE-2026-102274, correctif 2.14.0) et le plancher de BUG-091 (`>=2.13.0`) était donc sous le dernier correctif. Plancher porté à `pyjwt[crypto]>=2.14.0` (au-dessus de la toolcache du runner, sinon `pip` répond « already satisfied »), garde-fou `TestDependencySecurityFloors` mis à jour (contre-preuve : plancher remis à 2.13.0 → test rouge), commentaire de la job `security` aligné. Vérifié : `test_ci_workflow.py` 9 passed, pip-audit local OK (pyjwt 2.15.1). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-29 | BUG-096 → BUG-099 (#156 A1-A4) | Correction | `frontend/js/viewer.js`, `frontend/locales/{fr,en}.json`, `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/test_xlsx_viewer.py`, `tests/test_xlsx_formats.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `docs/ISSUES_TODOLIST.md` | **Les 4 défauts P0 du tableur sont corrigés.** (BUG-096) l'enregistrement d'un `.csv` depuis la visionneuse ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV) : le `TypeError` partait **avant** tout appel réseau, aucun `PUT …/csv/save` n'était émis. (BUG-097) `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` — le chargement des lignes au-delà du plafond fonctionne pour les classeurs macro. (BUG-098) `sniff_csv_delimiter()` détecte `;`/`,`/tabulation et `save_csv_cells()` réutilise le délimiteur : un CSV français s'affiche en colonnes distinctes et **le reste** après édition. (BUG-099) sonde de perte **par feuille** (4 Mo) + plafond global (32 Mo) et clé `cached_values_unverified` quand le budget est épuisé — plus de perte silencieuse possible des valeurs calculées. Tests : `TestXlsxCachedValueProbe` (3, contre-preuve : budget épuisé signalé au lieu de `[]`), `TestCsvDelimiter` + 3 tests CSV, fenêtre `.xlsm` (+ refus `.csv`), 1 test JSDOM CSV. Vérifié : suite **1490 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), `xlsx-viewer.test.mjs` 61/61, validate-imports 40 modules, unit 12/12, i18n parity. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-29 | #156 A5-A7 (P1) | Fonctionnalité (sans nouveau défaut) | `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | **P1 livré** — (A5) presse-papiers de plage : copier/couper/coller un bloc TSV au clavier et au menu contextuel, presse-papiers interne + miroir système, remplissage multi-cellules, insertion **texte** échappée, débordement signalé, annulable ; (A6) clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) ; (A7) zone Nom éditable (« Atteindre ») + liste de fonctions. Vérifié : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux), E2E Playwright. | ✅ livré |
| 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré |
| 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) |
---
@@ -286,7 +319,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| # | Titre | Date résolution | Résolu par | Correctif / Commit | Notes |
|---|---|---|---|---|---|
| *(aucun pour l'instant)* | | | | | |
| *BUG-083* | Job CI `security` rouge : le runner Gitea Act tronque le script `pip-audit` au premier `#` (citation de l'echo non fermée → `unexpected EOF while looking for matching '"'`) | 2026-09-27 | Utilisateur | `run:` assaini (echo sans `#`, réf `#87` en commentaire YAML) ; `tests/test_ci_workflow.py` (2 tests : aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM) ; vérifié : 56 passed (ci_workflow + e2e_harness + version), contre-preuve OK sur l'ancien `ci.yml` | Seul `run:` du workflow contenant un `#` (`see #87` dans l'echo). Les `#` des noms d'étapes (Bandit, Npm audit) sont inoffensifs (ces étapes passent). Correctif : echo sans `#`, réf `#87` en commentaire YAML |
| *BUG-082* | CI `lint` rouge : suites frontend à import statique `jsdom` exécutées dans l'étape racine où `jsdom` n'est jamais installé | 2026-09-27 | Utilisateur | `upload.test.mjs` + `config-ai-keys.test.mjs` déplacés dans l'étape JSDOM (les deux branches) ; garde-fou `test_ci_workflow.py` (aucun fichier racine à import statique jsdom + suites verrouillées en JSDOM) ; vérifié : étape racine verte + `upload` et `config-ai-keys` verts depuis `tests/frontend/` | `jsdom` ne vit que dans `tests/frontend/node_modules` (installé par l'étape JSDOM). Correctif : déplacer les suites concernées dans l'étape JSDOM |
| *BUG-080* | [🔴 BLOQUANT] E2E locaux bloqués toute la nuit : `npm run test:e2e:ps` ne termine jamais (serveurs orphelins sur le port 2029, `npx playwright install` sans `--yes` ni garde-fou, suite ~130 tests sans timeout global) | 2026-09-27 | Utilisateur | `run-e2e-local` : `npx --yes`, skip install Chromium si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124) ; `playwright.config.ts` : `globalTimeout` 15 min local / 30 min CI (`E2E_GLOBAL_TIMEOUT_MS`) ; `e2e-server.ps1` : pidfile = vrai owner du port, `stop` tue l'arbre complet. Tests : `tests/test_e2e_harness.py` (8/8), cycle start/stop live (pidfile cohérent, port libéré) | Constat 2026-09-27 : `e2e-server.ps1 start` OK (READY 12 s) mais run suivant pendu toute la nuit ; 2 python orphelins (PID 81180 parent + 81936 sur le port, pidfile périmé). Double processus systématique (parent `.venv` parqué + enfant qui sert — aussi sur flowdeck/3.13 : environnemental, sans impact après correctif). Trouvé au passage : BUG-081 (`/api/auth/mfa/status` → 500 auth désactivée) |
---
+164 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.28.8 | **Dernière mise à jour :** 2026-09-27
> **Version :** 2.47.0 | **Dernière mise à jour :** 2026-10-01
> **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,150 @@
---
## 🔵 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 :** ✅ **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 v2.33.0 → v2.39.0 : A6, A7, A13, A14, A15, A16, A17 (+ A11 déjà au CI) — **backlog #153 terminé**
- **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) — 🟢 livré**
- [x] **A5** Indexation du contenu des feuilles (noms de feuilles + 20 premières lignes, plafond 5 k caractères) — les mots tapés dans une cellule rendent le fichier trouvable ; au passage **BUG-089** (reindex manuel ne reconstruisait pas l'index inversé)
- [x] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets` (v2.33.0)
- [x] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches) (v2.34.0)
- [x] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) — BUG-090
- [x] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
- [x] **A10** Types & formats de saisie (nombre/texte, booléens `TRUE`/`FAUX`, dates FR `JJ/MM/AAAA` jour-first)
- [x] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI (JSDOM dans le job lint depuis v2.31.0 ; spec E2E livrée avec A9bis)
- [x] **A12** Valeur calculée affichée sous la formule (2ᵉ lecture `data_only=True` seulement si l'archive contient un `<v>`, info-bulle FR/EN)
- **P2 — étendu (🟢, 2-4 j) — 🟢 livré**
- [x] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection (v2.35.0)
- [x] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer) (v2.36.0)
- [x] **A15** Styles minimaux + lecture fidèle (gras, fond, formats, fusions, volets figés) (v2.37.0)
- [x] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`/`.ods` lecture seule via xlrd/odfpy, `.csv` éditable) (v2.38.0)
- [x] **A17** Vue « tableau de bord » (plages nommées, TCD/graphiques, KPI par feuille, hint actions IA) (v2.39.0)
- **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).
---
## 🔵 En cours — Refonte UI/UX tableur (P2)
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
- **Statut :** ✅ **livré le 2026-09-29 (Lots 1 → 5, A1-A5)** — ruban de commandes groupé, onglets
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non recalculées,
tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`, bandeau de conflit
409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** (tableau de bord + entrée
Assistant IA, redimensionnable) ; **undo/redo**, chargement via `IntersectionObserver`,
ARIA `role="grid"` ; extraction des unités sans état dans `frontend/js/xlsx/*`.
- **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md)
- **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale :
commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés
(tableau de bord, troncature, lecture seule, formules non recalculées, conflits) mal intégrés.
La refonte s'appuie sur les standards Excel/Google Sheets/Airtable **sans renier** la contrainte
`vanilla JS`, zéro framework, zéro build npm.
- **Sous-tâches :**
- [x] **A1** Coquille : barre de commandes groupée, onglets feuilles permanents + « + »,
badges d'état, tokens de grille et affordances visuelles (Lot 1)
- [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
- [x] **A3** Inspecteur droit repliable : Tableau de bord + entrée Assistant IA (Lot 3)
- [x] **A4** Undo/redo, chargement via `IntersectionObserver`, sémantique ARIA (Lot 4)
- [x] **A5** Découpage `frontend/js/xlsx/*`, lien dashboard → grille, inspecteur redimensionnable (Lot 5)
---
## 🔵 En cours — Ergonomie tableur
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
- **Effort :** 2-4 jours | **Impact :** 🟡
- **Statut :** ✅ **livré** — 2026-09-29 (menu contextuel clic droit + appui long, sélection type Excel)
- **Analyse, conception et critères :** [features/xlsx-context-menu.md](./features/xlsx-context-menu.md)
- **Description :** retours utilisateur après #154 — ajouter un **menu contextuel** (clic droit +
appui long tactile) sur les cellules et en-têtes, et aligner la **sélection/curseur** sur le
comportement d'Excel (plage par glisser, `Maj`, sélection de ligne/colonne par en-tête).
- **Sous-tâches :**
- [x] **A1** Menu contextuel thémé (clic droit + appui long) : insérer/supprimer ligne & colonne,
trier, effacer le contenu
- [x] **A2** Sélection type Excel : plage (glisser / `Maj+clic` / `Maj+flèches`), en-têtes
ligne/colonne, curseur croix, zone Nom affichant la plage
---
## ✅ Terminé — Tableur Excel (complétude)
### 156. Éditeur Excel — complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
- **Effort :** 15-22 jours (P0 2-3 j ✅ · P1 4-6 j ✅ · P2 5-7 j ✅ · P3 4-6 j ✅) | **Impact :** 🟡
- **Statut :** ✅ **livré le 2026-09-30** — **P0** (4 défauts **BUG-096 → BUG-099** corrigés),
**P1** (**A5-A7** : presse-papiers de plage, clavier complet, zone Nom éditable), **P2**
(**A8** mise en forme en écriture, **A9** décision « pas de moteur de formule, annoncée dans
l'UI », **A10** undo/redo unifié conservé au re-rendu) et **P3** (**A11** export sélection/
Markdown/HTML/impression + recherche multi-feuilles, **A12** concurrence optimiste `If-Match`,
**A13** cache des métadonnées, **A14** outils IA `.xlsm`/`.csv` + recherche/analyse/structure)
- **Analyse, défauts, risques et critères d'acceptation :** [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md)
- **Description :** #153 a rendu l'éditeur **correct** sur la grille de valeurs, #154/#155 l'ont
rendu **convivial** (ruban, inspecteur, undo/redo des cellules, sélection et menu contextuel
type Excel). Il reste l'écart avec un vrai éditeur tableur : coller une **plage**, écrire la
**mise en forme**, **calculer**, **sortir** le résultat, et détecter un écrivain concurrent.
La fiche porte l'audit ; les cases ci-dessous sont le **suivi de référence**.
- **Défauts corrigés le 2026-09-29 (cf. [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md)) :**
- ✅ BUG-096 — enregistrer un `.csv` depuis la visionneuse levait un `TypeError`
(`sheet: sheets[…].name` alors qu'un CSV n'a pas de `xlsx_sheets`) : aucun `PUT …/csv/save`
n'était émis (reproduit en JSDOM, corrigé + test) ;
- ✅ BUG-097 — feuille `.xlsm` tronquée : « Charger la suite » échouait en **415** —
`GET …/xlsx/sheet` accepte désormais `.xlsx` **et** `.xlsm` ;
- ✅ BUG-098 — délimiteur CSV détecté (`;`/`,`/tabulation) et réutilisé à l'écriture :
un CSV français s'affiche en colonnes et le reste après édition ;
- ✅ BUG-099 — sonde de perte **par feuille** + signal `cached_values_unverified` quand le
budget est épuisé : plus de perte silencieuse possible des valeurs calculées.
- **Sous-tâches :**
- **P0 — défauts (🔴, 2-3 j) — ✅ livré le 2026-09-29**
- [x] **A1** Sauvegarde `.csv` depuis la visionneuse — BUG-096 (test JSDOM : `PUT …/csv/save` émis)
- [x] **A2** Chargement paresseux des `.xlsm` — BUG-097 (`GET …/xlsx/sheet` accepte `.xlsx`/`.xlsm`)
- [x] **A3** Délimiteur CSV détecté et réutilisé à l'écriture — BUG-098 (`sniff_csv_delimiter`)
- [x] **A4** Sonde de perte par feuille + signal `cached_values_unverified` — BUG-099
- **P1 — presse-papiers & clavier (🟡, 4-6 j) — ✅ livré le 2026-09-29**
- [x] **A5** Presse-papiers de plage — copier/couper/coller un bloc TSV (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`, menu contextuel), presse-papiers interne + système, remplissage multi-cellules (tests JSDOM : `A1:B2` → `D5:E6`)
- [x] **A6** Clavier complet — `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` (saut de ligne en cellule)
- [x] **A7** Zone Nom éditable (« Atteindre » : `B12`, `A1:B3`, `Feuille!A1`) + aide à la saisie des fonctions
- **P2 — mise en forme, calcul, undo (🟡, 5-7 j) — ✅ livré le 2026-09-30**
- [x] **A8** Mise en forme en écriture — bouton **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur) via `PUT …/xlsx/style` (verrou, backup, garde de perte, `If-Match`)
- [x] **A9** Calcul — **décision documentée** : pas de moteur de formule, l'enregistrement annonce « enregistrée comme texte » (la garde anti-DDE reste)
- [x] **A10** Undo/redo unifié (cellules, effacement, tri/filtre, structure) et conservé au re-rendu (`_xlsxHistory` par fichier)
- **P3 — sortie, robustesse, performances (🟢, 4-6 j) — ✅ livré le 2026-09-30**
- [x] **A11** Sortie & recherche : export de la sélection / Markdown / HTML / impression, recherche sur toutes les feuilles (compteur `n/m · k feuilles`)
- [x] **A12** Concurrence optimiste inter-processus (`ETag`/`If-Match`, **409** réparable, bouton « Réessayer » qui relit)
- [x] **A13** Performances : cache des métadonnées par `(chemin, mtime, taille)` (LRU 8), invalidé à chaque écriture (axe des colonnes > `MAX_COLS` : hors périmètre)
- [x] **A14** Outils IA étendus — `.xlsm`/`.csv` acceptés, `search_workbook`, `analyze_range`, `edit_xlsx_structure`
---
## ⚪ Backlog — Priorité 4 (P4)
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
@@ -68,11 +212,15 @@
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Statut :** 🔵 en cours depuis 2026-09-26 — par tranches. **T1 livrée (v2.28.1) :** bandit bloquant (`nosec` justifiés B324/B404/B603/B607/B406, B105 exclu comme `pyproject`), `npm audit` bloquant (0 vulnérabilité), 5 suites frontend intégrées au CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`). pip-audit reste consultatif (montées starlette/weasyprint à qualifier).
- **T6 livrée (v2.28.15) :** dépendances qualifiées — mistune 3.3.3, python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 (`cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant, 0 vulnérabilité** (seule exception documentée : PYSEC-2026-1325 ecdsa, sans correctif upstream, JWT HS256 uniquement).
- **T7 livrée (v2.28.15) :** **semgrep bloquant** sur ruleset 100 % local `semgrep-rules/` (8 règles, 0 finding, contrôle négatif OK) ; trivy écarté (binaire + DB réseau, couche Python couverte).
- **T8 livrée (v2.28.15, fin BUG-034) :** cookies `Secure` auto (`true|false|auto`, `X-Forwarded-Proto` sous `TRUST_PROXY`, warning affiné, `TRUST_PROXY=true` en prod) ; `CORSMiddleware` same-origin explicite ; `style-src 'unsafe-inline'` conservé assumé (189 `style=` + 343 `el.style`, T5c ayant verrouillé `script-src`).
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
- **Sous-tâches :**
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown) — **T4 livrée :** `tests/e2e/xss.spec.js` (BUG-021/022, 2/2 vert) + `scripts/e2e-server.ps1` (cycle de vie serveur E2E avec progression `start|stop|status|logs`) + validation locale projet `chromium-desktop` : **108/108 verts** (obsigate 44, split 37, viewers 24, xss/header 3), mobiles ciblés 10/10
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`) — **T2 livrée (v2.28.2) :** `tests/test_hardening_concurrency.py` (users.json concurrent + budget temps regex) ; 5 suites au CI (T1)
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3)** (helper + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte)
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3)** (helper + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte) ; **T5c livrée (v2.28.13)** (`script-src` sans `unsafe-inline`) ; **T8 livrée (v2.28.15)** (fin BUG-034 : Secure auto + CORS explicite ; `style-src` résiduel assumé ; rotation DeepSeek BUG-006 toujours côté utilisateur)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD — **T6/T9 livrées (v2.28.15)** (`pip-audit` 0, `npm audit` 0, locales FR/EN 2213 clés parité testée `test_i18n_parity.py`, gardes `test_version.py` + `test_ci_workflow.py`)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
---
@@ -86,6 +234,9 @@
| # | Domaine / fonctionnalité | Version | Détails |
|---|---|---|---|
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
| 156 | Tableur — Complétude éditeur : presse-papiers/clavier (A5-A7), mise en forme (A8), décision calcul (A9), undo unifié (A10), export + recherche multi-feuilles (A11), concurrence optimiste (A12), cache méta (A13), outils IA `.xlsm`/`.csv` (A14) + BUG-096 → BUG-099 | 2.45.0 | [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md) |
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
@@ -153,6 +304,7 @@
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) + panneau repliable | 2.46.0→2.47.0 | [archive](./archive/COMPLETED_v1-v2.md) |
---
@@ -164,6 +316,10 @@
| 🔵 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 |
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
| ✅ Terminé | #155 Ergonomie tableur — menu contextuel & sélection type Excel | 0 jour restant |
| ✅ Terminé | #156 Éditeur Excel — complétude — **P0-P3 ✅ livrés le 2026-09-30** (BUG-096 → BUG-099, A5-A14) | 0 jour restant |
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
---
@@ -171,6 +327,12 @@
## 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).
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
- **Ajout 2026-09-29 :** #155 livré — menu contextuel (clic droit / appui long) et sélection type Excel sur la grille ([features/xlsx-context-menu.md](./features/xlsx-context-menu.md)) ; au passage **BUG-094** corrigé (feuille vide/nouvelle désormais éditable, quadrillage vierge 20×8).
- **Clôture 2026-09-30 :** #156 **livré (P0 → P3)** — **A8** mise en forme en écriture (bouton **Mise en forme** : gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — via `PUT …/xlsx/style`), **A9** décision « pas de moteur de formule, annoncée dans l'UI », **A10** undo/redo unifié conservé au re-rendu, **A11** export de la sélection / Markdown / HTML / impression + recherche sur toutes les feuilles, **A12** concurrence optimiste (`If-Match`, **409** réparable), **A13** cache des métadonnées par `mtime`, **A14** outils IA `.xlsm`/`.csv` + `search_workbook`/`analyze_range`/`edit_xlsx_structure` — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
- **Ajout 2026-09-29 :** #156 **P1 livré** — **A5** presse-papiers de plage (copier/couper/coller un bloc, presse-papiers interne + système, entrées du menu contextuel, remplissage multi-cellules), **A6** clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) et **A7** zone Nom éditable + aide à la saisie ; restent P2 (mise en forme, calcul, undo unifié) et P3 (sortie, concurrence optimiste, performances, outils IA).
- **Ajout 2026-09-29 :** #156 ouvert — audit de complétude de l'éditeur Excel : **4 défauts recensés** (BUG-096 enregistrement `.csv`, BUG-097 lazy-load `.xlsm`, BUG-098 délimiteur CSV, BUG-099 sonde de perte), **corrigés le jour même (P0 ✅)** avec tests de non-régression ; restent P1-P3 (presse-papiers, clavier, mise en forme, calcul, export, concurrence optimiste) puis presse-papiers de plage, clavier complet, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.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.
+18
View File
@@ -419,6 +419,24 @@ première modification et téléchargement du fichier d'origine.
---
## #157 — Recherche : facette « Extensions » ✅ TERMINÉ
Le panneau de facettes de la page de résultats de recherche affiche désormais **trois** groupes :
**Vaults**, **Tags** et **Extensions**. Le troisième liste les extensions de fichiers présents
dans le résultat courant (`.md`, `.xlsx`, `.pdf`, …) avec leur compteur ; un clic ajoute
l'opérateur `ext:<type>` à la requête (un `ext:` déjà actif est remplacé, jamais dupliqué).
L'ensemble du panneau se replie/rouvre d'un clic sur un bouton discret (chevron), état
mémorisé dans `localStorage`.
| Aspect | Détail |
|---|---|
| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence · **`backend/schemas.py::SearchFacets`** expose le champ (sinon le `response_model` le supprimait — corrigé après livraison de 2.46.0) |
| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) ; panneau repliable via `.search-facets__toggle` + `data-collapsed` (état `obsigate_facets_collapsed`) |
| i18n / aide | `help.desc_facet_extensions`, `search.facets_collapse`/`_expand` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` |
| Tests | `tests/test_search.py::test_facets` (route réelle `/api/search/advanced`, assertion stricte `facets.extensions`), `tests/frontend/search-facets.test.mjs` (5 tests JSDOM, ajoutés au CI), e2e `obsigate.spec.ts` (facette visible + repli/rouverture) |
---
## Grosses fonctionnalités — fiches dédiées
| # | Feature | Version | Fiche |
+72
View File
@@ -0,0 +1,72 @@
# #155 — Ergonomie tableur : menu contextuel, sélection type Excel
> **Item de roadmap :** [#155 — Ergonomie tableur (menu contextuel + sélection)](../ROADMAP.md)
> **Origine :** retours utilisateur après #154 (refonte UI/UX tableur)
> **Statut :** ✅ **livré** — 2026-09-29
> **Effort estimé :** 2-4 jours (réalisé en 1 lot)
---
## 1. Demande
1. **Menu contextuel** au clic droit (et **appui long** sur mobile/tactile) dans la grille, sur une
cellule, un numéro de ligne ou un en-tête de colonne.
2. **Comportement du curseur / de la sélection comme Excel** : le curseur en forme de croix
au-dessus des cellules, sélection d'une **plage** (glisser, `Maj+clic`, `Maj+flèches`), clic sur
un en-tête de ligne/colonne pour sélectionner toute la ligne/colonne.
3. (BUG-094, traité séparément) une **feuille vide/nouvelle** doit rester éditable et permettre
d'ajouter lignes/colonnes.
## 2. Conception
### Menu contextuel
- Positionné au **pointeur** (souris) ou au point de contact (tactile), borné au viewport.
- Thème de l'application (classes `.xlsx-context-menu`, réutilise `.btn-action`).
- Contenu selon la cible et le mode :
- **Insérer** : ligne au-dessus / en dessous, colonne à gauche / à droite (sur la plage
sélectionnée, via `PUT …/xlsx/structure`, `count` = taille de la plage).
- **Supprimer** : ligne(s) / colonne(s) — confirmation modale (destructif).
- **Trier** : A→Z / Z→A sur la colonne de gauche de la sélection.
- **Effacer le contenu** : vide les cellules sélectionnées (modification locale, non
sauvegardée tant que l'utilisateur n'enregistre pas).
- Masqué pour les formats en lecture seule (`.xls`/`.ods`) ; sans les actions de structure pour un
`.csv`.
- **Appui long** (tactile) : minuteur ~500 ms annulé au déplacement / relâchement.
### Sélection type Excel
- **Clic** : cellule active + sélection simple ; la barre de formule (zone Nom) affiche la plage
(`A1` ou `A1:B3`).
- **Glisser** : sélection rectangulaire (`mousedown` → `mouseover` sur une autre cellule →
`mouseup`) ; le texte de la cellule n'est plus sélectionné pendant le glisser.
- **`Maj+clic`** et **`Maj+flèches`** : étendent la sélection depuis l'ancre.
- **En-têtes** : clic sur un numéro de ligne / lettre de colonne sélectionne la ligne/colonne
entière. Le tri n'est plus déclenché par simple clic sur l'en-tête (il passe au menu contextuel),
conformément au comportement Excel.
- **Curseur** : `cursor: cell` (croix) sur les cellules, `pointer` sur les en-têtes.
## 3. Fichiers
| Couche | Fichier | Rôle |
|---|---|---|
| Menu | `frontend/js/xlsx/context-menu.js` (nouveau) | Menu contextuel générique thémé (build + position + dismiss) |
| Sélection + câblage | `frontend/js/viewer.js` | État de sélection, drag, `Maj`, en-têtes, ouverture du menu, actions |
| Styles | `frontend/style.css` | `.xlsx-selected`, `.xlsx-context-menu`, curseurs |
| i18n | `frontend/locales/{fr,en}.json` | Libellés du menu |
| Tests | `tests/frontend/xlsx-viewer.test.mjs` | Sélection, menu contextuel, tri via menu |
## 4. Critères d'acceptation
- Clic droit sur une cellule ouvre un menu thémé aux coordonnées du pointeur ; `Échap` ou clic
ailleurs le ferme.
- Insérer/supprimer ligne/colonne via le menu envoie la bonne action `PUT …/xlsx/structure`
(avec `count` pour une plage) puis re-rend.
- Glisser de A1 vers B2 surligne A1:B2 et la zone Nom affiche `A1:B2`.
- `Maj+clic` étend la sélection ; cliquer un en-tête sélectionne la ligne/colonne entière.
- Aucune régression des tests #154 (édition, save, undo/redo, dashboard).
## 5. Historique
| Date | Événement |
|---|---|
| 2026-09-29 | Ouverture #155 (menu contextuel + sélection type Excel) suite aux retours utilisateur |
| 2026-09-29 | **Livré** : menu contextuel thémé (`frontend/js/xlsx/context-menu.js`) ouvert au clic droit et à l'appui long, actions résultat de `PUT …/xlsx/structure` (insérer/supprimer ligne & colonne avec `count`), tri et effacement ; sélection type Excel (glisser, `Maj+clic`, `Maj+flèches`, en-têtes ligne/colonne, curseur croix, zone Nom affichant la plage) |
+322
View File
@@ -0,0 +1,322 @@
# #156 — Éditeur Excel : complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
> **Item de roadmap :** [#156 — Éditeur Excel — complétude fonctionnelle](../ROADMAP.md)
> **Origine :** audit de complétude demandé le 2026-09-29, après #153 (backlog tableur),
> #154 (refonte UI/UX) et #155 (menu contextuel & sélection)
> **Statut :** ✅ **livré le 2026-09-30** — P0 ✅ (BUG-096 → BUG-099), P1 ✅ (A5, A6, A7),
> P2 ✅ (A8, A9, A10) et P3 ✅ (A11, A12, A13, A14) ; ouverture 2026-09-29 (audit statique,
> A1 reproduit en JSDOM)
> **Effort estimé :** 15-22 jours (P0 2-3 j · P1 4-6 j · P2 5-7 j · P3 4-6 j) — **réalisé dans
> l'enveloppe annoncée**
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
> l'analyse, les défauts, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
---
## 1. Objectif
#153 a rendu l'éditeur XLSX **correct sur le périmètre « grille de valeurs »** (lecture/écriture
gardées, styles en lecture, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation, outils IA,
tableau de bord). #154/#155 l'ont rendu **convivial** (ruban, inspecteur, undo/redo des cellules,
sélection et menu contextuel type Excel).
Reste l'écart avec ce qu'un utilisateur attend d'un **éditeur tableur** : coller une plage,
penser la mise en forme, calculer, sortir le résultat, et se protéger d'un autre écrivain.
Cet item recense **4 défauts** (A1 reproduit, A4 théorique) et les **manques fonctionnels**
correspondants.
## 2. Périmètre (couches touchées)
| Couche | Fichier | Rôle dans cet item |
|---|---|---|
| Lecture XLSX | `backend/xlsx_reader.py` | Fenêtres de lignes, délimiteur CSV, sonde de perte, métadonnées |
| Écriture XLSX | `backend/services/mutations.py` | Cellules, structure, **style (A8)**, coercion, `revision` (A12) |
| Endpoints | `backend/routers/files_read.py`, `files_write.py` | `xlsx/sheet`, `xlsx/save`, `xlsx/structure`, **`xlsx/style` (A8)**, `csv/save` |
| Outils IA | `backend/tools/spreadsheets.py` | Lecture/édition par l'assistant (`.xlsx`/`.xlsm`/`.csv`, recherche, analyse, structure — A14) |
| Visionneuse | `frontend/js/viewer.js` + `frontend/js/xlsx/*` | Presse-papiers, clavier, barre de formule, undo/redo, export |
| Styles | `frontend/style.css` | Affordances presse-papiers/mise en forme (variables CSS) |
| i18n | `frontend/locales/{fr,en}.json` | Tout libellé nouveau, FR **et** EN |
| Tests | `tests/test_xlsx_*.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js` | Non-régression par sous-tâche |
## 3. Audit
### 3.1 Défauts recensés (P0) — ✅ corrigés le 2026-09-29
#### ✅ A1 → BUG-096 — Enregistrer un `.csv` depuis la visionneuse échoue
`frontend/js/viewer.js` construit l'unique job de sauvegarde ainsi :
```js
const jobs = panelEls.map((panel) => {
const cells = {};
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
return { sheet: sheets[Number(panel.dataset.sheet)].name, cells }; // ← ligne 1757
}).filter((job) => Object.keys(job.cells).length);
```
En mode CSV, la réponse de lecture (`backend/routers/files_read.py:548-556`) **ne contient pas
`xlsx_sheets`** : `sheets` est donc vide et `sheets[...].name` lève un `TypeError`. Vérifié le
2026-09-29 avec le harnais JSDOM (`tests/frontend/xlsx-viewer.test.mjs`) : après édition d'une
cellule CSV et clic sur **Enregistrer**, `TypeError: Cannot read properties of undefined
(reading 'name')` et **aucun** `PUT /api/file/{vault}/csv/save` n'est émis. Le service backend
`save_csv_cells()` et l'endpoint sont pourtant corrects : le défaut est purement côté visionneuse.
*Cause racine :* le mode CSV réutilise un panneau unique (`data-sheet="0"`) sans feuille
correspondante dans `sheets` ; le chemin d'enregistrement n'a pas été adapté comme l'ont été les
onglets, la structure et le tableau de bord (qui, eux, testent `isCsv`).
**Correctif (2026-09-29) :** le nom de feuille est résolu avec repli
(`sheets[idx]?.name || sheets[0]?.name || data.title || data.path`). Test JSDOM : un CSV monté
**sans** `xlsx_sheets` émet un unique `PUT …/csv/save`.
#### ✅ A2 → BUG-097 — `.xlsm` éditable : « Charger la suite » renvoie 415 au-delà de 500 lignes
Un `.xlsm` est servi **éditable** (`backend/routers/files_read.py:491-512`, pas de
`xlsx_readonly`) : la visionneuse câble donc le chargement paresseux. Mais
`GET /api/file/{vault}/xlsx/sheet` refuse **tout ce qui n'est pas `.xlsx`** (`files_read.py:272`,
**415**). Une feuille `.xlsm` de plus de 500 lignes affiche donc le pied « Charger la suite » dont
chaque clic échoue (toast d'erreur), alors que la limitation n'est pas annoncée comme telle pour
ce format.
**Correctif (2026-09-29) :** `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (les autres
formats restent en 415). Tests : fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`.
#### ✅ A3 → BUG-098 — Délimiteur CSV figé : un `.csv` français s'ouvre en une seule colonne
`render_csv_table(raw)` utilise le délimiteur par défaut `,` (`backend/xlsx_reader.py:784`),
appelé sans argument (`backend/routers/files_read.py:551`) — tandis que l'**export** CSV de la
visionneuse écrit du `;` et un BOM UTF-8. Un CSV produit par Excel/Sheets en locale française
(`;`) s'affiche donc en une colonne. Le même `,` est utilisé par `save_csv_cells()` pour re-parser
et réécrire le fichier : l'édition peut re-sérialiser dans un format différent de la source.
**Correctif (2026-09-29) :** `sniff_csv_delimiter()` (Sniffer, repli par décompte sur la
première ligne, puis `,`) est partagé par le rendu et la réécriture — `save_csv_cells()`
réutilise le délimiteur détecté. Tests : lecture, écriture et champs entre guillemets contenant
le séparateur.
#### ✅ A4 → BUG-099 — Sonde de perte plafonnée à 8 Mo : perte silencieuse possible
`inspect_workbook()` détecte les valeurs calculées en cache via un budget global
`_MAX_PROBE_BYTES = 8_000_000` (`backend/xlsx_reader.py:69`) partagé entre toutes les feuilles
(`_has_cached_formulas`). Sur un classeur dont le XML des feuilles dépasse ce budget **avant** la
première formule cachée, `xlsx_lossy_features` ne contient pas `cached_values` : le `409`
`xlsx_lossy_content` n'est pas déclenché et l'enregistrement détruit ces valeurs **sans
avertissement** — exactement le risque n°1 de #153. À ce stade, risque **théorique** (non
reproduit).
**Correctif (2026-09-29) :** budget **par feuille** (4 Mo) + plafond global (32 Mo) ;
`_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute
`cached_values_unverified` (libellé i18n FR/EN) : la sauvegarde demande une confirmation au lieu
de passer en silence. Trucs de test : budgets monkeypatchés. Contre-preuve : budget épuisé →
signalé au lieu de `[]` (comportement antérieur).
### 3.2 Fonctions manquantes (écart vs Excel / Google Sheets)
**Presse-papiers et sélection** — ✅ traité en P1 (A5)
- ✅ Coller une **plage** : le handler forcait tout sur une ligne
(`viewer.js` : `replace(/\r?\n/g, " ")`) — coller un bloc TSV/CSV depuis Excel écrasait une seule
cellule. Le collage découpe désormais le bloc (`parseMatrix`) et remplit la plage à partir de la
cellule active (`fillFrom`), 1×1 restant un remplacement de cellule.
- ✅ Copier / Couper / Coller de plage côté application : `Ctrl+C`/`Ctrl+X`/`Ctrl+V` (événements
`copy`/`cut`/`paste`) **et** entrées du menu contextuel ; une sélection de texte dans la cellule
éditée garde le comportement natif.
- Toujours absents : poignée de recopie (fill), multi-sélection `Ctrl+clic`, glisser-déposer de
lignes/colonnes, copie d'une image/du HTML.
**Clavier / navigation** — ✅ traité en P1 (A6, A7)
- ✅ `Ctrl+S`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Ctrl+A`
sont câblés dans un handler unique de la grille ; une cellule reste en mode « navigation »
jusqu'à la saisie (ou `F2`), sinon `Suppr`/`Home`/`End` voleraient une touche du curseur.
- ✅ `Maj+Entrée` insère un saut de ligne **dans** la cellule (texte uniquement).
- ✅ La **zone Nom** est un champ éditable (« Atteindre ») : `B12`, `A1:B3`, `Feuille!A1`.
- ✅ Aide à la saisie : la barre de formule propose les noms de fonctions courants (`<datalist>`,
liste localisée FR/EN). L'autocomplétion contextuelle reste à faire si un moteur de formules
arrive (A9).
**Mise en forme en écriture** — ✅ traitée (A8, 2026-09-30)
- ✅ Un bouton **Mise en forme** (`#xlsx-format-btn`, classeurs éditables non-CSV) agit sur la
**sélection** via `PUT …/xlsx/style` : gras / italique / souligné / effacer, alignement
gauche-centré-droite, couleur de police et de fond (`<input type="color">`, donc aucune
palette codée en dur), formats de nombre (général, nombre, pourcentage, devise, date, texte),
fusion/défusion, volets figés/libérés, largeur de colonne et hauteur de ligne. La fusion exige
une vraie plage (`xlsx.format_merge_needs_range`).
- Écriture sur le modèle de `xlsx/save` / `xlsx/structure` : verrou par fichier, backup, écriture
atomique (`.tmp` + `os.replace`), garde de perte (refus sans `force`), `If-Match` (A12), garde
anti-formule de BUG-088, plafond `MAX_STYLE_CELLS = 10 000`, invalidation du cache méta (A13).
- Toujours **hors périmètre** : commentaires, liens hypertexte, validation de données, mise en
forme conditionnelle, protection de cellules (lecture seule), bordures et palette Excel.
**Calcul** — ✅ décision documentée (A9, 2026-09-30)
- Aucun moteur de formule n'est livré : ObsiGate lit et écrit les formules telles qu'Excel les a
enregistrées, sans recalcul (pas de références inter-feuilles, pas d'agrégats). Le choix est
**annoncé à l'utilisateur** : un enregistrement contenant une valeur commençant par `=` ou `@`
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé ; la garde
anti-DDE de BUG-088 reste en place.
**Sortie et recherche** — ✅ traité (A11, 2026-09-30)
- ✅ Export de la **sélection** (CSV, nom `Feuille-A1B2.csv`) quand une plage de plusieurs cellules
est active, sinon la feuille entière ; l'écart doc/code de #153-A13 est tranché dans ce sens.
- ✅ Menu **Exporter** : Markdown (tableau), HTML (document autonome) et **impression** (iframe
hors écran, cadre précédent détruit). Les quatre formats suivent le **contenu affiché**.
- ✅ La recherche (`Ctrl+F`) parcourt **toutes les feuilles** : compteur `n/m · k feuilles` et
activation automatique de l'onglet contenant la correspondance.
**Robustesse / multi-postes** — ✅ traitée (A10, A12, 2026-09-30)
- ✅ Concurrence optimiste : `revision` (`mtime_ns` + taille) renvoyée à la lecture et acceptée en
`If-Match` sur `xlsx/save`, `xlsx/structure`, `xlsx/style` et `csv/save` ; un écrivain externe
détecté donne un **409** `conflict` (`details.reason = "stale_revision"`) au lieu d'un
écrasement, et le bandeau « Réessayer » **relit** le fichier avant de rejouer.
- ✅ Piles undo/redo **unifiées et conservées au re-rendu** (`_xlsxHistory` par fichier) : édition,
effacement, tri/filtre et structure ; action destructive (suppression) non annulable faute
d'inverse, et lecture du filtre **coalescée** (une entrée par session de frappe).
- Le tri/filtre restent **d'affichage** : après un tri, un enregistrement ne persiste pas l'ordre
(seules les cellules sales sont écrites) — le libellé `xlsx.sort_applied` le dit.
**Outils IA** — ✅ traités (A14, 2026-09-30)
- ✅ `_spreadsheet_path()` accepte `.xlsx`, `.xlsm` (macros préservées) et `.csv` (délimiteur
conservé) ; `update_xlsx_cells` délègue à `save_csv_cells` pour un CSV.
- ✅ Nouveaux outils : `search_workbook` (recherche multi-feuilles, comptée par feuille),
`analyze_range` (nombre, somme, moyenne, min, max d'une plage A1), `edit_xlsx_structure`
(feuilles, lignes, colonnes — risque WRITE, **confirmation conservée**).
- Bornes explicites : `MAX_SCAN_ROWS/MAX_SCAN_COLS`, `MAX_SEARCH_RESULTS = 100`,
`MAX_RANGE_VALUES = 200`, `MAX_RANGE_CELLS = 10 000`.
### 3.3 Limites techniques relevées
- `MAX_COLS = 40` sans pagination de colonnes : le chargement paresseux ne concerne que les
lignes (`read_sheet_window`), l'axe des colonnes reste tronqué.
- Coût de lecture : `render_sheets()` charge le classeur pour les formules, une seconde fois si
des valeurs cachées existent, puis `read_workbook_meta()` (troisième chargement, scan
`500 × 40` par feuille). ✅ **A13** : `read_workbook_meta()` est mémoïsé par
`(chemin, mtime_ns, taille)` (LRU 8) et invalidé à chaque écriture — le reload complet à chaque
fenêtre demandée a disparu ; `render_sheets()` reste hors cache (il doit relire les valeurs).
- `allow_formula` (bouton `f(x)`) reste actif pour toute la session une fois activé : rien ne le
remet à zéro après un enregistrement.
- `role="grid"` posé (#154-A4) mais sans `aria-rowcount`/`aria-colcount`/`aria-selected`/
`aria-activedescendant` ni annonce des changements de cellule active.
## 4. Backlog #156 — sous-tâches
Légende : 🔴 P0 (défaut) · 🟡 P1 (productivité immédiate) · 🟢 P2 (fidélité/finitions) ·
effort en jours-homme (développement + tests).
### P0 — défauts (2-3 j)
- [x] **A1 — Sauvegarde `.csv` depuis la visionneuse (BUG-096).** Nom de feuille résolu avec
repli (feuille → `data.title`/`data.path`), le chemin d'enregistrement ne dépend plus de la
structure du classeur. *Vérifié :* test JSDOM « saving an edited csv PUTs /csv/save
(payload without xlsx_sheets) » (contre-preuve : échouait avec le `TypeError`).
- [x] **A2 — Chargement paresseux des `.xlsm` (BUG-097).** `GET …/xlsx/sheet` accepte `.xlsx`
**et** `.xlsm` ; les autres formats restent refusés (415). *Vérifié :*
`test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats`.
- [x] **A3 — Détection du délimiteur CSV (BUG-098).** `sniff_csv_delimiter()` partagé par la
lecture et la réécriture, qui conserve le délimiteur détecté. *Vérifié :* `TestCsvDelimiter`
(3 séparateurs + repli) et 3 tests client (lecture, écriture, guillemets).
- [x] **A4 — Sonde de perte par feuille (BUG-099).** Budget par feuille + plafond global ;
budget épuisé → clé `cached_values_unverified` (i18n FR/EN) au lieu d'un silence. *Vérifié :*
`TestXlsxCachedValueProbe` (3), dont la contre-preuve du silence antérieur.
### P1 — presse-papiers & clavier (4-6 j)- [x] **A5 — Presse-papiers de plage (livré le 2026-09-29).** Copier / couper / coller un bloc
(presse-papiers interne TSV, miroir système best-effort) via `Ctrl+C`/`Ctrl+X`/`Ctrl+V` et les
entrées du menu contextuel ; remplissage multi-cellules depuis la cellule active, bloc laissé
sélectionné, collage **texte** uniquement (R4) et annulable cellule par cellule ; un bloc plus
grand que la grille rendue est tronqué et signalé (`xlsx.paste_out_of_grid`). Couper efface la
source au collage, sauf en collage sur place.
*Vérifié :* JSDOM — `A1:B2` copié puis collé en `D5` donne `D5:E6`, bloc TSV Excel + retour
ligne final, 1×1 qui remplace la cellule, coupe → collage, menu contextuel, débordement
signalé, `E2E` (Playwright : copie/collage par le menu contextuel sur la fixture 520 lignes).
- [x] **A6 — Clavier complet (livré le 2026-09-29).** `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`,
`Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` ; undo/redo déplacés
dans le même handler priorisé et `Suppr` passe par `clearRange()` (donc **annulable**).
*Vérifié :* JSDOM — un test par raccourci, dont la contre-épreuve « `Suppr` pendant la saisie
supprime un caractère, pas la sélection » ; `E2E` (Ctrl+A, Ctrl+Home/End, Suppr).
- [x] **A7 — Zone Nom éditable & aide à la saisie (livré le 2026-09-29).** La zone Nom est un
`<input>` (`#xlsx-active-cell`) : `B12`, `A1:B3`, `$A$1`, `Feuille!A1` (changement d'onglet) ;
référence inconnue → message `xlsx.name_box_invalid` et adresse précédente restaurée. Le champ
de formule porte la liste des fonctions (`<datalist id="xlsx-function-list">`, `xlsx.fn_suggestions`).
*Vérifié :* JSDOM (atteindre, plage normalisée, refus d'une référence inconnue, bascule
d'onglet, liste de fonctions) ; `E2E` (zone Nom → `B12`).
### P2 — mise en forme, calcul, undo (5-7 j)
- [x] **A8 — Mise en forme en écriture (livré le 2026-09-30).** Bouton **Mise en forme** :
gras/italique/souligné/effacer, alignements, couleurs de police et de fond (sélecteur natif),
formats de nombre (général/nombre/pourcentage/devise/date/texte), fusion/défusion, volets
figés/libérés, largeur de colonne et hauteur de ligne, sur la sélection courante. Nouvelle route
`PUT …/xlsx/style` (`mutate_xlsx_style`) sur le modèle de `PUT …/xlsx/structure` : verrou,
`.tmp`/`os.replace`, backup, garde de perte (`force`), `If-Match`, garde anti-formule BUG-088,
plafond `MAX_STYLE_CELLS`. i18n FR/EN. *Vérifié :* `tests/test_xlsx_styles.py` (17 : rechargement,
fusions/tailles/volets, effacement, entrées invalides, CSV refusé, `if_match`, backup) et JSDOM
(application d'un format, refus d'un CSV, garde de perte).
- [x] **A9 — Calcul : décision documentée (livré le 2026-09-30).** Pas de moteur de formule ; le
gap est **annoncé dans l'UI** (toast « enregistrée comme texte » à l'enregistrement si une valeur
commence par `=` ou `@` sans le bouton `f(x)`), la garde anti-DDE reste. *Vérifié :* JSDOM
(l'avertissement apparaît, disparaît une fois `f(x)` activé).
- [x] **A10 — Undo/redo unifié (livré le 2026-09-30).** Entrées typées `cell|order|hidden|structure`,
piles **par fichier** (`_xlsxHistory`, 8 fichiers) conservées au re-rendu, filtre coalescé,
rejeu de structure silencieux, action destructive non annulable. *Vérifié :* JSDOM (édition →
effacement → tri annulés successivement, insertion de ligne rejouée, filtre annulé en un `Ctrl+Z`).
### P3 — sortie, robustesse, performances (4-6 j)
- [x] **A11 — Sortie & recherche (livré le 2026-09-30).** Bouton **Exporter** : CSV de la
**sélection** (sinon de la feuille, nom `Feuille-A1B2.csv`), Markdown, HTML et impression
(iframe hors écran) ; écart #153-A13 tranché. `runFind` parcourt **toutes** les feuilles avec
compteur `n/m · k feuilles` et activation de l'onglet correspondant. *Vérifié :* JSDOM
(sélection multi-cellules, feuille entière, 4 formats, compteur multi-feuilles) et E2E.
- [x] **A12 — Concurrence optimiste (livré le 2026-09-30).** `revision` (`mtime_ns` + taille)
renvoyée à la lecture (`xlsx_revision`) et acceptée en `If-Match`/`if_match` sur `xlsx/save`,
`xlsx/structure`, `xlsx/style` et `csv/save` ; unconflict produit un **409** `conflict`
(`details.reason = "stale_revision"`) et le bandeau « Réessayer » relit avant de rejouer.
*Vérifié :* `TestOptimisticConcurrency` (6) + JSDOM (version rafraîchie par la réponse, relecture
avant le retry).
- [x] **A13 — Performances & plafonds (livré le 2026-09-30).** Cache de `read_workbook_meta()` par
`(chemin, mtime_ns, taille)` (LRU 8), invalidé à chaque écriture : charger une fenêtre ne
rescanne plus le classeur. *Hors périmètre :* la pagination de l'axe des colonnes au-delà de
`MAX_COLS = 40` (documenté dans les limites du guide). *Vérifié :* `TestMetaCache` (4).
- [x] **A14 — Outils IA étendus (livré le 2026-09-30).** `SPREADSHEET_EXTENSIONS` =
`.xlsx`/`.xlsm`/`.csv` (`_spreadsheet_path`, `_read_grid`), `update_xlsx_cells`/`append_xlsx_rows`
adaptés, nouveaux outils `search_workbook` (READ), `analyze_range` (READ) et `edit_xlsx_structure`
(WRITE, **confirmation conservée**, refus d'un CSV). *Vérifié :*
`tests/test_spreadsheet_tools.py` (34, dont `TestXlsmSupport`, `TestCsvSupport`,
`TestSearchWorkbook`, `TestAnalyzeRange`, `TestEditXlsxStructure`).
## 5. Risques et sécurité
| # | Risque | Où | Traitement |
|---|---|---|---|
| R1 | Perte silencieuse de valeurs calculées (sonde plafonnée) | `xlsx_reader.inspect_workbook` | ✅ A4 — budget par feuille + clé `cached_values_unverified` (BUG-099) |
| R2 | Écrasement par un écrivain externe | `mutations`, routes write | ✅ A12 — `If-Match` + **409** `conflict`, retry qui relit |
| R3 | Réécriture CSV dans un délimiteur différent de la source | `xlsx_reader`, `mutations.save_csv_cells` | ✅ A3 — `sniff_csv_delimiter` partagé (BUG-098) |
| R4 | Collage d'un bloc : ne jamais injecter de HTML/`contenteditable` brut | `viewer.js` | ✅ A5 — insertion **texte** uniquement, échappée |
| R5 | Mise en forme en écriture : garder la garde anti-formule (BUG-088) et la garde de perte (BUG-085) | `mutations` | ✅ A8 — `mutate_xlsx_style` réutilise verrou, backup, swap atomique, garde de perte et `If-Match` |
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
- Chaque sous-tâche démarre par son **ID stable** (`#156-A<n>`) ; un **défaut** est ouvert comme
`BUG-NNN` dans `docs/ISSUES_TODOLIST.md` (A1→BUG-096, A2→BUG-097, A3→BUG-098, A4→BUG-099).
- Tout correctif arrive avec son **test de non-régression** (contre-preuve quand c'est possible).
- Frontend : `vanilla JS`, **zéro build**, `safeCreateIcons()`, **variables CSS**, **i18n FR+EN**
(`test_i18n_parity.py` vert).
- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans
`backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`.
- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée + index), cette fiche,
guide utilisateur i18n + README si impact utilisateur.
## 7. Historique
| Date | Événement |
|---|---|
| 2026-09-29 | Audit de complétude de l'éditeur Excel → ouverture de #156 et de **BUG-096 → BUG-099** ; défaut A1 reproduit en JSDOM (`TypeError` sur l'enregistrement d'un CSV, aucun `PUT …/csv/save` émis) |
| 2026-09-29 | **P1 livré** — A5 (presse-papiers de plage), A6 (clavier complet) et A7 (zone Nom éditable + aide à la saisie) : presse-papiers interne TSV (miroir système best-effort), collage texte/annulable, `Suppr`/`F2`/`Ctrl+flèches`/`PgUp-PgDn`/`Ctrl+A`/`Maj+Entrée`, zone Nom `<input>` et liste de fonctions i18n FR/EN. Tests : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux) + E2E Playwright |
| 2026-09-30 | **P2 livré** — A8 (mise en forme en écriture : bouton Mise en forme, `PUT …/xlsx/style`, verrou/backup/garde de perte/`If-Match`), A9 (décision « pas de moteur de formule », annoncée dans l'UI), A10 (undo/redo unifié, piles conservées au re-rendu). Tests : `test_xlsx_styles.py` 17 + JSDOM `xlsx-viewer.test.mjs` |
| 2026-09-30 | **P3 livré** — A11 (export sélection / Markdown / HTML / impression, recherche multi-feuilles), A12 (`If-Match` + 409 réparable), A13 (cache des métadonnées par `mtime`), A14 (outils IA `.xlsm`/`.csv`, recherche, analyse de plage, structure). Tests : `test_spreadsheet_tools.py` 34, `test_xlsx_viewer.py` (`TestOptimisticConcurrency`, `TestMetaCache`), JSDOM |
| 2026-09-30 | **Backlog #156 clôturé** — P0 → P3 livrés (A1 → A14, dont 4 défauts **BUG-096 → BUG-099**). Suite complète : `pytest tests/` 1525 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 107/107, E2E `xlsx` 10/10 |
| 2026-09-29 | **P0 livré** — A1-A4 corrigés et testés (BUG-096 → BUG-099) : sauvegarde `.csv` depuis la visionneuse, fenêtres `.xlsm`, délimiteur CSV détecté/réutilisé, sonde de perte par feuille + signal `cached_values_unverified`. Suite complète : 1490 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 61/61 |
+149
View File
@@ -0,0 +1,149 @@
# #154 — Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
> **Statut :** ✅ **terminé** — Lots 1 → 5 livrés le 2026-09-29 (A1-A5)
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5 ✅)
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
> l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.**
---
## 1. Objectif
Rendre la vue tableur d'ObsiGate **intuitive, moderne et hautement utilisable** en s'inspirant
des standards du marché (Excel, Google Sheets, Airtable), **sans renier les contraintes du
dépôt** : thème sombre, `vanilla JS`, **zéro framework, zéro build npm**
([`AGENTS.md`](../../AGENTS.md)). La refonte est **organique** : on améliore la coquille
existante (`frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css`), on ne réécrit pas
la grille ni le backend.
## 2. Audit UX — les 3 problèmes majeurs
| # | Problème | Constat | Résolution |
|---|---|---|---|
| **P1** | **Aucune hiérarchie ni regroupement des commandes** | Rangée plate de boutons de poids identique (`viewer.js` toolbar historique) ; « Tableau de bord » *prependé* au runtime ; barre de formule réduite à un `input`. | **Barre de commandes groupée** (Formules · Insertion · Vue · Fichier), bouton **Enregistrer primaire**, état *dirty*. |
| **P2** | **Grille sans affordances : en-têtes = cellules** | Contraste faible entre `th` et `td`, pas de zébrage, pas de survol lisible, cellule active peu marquée. | **Tokens de grille** + en-têtes plus clairs/interactifs, zébrage, survol, cellule active en bordure accent. |
| **P3** | **États avancés traités comme du contenu** | Dashboard *inline* qui pousse la grille, troncature/lecture seule/formules non recalculées sans emplacement dédié, `confirm()`/`prompt()` natifs. | **Couche UI dédiée** : bandeaux d'état + **inspecteur droit** (Lot 3) + dialogues thémés (Lot 2). |
## 3. Architecture cible de l'écran
```
┌──────────────────────────────────────────────────────────────────────────┐
│ BARRE APP (globale, existante) │
├─────────────┬────────────────────────────────────────────────────────────┤
│ │ A. RUBAN — groupes Formules · Insertion · Vue · Fichier │
│ EXPLORATEUR│ B. BARRE DE FORMULE — [ A1 ] fx [ … ] │
│ DE FICHIERS│ C. BANDEAUX D'ÉTAT — lecture seule · formules non recalculées│
│ (sidebar) ├──────────────────────────────────────────────┬─────────────┤
│ │ D. GRILLE (en-têtes clairs, zébrage, survol) │ E. INSPECTEUR│
│ │ │ (dashboard + │
│ │ │ IA, repliable)│
│ ├───────────────────────────────────────────────┤ │
│ │ F. ONGLETS FEUILLES + « + » · 500/522 │ │
└─────────────┴───────────────────────────────────────────────┴─────────────┘
```
- **A. Ruban** : groupes d'actions avec séparateurs ; actions de style désactivées (styles lus,
pas écrits). Bouton **Enregistrer** en accent, désactivé si rien de *dirty*.
- **B. Barre de formule** : zone nom + champ + badge de session `f(x)`.
- **C. Bandeaux d'état** : empilables, non bloquants ; portent lecture seule et
« formules non recalculées ».
- **D. Grille** : rendue côté serveur (`backend/xlsx_reader.py`), habillée et câblée par le front.
- **E. Inspecteur** : **à venir (Lot 3)** — Tableau de bord + Assistant IA dans un panneau droit
repliable (réutilise `PaneManager` pour le détachement), au lieu du dashboard *inline* actuel.
- **F. Onglets feuilles** : permanents (même à une seule feuille) + bouton « + ».
## 4. Plan par lots (incréments livrables)
### Lot 1 — Coquille : ruban groupé, onglets permanents, badges d'état ✅ *(2026-09-29)*
- **A1.1** Barre de commandes groupée (`.xlsx-cmdbar`, `.xlsx-cmd-group`, `.xlsx-cmd-sep`,
`.xlsx-save-primary`), IDs existants conservés (compatibilité tests JSDOM/E2E).
- **A1.2** Onglets de feuilles **toujours rendus** (non-CSV) + bouton **`+`** `.xlsx-tab-add`
→ `sheet_add` (même pipeline `putStructure`).
- **A1.3** Badges d'état : `.xlsx-status-pill` **lecture seule** (`.xls`/`.ods`) et
**formules non recalculées** (non-CSV).
- **A1.4** Tokens de grille (`--grid-bg`, `--grid-header-bg`, `--grid-header-text`,
`--grid-border`, `--grid-zebra`) déclinés dark/light + affordances (en-têtes clairs,
zébrage, survol, cellule active solide, cellule *dirty* prioritaire au survol).
### Lot 2 — Dialogues thémés & feedback ✅ *(2026-09-29)*
- **A2.1** Helpers génériques **`showConfirm()` / `showPrompt()`** (`frontend/js/ui.js`), promise-based,
réutilisant les classes `.obsigate-modal-*` (fini `window.confirm()` / `window.prompt()`).
- **A2.2** La visionneuse XLSX utilise ces dialogues pour les actions de structure (ajouter /
renommer / dupliquer / supprimer feuille, insérer / supprimer ligne et colonne) et pour la
confirmation de perte (409 `xlsx_lossy_content`).
- **A2.3** **Conflit de sauvegarde (409 `conflict`)** : bandeau **non bloquant** `.xlsx-banner-conflict`
avec bouton **Réessayer** — les modifications sont conservées.
- **A2.4** Indicateur *dirty* sur le bouton **Enregistrer** et sur l'onglet de la feuille concernée.
### Lot 3 — Inspecteur droit ✅ *(2026-09-29)*
- **A3.1** Le **Tableau de bord** quitte le flux de la grille pour un **panneau droit repliable**
(`.xlsx-inspector`) : la grille reste visible à côté (`.xlsx-body` = `.xlsx-main` + inspecteur).
- **A3.2** En-tête d'inspecteur : titre, bouton **Assistant IA** (ouvre le panneau latéral global
existant) et bouton de fermeture.
- **A3.3** Responsive : sous 900 px, l'inspecteur passe sous la grille.
### Lot 4 — Interactions : undo/redo, défilement, accessibilité ✅ *(2026-09-29)*
- **A4.1** **Undo/redo** local (pile de commandes) pour les éditions de cellules : boutons
**Annuler / Rétablir** dans le ruban + raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`.
- **A4.2** Chargement des fenêtres via **`IntersectionObserver`** (repli sur l'écouteur de
défilement pour les environnements sans IO).
- **A4.3** Sémantique **ARIA** : `role="grid"` / `row` / `gridcell` / `columnheader` / `rowheader`.
### Lot 5 — Découpage modulaire & finitions ✅ *(2026-09-29)*
- **A5.1** Extraction des parties pures/sans état du monolithe `renderXlsxViewer` dans
`frontend/js/xlsx/` : **`refs.js`** (`parseRef`, `columnName`, `findTd`, `sheetOfRef`,
`firstCellOfRange`), **`command-bar.js`** (`buildCommandBar` : onglets + ruban + pastilles),
**`dashboard.js`** (`renderDashboardLoading`, `renderDashboardHtml`). Le noyau **avec état**
(orchestration DOM, édition, écouteurs) reste dans `viewer.js` : l'extraction est volontairement
limitée aux unités sans état, à comportement constant et sous couvert des tests.
- **A5.2** Lien **dashboard → grille** : cliquer une plage nommée sélectionne et révèle sa
première cellule (change d'onglet si la plage est sur une autre feuille).
- **A5.3** Inspecteur **redimensionnable** (poignée gauche, largeur 260–640 px, restaurée par
session via `localStorage`).
- **A5.4** `SW_VERSION` incrémenté (`v28`) et nouveaux modules ajoutés au pré-cache du service
worker.
> **Hors périmètre (documenté) :** le détachement de l'inspecteur en split view
> (`PaneManager.splitRight()`) n'est pas retenu — l'inspecteur est intrinsèquement lié à la
> visionneuse d'un document ; un split générique ouvrirait un second contexte sans le classeur.
> À réévaluer si un usage concret apparaît.
## 5. Recommandations techniques (contrainte « zéro build »)
| Option Data Grid | Build | Licence | Verdict |
|---|---|---|---|
| AG Grid Community | npm + bundler | MIT | ❌ viole « zéro build », réécrit le DOM, casse les tests |
| Handsontable | npm + bundler | **commerciale** | ❌ licence non libre |
| TanStack Table | headless (importable esm.sh) | MIT | ⚠ possible sans build, mais *headless* → gain limité |
| **Grille maison sur `<table>`** | aucun | — | ✅ **recommandé** (conserve DOM, CSP, i18n, tests) |
- **Performance** : ne pas ré-écrire tout le DOM ; réutiliser le pipeline `appendWindow` ;
`content-visibility:auto; contain:strict` sur les lignes ; garder la pagination serveur
(500 × 40 = 20 000 cellules/feuille) plutôt qu'une virtualisation client complexe.
- **CSP** : `main.py` autorise déjà `esm.sh` — une lib *headless* reste possible en Lot 4 si
un vrai besoin de modèle de colonnes apparaît.
## 6. Critères d'acceptation (par lot)
- **Lot 1** : une feuille unique affiche son onglet + « + » ; « + » ajoute une feuille via
`PUT …/xlsx/structure` et re-rend ; `.xls`/`.ods` montrent le badge « lecture seule » (pas de
« + », pas de structure, pas de dashboard) ; un `.xlsx` montre le badge « formules non
recalculées », un `.csv` non ; les tests JSDOM/E2E existants restent verts + nouveaux tests.
- Lots suivants : définis à leur ouverture.
## 7. Historique
| Date | Événement |
|---|---|
| 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) |
| 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) |
| 2026-09-29 | **Lot 3** livré : le Tableau de bord passe dans un **inspecteur droit repliable** (grille toujours visible), en-tête d'inspecteur avec entrée **Assistant IA** et fermeture, responsive < 900 px |
| 2026-09-29 | **Lot 4** livré : **undo/redo** (boutons + `Ctrl+Z`/`Ctrl+Maj+Z`/`Ctrl+Y`), chargement par `IntersectionObserver`, **ARIA** `role="grid"` ; le découpage modulaire est reporté en A5 |
| 2026-09-29 | **Lot 5** livré (clôture #154) : extraction des unités sans état dans `frontend/js/xlsx/*` (`refs.js`, `command-bar.js`, `dashboard.js`), lien **dashboard → grille**, inspecteur **redimensionnable**, `SW_VERSION` v28 + pré-cache. Split view écarté (documenté) |
+260
View File
@@ -0,0 +1,260 @@
# #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 :** ✅ **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`](../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 | 🟢 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
- [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) — 🟢 livré le 2026-09-28 (A5 → A12)
- [x] **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).
- [x] **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`. *Livré (v2.33.0) :* `backend/tools/spreadsheets.py`.
*Vérifié :* `tests/test_spreadsheet_tools.py` (17).
- [x] **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`). *Livré (v2.34.0).*
- [x] **A8 — `thead` sticky + 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: auto` sur les
numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose
`total_rows`/`total_cols` (dimensions déclarées), `max_rows`/`max_cols` (plafonds) et
`truncated` — 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és
`xlsx.truncated_*` FR/EN. *Vérifié :* `TestXlsxTruncationNotice` (4), `xlsx-viewer.test.mjs`
(4 nouveaux), E2E sur `test_vault/sample-xlsx-large.xlsx` (520 lignes).
- [x] **A9 — Chargement paresseux par feuille (côté API).** Endpoint
`GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`,
exemple dans `backend/openapi_docs.py`) : une fenêtre de 1 à 1 000 lignes (plafond
`MAX_WINDOW_ROWS`, `limit>1000` → 422), `has_more` pour paginer, valeurs calculées A12
incluses. Les numéros de ligne et `data-cell` restent 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 ».
- [x] **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 (`setupCell` factorisé : 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.mjs` 19/19 (5 nouveaux), **contre-preuve** (désactiver
`wireLazyRows` → 5 tests échouent), E2E « le bouton charger la suite ajoute les lignes
cachées » sur `sample-xlsx-large.xlsx` (A520 visible et éditable après clic).
- [x] **A10 — Types et formats de saisie.** `_coerce_xlsx_value()` reconnait les booléens
(`true`/`vrai`/`oui`/`yes` et leurs négatifs) et les dates FR `JJ/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).
- [x] **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. *Vérifié :*
35 tests JSDOM (le job CI `lint` lance `node xlsx-viewer.test.mjs`) et 9 E2E
chromium-desktop ; la couverture a grandi avec chaque sous-tâche (A5/A8 → P2).
- [x] **A12 — Valeurs calculées.** La valeur en cache s'affiche sous la formule dans un
`<span class="xlsx-cached">`. La 2ᵉ lecture `data_only=True` n'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_title` FR/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
- [x] **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`).
- [x] **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).
- [x] **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. *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).
- [x] **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`/`.ods` lecture seule (xlrd/odfpy), `.csv`
éditable et réécrit RFC 4180. *Vérifié :* `tests/test_xlsx_formats.py` (12).
- [x] **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 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 |
| 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é** |
+7 -4
View File
@@ -1196,16 +1196,15 @@
>Ctrl+Shift+Space</span
>
<span class="qh-desc"
>Palette de commandes</span
>Palette de fichiers (nav.
rapide)</span
>
</div>
<div class="qh-row">
<span class="qh-kbd"
>Ctrl+Alt+Space</span
>
<span class="qh-desc"
>Palette de fichiers (nav.
rapide)</span
<span class="qh-desc">Palette de commandes</span
>
</div>
<div class="qh-row">
@@ -3695,6 +3694,10 @@
<strong>Facettes vaults</strong><span data-i18n="help.desc_facet_vaults">: Compteurs
par vault</span>
</li>
<li>
<strong>Facettes extensions</strong><span data-i18n="help.desc_facet_extensions">:
Compteurs par extension de fichier dans les résultats</span>
</li>
<li>
<strong>Tri par pertinence</strong><span data-i18n="help.desc_9fec424d"> : Score
TF-IDF (défaut)</span></li>
+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();
}
+2
View File
@@ -60,12 +60,14 @@ const MUTATING_TOOLS = new Set([
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
'delete_file', 'delete_directory', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
'update_xlsx_cells', 'append_xlsx_rows',
]);
// Subset carrying a concrete `vault` + `path`: the displayed document is
// reloaded from disk so an open viewer/editor reflects the agent's write.
const FILE_WRITE_TOOLS = new Set([
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
'update_xlsx_cells', 'append_xlsx_rows',
]);
/**
+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"],
],
},
{
+36 -7
View File
@@ -14,17 +14,26 @@ function getCurrentFile() {
return null;
}
// ── Query parser: +word = required, -word = exclude, word = required ──
// ── Query parser: +word = required, -word = exclude, word = required, ext:xxx, vault:xxx, tag:xxx ──
function parseQuery(raw) {
const positive = [];
const negative = [];
const filters = { ext: null, vault: null, tag: null };
const parts = (raw || '').split(/\s+/).filter(Boolean);
for (const p of parts) {
if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
const lower = p.toLowerCase();
if (lower.startsWith('ext:')) {
filters.ext = lower.slice(4).trim().replace(/^\./, '').toLowerCase();
} else if (lower.startsWith('vault:')) {
filters.vault = lower.slice(6).trim();
} else if (lower.startsWith('tag:') || (lower.startsWith('#') && p.length > 1)) {
const tag = lower.startsWith('tag:') ? lower.slice(4) : lower.slice(1);
if (tag) filters.tag = tag.trim();
} else if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
else if (p.startsWith('+')) positive.push(p.slice(1).toLowerCase());
else positive.push(p.toLowerCase());
}
return { positive, negative };
return { positive, negative, filters };
}
// ── DOM / state ──
@@ -407,8 +416,8 @@ function createDOM() {
_overlay.className = 'command-palette-overlay';
_overlay.tabIndex = -1;
_overlay.innerHTML = `<div class="command-palette">
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure ext:md vault:nom tag:monTag)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot ext:xxx vault:nom tag:xxx) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
<div class="cp-results"></div>
<div class="cp-footer"><span>↑↓ naviguer</span><span>↵ ouvrir/entrer</span><span>⌫ monter</span><span>Esc fermer</span></div></div>`;
_input = _overlay.querySelector('.cp-input');
@@ -488,11 +497,14 @@ async function searchFiles(rawQ) {
return;
}
const { positive, negative } = parseQuery(rawQ);
const { positive, negative, filters } = parseQuery(rawQ);
try {
const q = positive.join(' ');
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=all`);
let vaultFilter = 'all';
if (filters.vault) vaultFilter = filters.vault;
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=${encodeURIComponent(vaultFilter)}`);
let sug = d.suggestions || [];
// Client-side negative filter
@@ -506,6 +518,23 @@ async function searchFiles(rawQ) {
});
}
// Filter by extension if specified
if (filters.ext) {
sug = sug.filter(s => {
const name = s.path.split('/').pop() || '';
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
return ext === filters.ext;
});
}
// Filter by tag if specified
if (filters.tag) {
sug = sug.filter(s => {
const tags = s.tags || [];
return tags.some(t => t.toLowerCase() === filters.tag.toLowerCase());
});
}
if (sug.length === 0) {
_items = [{ type:'_browse', label:'📂 Parcourir les répertoires...', sublabel:'Aucun résultat — naviguer manuellement', act:()=>startBrowse('open-file') }];
} else {
+50 -4
View File
@@ -1034,8 +1034,27 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
}
// Facets panel
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) {
const facetsDiv = el("div", { class: "search-facets" });
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0 || Object.keys(data.facets.extensions || {}).length > 0)) {
const collapsed = localStorage.getItem("obsigate_facets_collapsed") === "1";
const facetsDiv = el("div", { class: "search-facets", "data-collapsed": collapsed ? "true" : "false" });
// Discreet collapse toggle for the whole panel (vaults + tags + extensions)
const toggleBtn = el("button", {
class: "search-facets__toggle",
type: "button",
"aria-expanded": String(!collapsed),
title: collapsed ? t("search.facets_expand") : t("search.facets_collapse"),
});
toggleBtn.innerHTML = '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
toggleBtn.addEventListener("click", () => {
const nowCollapsed = facetsDiv.getAttribute("data-collapsed") !== "true";
facetsDiv.setAttribute("data-collapsed", nowCollapsed ? "true" : "false");
toggleBtn.setAttribute("aria-expanded", String(!nowCollapsed));
toggleBtn.title = nowCollapsed ? t("search.facets_expand") : t("search.facets_collapse");
localStorage.setItem("obsigate_facets_collapsed", nowCollapsed ? "1" : "0");
});
facetsDiv.appendChild(toggleBtn);
const facetsBody = el("div", { class: "search-facets__body" });
// Vault facets
const vaultFacets = data.facets.vaults || {};
@@ -1056,7 +1075,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
});
group.appendChild(item);
}
facetsDiv.appendChild(group);
facetsBody.appendChild(group);
}
// Tag facets
@@ -1075,9 +1094,36 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
});
group.appendChild(item);
}
facetsDiv.appendChild(group);
facetsBody.appendChild(group);
}
// Extension facets
const extFacets = data.facets.extensions || {};
if (Object.keys(extFacets).length > 0) {
const group = el("div", { class: "search-facets__group", "data-facet": "extensions" });
const label = el("span", { class: "search-facets__label" });
label.textContent = "Extensions";
group.appendChild(label);
for (const [extName, count] of Object.entries(extFacets)) {
const item = el("span", { class: "search-facets__item" });
// textContent: extension suffixes come from filenames (untrusted), never innerHTML
item.appendChild(document.createTextNode(`.${extName} `));
const facetCount = el("span", { class: "facet-count" });
facetCount.textContent = String(count);
item.appendChild(facetCount);
item.addEventListener("click", () => {
const input = document.getElementById("search-input");
// Replace any existing ext: operator, then trigger a new search
const current = input.value.replace(/ext:\S+\s*/gi, "").trim();
input.value = current + " ext:" + extName;
_triggerAdvancedSearch(input.value);
});
group.appendChild(item);
}
facetsBody.appendChild(group);
}
facetsDiv.appendChild(facetsBody);
area.appendChild(facetsDiv);
}
+82
View File
@@ -1143,6 +1143,88 @@ const FileOperations = {
};
// ---------------------------------------------------------------------------
// Generic themed dialogs (#154-A2)
// ---------------------------------------------------------------------------
// Promise-based replacements for window.confirm() / window.prompt() so the
// Excel viewer's structure actions and lossy-write confirmations stay inside
// the app theme (and are keyboard accessible) instead of native dialogs.
function _closeDialog(overlay, resolve, value) {
overlay.classList.remove("active");
if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey);
setTimeout(() => overlay.remove(), 200);
resolve(value);
}
function _openDialog(innerHtml) {
const overlay = document.createElement("div");
overlay.className = "obsigate-modal-overlay";
const modal = document.createElement("div");
modal.className = "obsigate-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.innerHTML = innerHtml;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add("active"), 10);
return { overlay, modal };
}
/** Themed replacement for window.confirm(). Resolves to a boolean. */
export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body"><p class="modal-confirm-text">${escapeHtml(message)}</p></div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn ${danger ? "danger" : "primary"}" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false));
overlay._onKey = (e) => {
if (e.key === "Escape") done(false);
else if (e.key === "Enter") done(true);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20);
});
}
/** Themed replacement for window.prompt(). Resolves to the string (or null). */
export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body">
<div class="modal-form-group">
${message ? `<label class="modal-label">${escapeHtml(message)}</label>` : ""}
<input type="text" class="modal-input" data-dialog="input" spellcheck="false"
value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}" />
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn primary" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const input = modal.querySelector('[data-dialog="input"]');
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null));
overlay._onKey = (e) => {
if (e.key === "Escape") done(null);
else if (e.key === "Enter") done(input.value);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => { input.focus(); input.select(); }, 20);
});
}
// ---------------------------------------------------------------------------
// Find in Page Manager
// ---------------------------------------------------------------------------
+2279 -30
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
/* ObsiGate — XLSX command bar builder (#154-A5).
*
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
* is testable and the viewer only wires behaviour.
*/
import { t } from '../i18n.js';
import { escapeHtml } from '../utils.js';
/**
* @param {object} ctx
* @param {Array<{name: string}>} ctx.sheets
* @param {boolean} ctx.isCsv
* @param {boolean} ctx.readOnly
* @param {boolean} ctx.editable - not a CSV and not read-only
* @returns {{ tabs: string, actionsHtml: string, statusBar: string }}
*/
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
// as a real affordance; an editable workbook gets an explicit “+” button.
const tabs = isCsv
? ""
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
).join("")}${editable
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
: ""}</div>`;
// Status pills make the viewer's limits visible up front.
const statusPills = [
readOnly
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
: "",
!isCsv
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
: "",
].filter(Boolean).join("");
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
// Command groups, separated by thin rules.
const actionGroups = [];
if (!readOnly) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
<i data-lucide="undo-2" style="width:14px;height:14px"></i>
</button>
<button class="btn-action" id="xlsx-redo-btn" type="button" title="${escapeHtml(t("xlsx.redo"))}" disabled>
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
</button>
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
<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>
</span>`);
}
if (editable) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}">
<i data-lucide="paintbrush" style="width:14px;height:14px"></i>
</button>
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
</button>
</span>`);
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
}
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
</button>
<span class="export-dropdown xlsx-export-dropdown">
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}">
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
</button>
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none">
<button class="export-menu-item" type="button" data-xlsx-export="md">${escapeHtml(t("xlsx.export_md"))}</button>
<button class="export-menu-item" type="button" data-xlsx-export="html">${escapeHtml(t("xlsx.export_html"))}</button>
<button class="export-menu-item" type="button" data-xlsx-export="print">${escapeHtml(t("xlsx.print"))}</button>
</div>
</span>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
</span>`);
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
return { tabs, actionsHtml, statusBar };
}
+85
View File
@@ -0,0 +1,85 @@
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
*
* Builds a small menu anchored at a screen position, closes it on outside
* click / Escape / window blur. Labels and callbacks come from the caller, so
* no i18n lives here.
*/
let _menu = null;
let _openedAt = 0;
let _dismissBound = false;
export function closeContextMenu() {
if (_menu) {
_menu.remove();
_menu = null;
}
}
export function isContextMenuOpen() {
return _menu !== null;
}
function bindDismiss() {
if (_dismissBound) return;
_dismissBound = true;
document.addEventListener("click", (e) => {
if (!_menu) return;
// Ignore the very gesture that opened the menu (touch long-press can emit
// a click right after).
if (Date.now() - _openedAt < 250) return;
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
closeContextMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeContextMenu();
});
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
}
/**
* @param {number} x - viewport X of the pointer
* @param {number} y - viewport Y of the pointer
* @param {Array<{label?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @returns {HTMLElement} the menu element
*/
export function openContextMenu(x, y, items) {
bindDismiss();
closeContextMenu();
const menu = document.createElement("div");
menu.className = "xlsx-context-menu";
menu.setAttribute("role", "menu");
for (const item of items) {
if (item.separator) {
const sep = document.createElement("div");
sep.className = "xlsx-context-sep";
menu.appendChild(sep);
continue;
}
const btn = document.createElement("button");
btn.type = "button";
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
btn.disabled = Boolean(item.disabled);
btn.addEventListener("click", () => {
closeContextMenu();
if (!item.disabled && item.onClick) item.onClick();
});
menu.appendChild(btn);
}
document.body.appendChild(menu);
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
// which simply leaves the requested position).
const rect = menu.getBoundingClientRect();
const vw = (typeof window !== "undefined" && window.innerWidth) || 1024;
const vh = (typeof window !== "undefined" && window.innerHeight) || 768;
const left = Math.max(4, Math.min(x, vw - rect.width - 4));
const top = Math.max(4, Math.min(y, vh - rect.height - 4));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
_menu = menu;
_openedAt = Date.now();
return menu;
}
+53
View File
@@ -0,0 +1,53 @@
/* ObsiGate — XLSX dashboard renderer (#154-A5).
*
* Pure string builders for the workbook dashboard inspector (named ranges, KPI
* cards). Extracted from frontend/js/viewer.js; the viewer handles the fetch,
* the inspector host and the range → grid link.
*/
import { t } from '../i18n.js';
import { escapeHtml } from '../utils.js';
export function renderDashboardLoading() {
return `<div class="xlsx-dashboard-loading">…</div>`;
}
/**
* @param {object} dash - payload of GET …/xlsx/dashboard
* @returns {string} the inspector body markup
*/
export function renderDashboardHtml(dash) {
const namedRanges = dash.named_ranges || [];
const sheets = dash.sheets || [];
const rangeRows = namedRanges.map((r) =>
`<tr class="xlsx-range-row" data-ref="${escapeHtml(r.ref || "")}"><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
).join("");
const kpiCards = sheets.map((s) => {
const cards = (s.kpi || []).map((k) =>
`<span class="xlsx-kpi"><span class="xlsx-kpi-label">${escapeHtml(k.label)}</span><span class="xlsx-kpi-value">${escapeHtml(String(k.value))}</span></span>`,
).join("");
return `<div class="xlsx-kpi-sheet">
<h4>${escapeHtml(s.name)}</h4>
<p class="xlsx-kpi-meta">${escapeHtml(t("xlsx.dashboard_stats", {
cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric,
}))}</p>
<div class="xlsx-kpi-cards">${cards || "<span class=\"xlsx-kpi-empty\">—</span>"}</div>
</div>`;
}).join("");
return `
<div class="xlsx-dashboard-head">
<h3><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.dashboard_title"))}</h3>
<span class="xlsx-dashboard-objects">
${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))}
· ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))}
</span>
</div>
${namedRanges.length || sheets.length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
${namedRanges.length ? `
<table class="csv-table xlsx-ranges-table"><thead><tr>
<th>${escapeHtml(t("xlsx.dashboard_nr_name"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_scope"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_ref"))}</th>
</tr></thead><tbody>${rangeRows}</tbody></table>` : ""}
<div class="xlsx-kpi-grid">${kpiCards}</div>
<p class="xlsx-dashboard-hint">${escapeHtml(t("xlsx.dashboard_hint"))}</p>`;
}
+45
View File
@@ -0,0 +1,45 @@
/* ObsiGate — XLSX A1 reference helpers (#154-A5).
*
* Pure functions (no DOM, no i18n): safe to unit-test in Node and shared by the
* viewer. Extracted from frontend/js/viewer.js without behaviour change.
*/
/** Parse an A1 reference ("B12") into its 1-based (row, col) parts. */
export function parseRef(ref) {
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
if (!m) return null;
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return { row: Number(m[2]), col };
}
/** Column number (1-based) → letters ("A", "Z", "AA"). */
export function columnName(col) {
let name = "";
while (col > 0) {
const rem = (col - 1) % 26;
name = String.fromCharCode(65 + rem) + name;
col = Math.floor((col - 1) / 26);
}
return name;
}
/** Find a rendered cell inside a sheet panel by (row, col). */
export function findTd(panel, row, col) {
return panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
}
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
export function sheetOfRef(ref) {
const s = String(ref || "");
if (!s.includes("!")) return null;
return s.split("!")[0].replace(/^'|'$/g, "");
}
/** First cell of a range ref ("Data!$A$1:$B$5" → "A1"), or null. */
export function firstCellOfRange(ref) {
const s = String(ref || "");
const body = s.includes("!") ? s.split("!").pop() : s;
const m = /([A-Za-z]+)\$?(\d+)/.exec(body);
return m ? `${m[1].toUpperCase()}${m[2]}` : null;
}
+146
View File
@@ -1101,6 +1101,7 @@
"help.desc_f952db17": "Refresh tokens : Cookies HttpOnly avec rotation automatique",
"help.desc_fa126609": "Converts the document structure into an Obsidian Canvas (visual cards).",
"help.desc_facet_vaults": ": Counters by vault",
"help.desc_facet_extensions": ": Counters by file extension in the results",
"help.desc_fc9391f3": "ObsiGate sends an HTTP POST to your webhook URL with event details.",
"help.desc_fdeaf803": ": Each file type has its icon (markdown, code, etc.)",
"help.desc_file_suggest": ": File title suggestions",
@@ -1608,6 +1609,8 @@
"search.exclude_placeholder": "Exclude: vendor/*, *.lock",
"search.facet_tags": "Tags",
"search.facet_vaults": "Vaults",
"search.facets_collapse": "Collapse facets",
"search.facets_expand": "Expand facets",
"search.files_filtered": "{count} file(s) with filters",
"search.filter_path_title": "Path filters (Alt-F)",
"search.filters": "Filters",
@@ -1823,6 +1826,142 @@
"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.lossy_confirm_btn": "Save anyway",
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
"xlsx.conflict_retry": "Retry",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"xlsx.undo": "Undo the last change",
"xlsx.redo": "Redo the change",
"xlsx.cached_value_title": "Last value calculated by Excel",
"xlsx.tabs_add_sheet": "Add a sheet",
"xlsx.readonly_badge": "Read-only",
"xlsx.readonly_hint": "This format (.xls / .ods) cannot be edited in ObsiGate — convert it to .xlsx to edit.",
"xlsx.formulas_note": "Formulas not recalculated",
"xlsx.formulas_note_title": "ObsiGate shows the formula as stored: Excel recalculates it on open. Dependent cells do not refresh on screen.",
"xlsx.truncated_title": "Truncated sheet",
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
"xlsx.truncated_hint": "Cells outside the displayed area cannot be edited here; the workbook is unchanged.",
"xlsx.load_more": "Load more",
"xlsx.loading_more": "Loading…",
"xlsx.load_error": "Could not load the remaining rows",
"xlsx.formula_bar_placeholder": "Active cell content",
"xlsx.active_cell": "Cell",
"xlsx.name_box_label": "Name Box — jump to a cell or a range",
"xlsx.name_box_hint": "Type a reference (B12, A1:B3) then Enter to jump there",
"xlsx.name_box_invalid": "Unknown reference: {ref}",
"xlsx.copy": "Copy",
"xlsx.cut": "Cut",
"xlsx.paste": "Paste",
"xlsx.paste_empty": "Clipboard empty or unreadable",
"xlsx.paste_out_of_grid": "Part of the pasted block is outside the displayed grid and was skipped",
"xlsx.fn_suggestions": "SUM;AVERAGE;COUNT;COUNTA;MIN;MAX;IF;VLOOKUP;XLOOKUP;SUMIF;SUMIFS;COUNTIF;CONCAT;LEFT;RIGHT;MID;TODAY;NOW;ROUND;ABS;SQRT",
"xlsx.formula_stored_as_text": "{n} value(s) starting with “=” or “@” saved as text — turn on the f(x) button to write real formulas",
"xlsx.find_placeholder": "Search in the workbook…",
"xlsx.find_prev": "Previous",
"xlsx.find_next": "Next",
"xlsx.find_case": "Match case",
"xlsx.find_no_match": "No match",
"xlsx.find_count": "{index}/{count}",
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
"xlsx.csv_export": "Export the sheet or the selection as CSV",
"xlsx.export_btn": "Export",
"xlsx.export_title": "Export the selection or the sheet",
"xlsx.export_md": "Export as Markdown",
"xlsx.export_html": "Export as HTML",
"xlsx.print": "Print",
"xlsx.export_scope_sheet": "the sheet",
"xlsx.export_scope_selection": "the selection",
"xlsx.export_done": "Exported {scope}",
"xlsx.format_btn": "Formatting",
"xlsx.format_bold": "Bold",
"xlsx.format_italic": "Italic",
"xlsx.format_underline": "Underline",
"xlsx.format_clear": "Clear formatting",
"xlsx.format_align_left": "Align left",
"xlsx.format_align_center": "Center",
"xlsx.format_align_right": "Align right",
"xlsx.format_font_color": "Text colour",
"xlsx.format_fill_color": "Fill colour",
"xlsx.format_num_general": "General format",
"xlsx.format_num_number": "Number (2 decimals)",
"xlsx.format_num_percent": "Percentage",
"xlsx.format_num_currency": "Currency (€)",
"xlsx.format_num_date": "Date (DD/MM/YYYY)",
"xlsx.format_num_text": "Text",
"xlsx.format_merge": "Merge cells",
"xlsx.format_unmerge": "Unmerge cells",
"xlsx.format_merge_needs_range": "Select at least two cells to merge",
"xlsx.format_freeze": "Freeze panes here",
"xlsx.format_unfreeze": "Unfreeze panes",
"xlsx.format_col_width": "Column width…",
"xlsx.format_col_prompt": "Width of column {col} (0 to 255)",
"xlsx.format_row_height": "Row height…",
"xlsx.format_row_prompt": "Height of row {row} (0 to 409)",
"xlsx.format_applied": "Formatting saved",
"xlsx.format_error": "Formatting failed",
"xlsx.sort_asc": "Sort column A→Z",
"xlsx.sort_desc": "Sort column Z→A",
"xlsx.sort_applied": "Sort applied on {col} — display only, the workbook is unchanged",
"xlsx.sort_reset": "Reset sort and filter",
"xlsx.filter_placeholder": "Filter rows…",
"xlsx.structure_btn": "Sheet structure",
"xlsx.structure_title": "Edit the workbook structure",
"xlsx.sheet_add": "Add a sheet",
"xlsx.sheet_rename": "Rename the current sheet",
"xlsx.sheet_duplicate": "Duplicate the current sheet",
"xlsx.sheet_delete": "Delete the current sheet",
"xlsx.row_insert": "Insert a row above",
"xlsx.row_delete": "Delete the active cell's row",
"xlsx.col_insert": "Insert a column to the left",
"xlsx.col_delete": "Delete the active cell's column",
"xlsx.structure_prompt_add": "Name of the new sheet:",
"xlsx.structure_prompt_rename": "New name of the sheet:",
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
"xlsx.select_column": "Select the column",
"xlsx.select_row": "Select the row",
"xlsx.insert_row_above": "Insert a row above",
"xlsx.insert_row_below": "Insert a row below",
"xlsx.insert_col_left": "Insert a column to the left",
"xlsx.insert_col_right": "Insert a column to the right",
"xlsx.delete_rows": "Delete {n} row(s)",
"xlsx.delete_cols": "Delete {n} column(s)",
"xlsx.clear_contents": "Clear contents",
"xlsx.confirm_delete_rows": "Delete {n} row(s)? This changes the file (a backup is created).",
"xlsx.confirm_delete_cols": "Delete {n} column(s)? This changes the file (a backup is created).",
"xlsx.structure_saved": "Structure updated",
"xlsx.structure_error": "Could not change the structure",
"xlsx.last_sheet": "The last sheet cannot be deleted",
"xlsx.dashboard_btn": "Dashboard",
"xlsx.inspector_title": "Inspector",
"xlsx.inspector_ai": "AI assistant",
"xlsx.inspector_ai_short": "AI",
"xlsx.inspector_resize": "Resize the inspector",
"xlsx.range_not_visible": "Cell out of view (truncated sheet)",
"xlsx.dashboard_title": "Workbook dashboard",
"xlsx.dashboard_stats": "{{cells}} cells · {{rows}} rows · {{cols}} columns · {{formulas}} formulas · {{numeric}} numeric values",
"xlsx.dashboard_charts": "{{n}} chart(s)",
"xlsx.dashboard_pivots": "{{n}} pivot tables",
"xlsx.dashboard_empty": "No named range or usable data in this workbook.",
"xlsx.dashboard_nr_name": "Name",
"xlsx.dashboard_nr_scope": "Scope",
"xlsx.dashboard_nr_ref": "Reference",
"xlsx.dashboard_hint": "Select a range or open the AI assistant to analyse this data.",
"xlsx.feature_cached_values": "cached values",
"xlsx.feature_cached_values_unverified": "cached values could not be verified (workbook too large)",
"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",
@@ -1987,6 +2126,13 @@
"ai.step.git_issues": "Searched issues: {value}",
"ai.step.git_file": "Read a repo file: {value}",
"ai.step.xlsx_create": "Spreadsheet proposed: {value}",
"ai.step.xlsx_sheets": "Workbook sheets listed: {value}",
"ai.step.xlsx_read": "Workbook read: {value}",
"ai.step.xlsx_update": "Cells edited: {value}",
"ai.step.xlsx_append": "Rows appended: {value}",
"ai.step.xlsx_search": "Workbook searched: {value}",
"ai.step.xlsx_analyze": "Range analysed: {value}",
"ai.step.xlsx_structure": "Structure changed: {value}",
"ai.step.docx_create": "Word document proposed: {value}",
"ai.step.csv_create": "CSV file proposed: {value}",
"ai.step.pdf_create": "PDF document proposed: {value}",
+146
View File
@@ -1101,6 +1101,7 @@
"help.desc_f952db17": "Refresh tokens : Cookies\n HttpOnly avec rotation automatique",
"help.desc_fa126609": "🔷\n Convert to canvas :\n Convertit en diagramme Mermaid.js",
"help.desc_facet_vaults": ": Compteurs par vault",
"help.desc_facet_extensions": ": Compteurs par extension de fichier dans les résultats",
"help.desc_fc9391f3": "# Exemple avec curl + jq dans un script bash\nWEBHOOK_URL=\"https://discord.com/api/webhooks/...\"\nOBSIGATE_SECRET=\"mon-secret\"\n\n# Lire le payload depuis stdin\nread -r payload\nevent=$(echo \"$payload\" | jq -r '.event')\npath=$(echo \"$payload\" | jq -r '.data.path')\n\ncurl -H \"Content-Type: application/json\" -d \"{\n \\\"content\\\": \\\"📝 **$event** — $path\\\"\n}\" \"$WEBHOOK_URL\"\n\n Tester avec curl\n # Simuler ce qu'ObsiGate envoie (pour tester votre endpoint)\ncurl -X POST https://votre-serveur.com/webhook \\\n -H \"Content-Type: application/json\" \\\n -H \"X-ObsiGate-Event: file_created\" \\\n -H \"X-ObsiGate-Signature: sha256=$(echo -n '{\"event\":\"file_created\",...}' | openssl dgst -sha256 -hmac 'mon-secret' | cut -d' ' -f2)\" \\\n -d '{\"event\":\"file_created\",\"timestamp\":\"2026-06-03T14:30:00Z\",\"data\":{\"vault\":\"IT\",\"path\":\"notes/test.md\"}}'\n\n Limitations actuelles\n \n \n Timeout de 5 secondes par\n requête — les webhooks sont \"fire and\n forget\" (pas de retry)\n \n \n Les modifications détectées par le watcher\n filesystem (watchdog)\n ne déclenchent pas de\n webhooks — seules les opérations via l'API\n REST sont prises en compte\n \n \n file_modified n'est pas encore\n dispatché (réservé pour version future)\n \n Pas de retry automatique en cas d'échec\n \n Pas de filtrage par vault — un webhook\n écoute toutes les vaults\n \n \n \n \n 💾 Sauvegardes et Audits\n\n Backup automatique\n \n ObsiGate crée automatiquement des sauvegardes\n avant chaque modification destructive.",
"help.desc_fdeaf803": ": Chaque type de\n fichier a son icône (markdown, code, etc.)",
"help.desc_file_suggest": ": Suggestions de titres de fichiers",
@@ -1608,6 +1609,8 @@
"search.exclude_placeholder": "Exclure: vendor/*, *.lock",
"search.facet_tags": "Tags",
"search.facet_vaults": "Vaults",
"search.facets_collapse": "Réduire les facettes",
"search.facets_expand": "Afficher les facettes",
"search.files_filtered": "{count} fichier(s) avec filtres",
"search.filter_path_title": "Filtres de chemin (Alt-F)",
"search.filters": "Filtres",
@@ -1823,6 +1826,142 @@
"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.lossy_confirm_btn": "Enregistrer quand même",
"xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.",
"xlsx.conflict_retry": "Réessayer",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"xlsx.undo": "Annuler la dernière modification",
"xlsx.redo": "Rétablir la modification",
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
"xlsx.tabs_add_sheet": "Ajouter une feuille",
"xlsx.readonly_badge": "Lecture seule",
"xlsx.readonly_hint": "Ce format (.xls / .ods) n'est pas modifiable dans ObsiGate — convertissez-le en .xlsx pour l'éditer.",
"xlsx.formulas_note": "Formules non recalculées",
"xlsx.formulas_note_title": "ObsiGate affiche la formule telle qu'elle est enregistrée : Excel la recalcule à l'ouverture. Les cellules dépendantes ne se rafraîchissent pas à l'écran.",
"xlsx.truncated_title": "Feuille tronquée",
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
"xlsx.truncated_hint": "Les cellules hors de l'affichage ne sont pas éditables ici ; le classeur n'est pas modifié.",
"xlsx.load_more": "Charger la suite",
"xlsx.loading_more": "Chargement…",
"xlsx.load_error": "Chargement de la suite impossible",
"xlsx.formula_bar_placeholder": "Contenu de la cellule active",
"xlsx.active_cell": "Cellule",
"xlsx.name_box_label": "Zone Nom — atteindre une cellule ou une plage",
"xlsx.name_box_hint": "Saisissez une référence (B12, A1:B3) puis Entrée pour y aller",
"xlsx.name_box_invalid": "Référence inconnue : {ref}",
"xlsx.copy": "Copier",
"xlsx.cut": "Couper",
"xlsx.paste": "Coller",
"xlsx.paste_empty": "Presse-papiers vide ou illisible",
"xlsx.paste_out_of_grid": "Une partie du collage dépasse la grille affichée et a été ignorée",
"xlsx.fn_suggestions": "SOMME;MOYENNE;NB;NBVAL;MIN;MAX;SI;RECHERCHEV;RECHERCHEX;SOMME.SI;SOMME.SI.ENS;NB.SI;CONCAT;GAUCHE;DROITE;STXT;AUJOURDHUI;MAINTENANT;ARRONDI;ABS;RACINE",
"xlsx.formula_stored_as_text": "{n} valeur(s) commençant par « = » ou « @ » enregistrée(s) comme texte — activez le bouton f(x) pour écrire de vraies formules",
"xlsx.find_placeholder": "Rechercher dans le classeur…",
"xlsx.find_prev": "Précédent",
"xlsx.find_next": "Suivant",
"xlsx.find_case": "Respecter la casse",
"xlsx.find_no_match": "Aucune correspondance",
"xlsx.find_count": "{index}/{count}",
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
"xlsx.csv_export": "Exporter la feuille ou la sélection en CSV",
"xlsx.export_btn": "Exporter",
"xlsx.export_title": "Exporter la sélection ou la feuille",
"xlsx.export_md": "Exporter en Markdown",
"xlsx.export_html": "Exporter en HTML",
"xlsx.print": "Imprimer",
"xlsx.export_scope_sheet": "la feuille",
"xlsx.export_scope_selection": "la sélection",
"xlsx.export_done": "Export de {scope} terminé",
"xlsx.format_btn": "Mise en forme",
"xlsx.format_bold": "Gras",
"xlsx.format_italic": "Italique",
"xlsx.format_underline": "Souligné",
"xlsx.format_clear": "Effacer la mise en forme",
"xlsx.format_align_left": "Aligner à gauche",
"xlsx.format_align_center": "Centrer",
"xlsx.format_align_right": "Aligner à droite",
"xlsx.format_font_color": "Couleur du texte",
"xlsx.format_fill_color": "Couleur de fond",
"xlsx.format_num_general": "Format général",
"xlsx.format_num_number": "Nombre (2 décimales)",
"xlsx.format_num_percent": "Pourcentage",
"xlsx.format_num_currency": "Monétaire (€)",
"xlsx.format_num_date": "Date (JJ/MM/AAAA)",
"xlsx.format_num_text": "Texte",
"xlsx.format_merge": "Fusionner les cellules",
"xlsx.format_unmerge": "Annuler la fusion",
"xlsx.format_merge_needs_range": "Sélectionnez au moins deux cellules pour fusionner",
"xlsx.format_freeze": "Figer les volets ici",
"xlsx.format_unfreeze": "Libérer les volets",
"xlsx.format_col_width": "Largeur de colonne…",
"xlsx.format_col_prompt": "Largeur de la colonne {col} (0 à 255)",
"xlsx.format_row_height": "Hauteur de ligne…",
"xlsx.format_row_prompt": "Hauteur de la ligne {row} (0 à 409)",
"xlsx.format_applied": "Mise en forme enregistrée",
"xlsx.format_error": "Échec de la mise en forme",
"xlsx.sort_asc": "Trier la colonne A→Z",
"xlsx.sort_desc": "Trier la colonne Z→A",
"xlsx.sort_applied": "Tri appliqué sur {col} — l'affichage seul, le classeur n'est pas modifié",
"xlsx.sort_reset": "Réinitialiser le tri et le filtre",
"xlsx.filter_placeholder": "Filtrer les lignes…",
"xlsx.structure_btn": "Structure de la feuille",
"xlsx.structure_title": "Modifier la structure du classeur",
"xlsx.sheet_add": "Ajouter une feuille",
"xlsx.sheet_rename": "Renommer la feuille courante",
"xlsx.sheet_duplicate": "Dupliquer la feuille courante",
"xlsx.sheet_delete": "Supprimer la feuille courante",
"xlsx.row_insert": "Insérer une ligne au-dessus",
"xlsx.row_delete": "Supprimer la ligne de la cellule active",
"xlsx.col_insert": "Insérer une colonne à gauche",
"xlsx.col_delete": "Supprimer la colonne de la cellule active",
"xlsx.structure_prompt_add": "Nom de la nouvelle feuille :",
"xlsx.structure_prompt_rename": "Nouveau nom de la feuille :",
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
"xlsx.select_column": "Sélectionner la colonne",
"xlsx.select_row": "Sélectionner la ligne",
"xlsx.insert_row_above": "Insérer une ligne au-dessus",
"xlsx.insert_row_below": "Insérer une ligne en dessous",
"xlsx.insert_col_left": "Insérer une colonne à gauche",
"xlsx.insert_col_right": "Insérer une colonne à droite",
"xlsx.delete_rows": "Supprimer {n} ligne(s)",
"xlsx.delete_cols": "Supprimer {n} colonne(s)",
"xlsx.clear_contents": "Effacer le contenu",
"xlsx.confirm_delete_rows": "Supprimer {n} ligne(s) ? Cette action modifie le fichier (un backup est créé).",
"xlsx.confirm_delete_cols": "Supprimer {n} colonne(s) ? Cette action modifie le fichier (un backup est créé).",
"xlsx.structure_saved": "Structure mise à jour",
"xlsx.structure_error": "Modification de la structure impossible",
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
"xlsx.dashboard_btn": "Tableau de bord",
"xlsx.inspector_title": "Inspecteur",
"xlsx.inspector_ai": "Assistant IA",
"xlsx.inspector_ai_short": "IA",
"xlsx.inspector_resize": "Redimensionner l'inspecteur",
"xlsx.range_not_visible": "Cellule hors de l'affichage (feuille tronquée)",
"xlsx.dashboard_title": "Tableau de bord du classeur",
"xlsx.dashboard_stats": "{{cells}} cellules · {{rows}} lignes · {{cols}} colonnes · {{formulas}} formules · {{numeric}} valeurs numériques",
"xlsx.dashboard_charts": "{{n}} graphique(s)",
"xlsx.dashboard_pivots": "{{n}} TCD",
"xlsx.dashboard_empty": "Aucune plage nommée ni donnée exploitable dans ce classeur.",
"xlsx.dashboard_nr_name": "Nom",
"xlsx.dashboard_nr_scope": "Portée",
"xlsx.dashboard_nr_ref": "Référence",
"xlsx.dashboard_hint": "Sélectionnez une plage ou ouvrez l'assistant IA pour analyser ces données.",
"xlsx.feature_cached_values": "valeurs calculées",
"xlsx.feature_cached_values_unverified": "valeurs calculées non vérifiables (classeur trop volumineux)",
"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",
@@ -1987,6 +2126,13 @@
"ai.step.git_issues": "Issues recherchées : {value}",
"ai.step.git_file": "Fichier de dépôt lu : {value}",
"ai.step.xlsx_create": "Tableur proposé : {value}",
"ai.step.xlsx_sheets": "Feuilles du classeur listées : {value}",
"ai.step.xlsx_read": "Classeur lu : {value}",
"ai.step.xlsx_update": "Cellules modifiées : {value}",
"ai.step.xlsx_append": "Lignes ajoutées : {value}",
"ai.step.xlsx_search": "Recherche dans le classeur : {value}",
"ai.step.xlsx_analyze": "Plage analysée : {value}",
"ai.step.xlsx_structure": "Structure modifiée : {value}",
"ai.step.docx_create": "Document Word proposé : {value}",
"ai.step.csv_create": "Fichier CSV proposé : {value}",
"ai.step.pdf_create": "Document PDF proposé : {value}",
+785 -11
View File
@@ -57,6 +57,13 @@
--mono: "JetBrains Mono", monospace;
--radius: 6px;
--radius-lg: 10px;
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
chrome (toolbar/panels), headers are clearly distinct from cells. */
--grid-bg: #161b22;
--grid-header-bg: #1f2430;
--grid-header-text: #e6edf3;
--grid-border: #30363d;
--grid-zebra: rgba(255, 255, 255, 0.025);
}
/* ===== THEME — DARK (explicit) ===== */
@@ -101,6 +108,11 @@
--mono: "JetBrains Mono", monospace;
--radius: 6px;
--radius-lg: 10px;
--grid-bg: #161b22;
--grid-header-bg: #1f2430;
--grid-header-text: #e6edf3;
--grid-border: #30363d;
--grid-zebra: rgba(255, 255, 255, 0.025);
}
/* ===== THEME — LIGHT ===== */
@@ -145,6 +157,11 @@
--mono: "JetBrains Mono", monospace;
--radius: 6px;
--radius-lg: 10px;
--grid-bg: #ffffff;
--grid-header-bg: #eaeef2;
--grid-header-text: #1f2328;
--grid-border: #d0d7de;
--grid-zebra: rgba(0, 0, 0, 0.025);
}
/* ===== BASE ===== */
@@ -5459,6 +5476,38 @@ body.resizing-v {
font-size: 0.68rem;
color: var(--text-muted);
}
.search-facets__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-muted);
cursor: pointer;
transition:
transform 120ms,
border-color 120ms,
color 120ms;
}
.search-facets__toggle:hover {
border-color: var(--accent);
color: var(--text-secondary);
}
.search-facets[data-collapsed="true"] .search-facets__toggle {
transform: rotate(-90deg);
}
.search-facets__body {
display: flex;
flex-wrap: wrap;
gap: 12px;
flex: 1 1 auto;
min-width: 0;
}
.search-facets[data-collapsed="true"] .search-facets__body {
display: none;
}
/* ---------------------------------------------------------------------------
Advanced Search — Pagination
@@ -10925,20 +10974,54 @@ body.desktop-mode .editor-container {
}
/* ── XLSX Viewer ── */
/* #154-A1 — the viewer shell is a grouped command bar: sheet tabs on the left,
action groups (Formules · Insertion · Vue · Fichier) on the right, then a
status row stating the viewer's limits (read-only, formulas not recalculated). */
.xlsx-toolbar {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface2);
position: relative; /* anchors the A14 structure menu */
}
.xlsx-cmdbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.xlsx-toolbar-actions {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
gap: 6px;
}
.xlsx-cmd-group {
display: inline-flex;
align-items: center;
gap: 6px;
}
.xlsx-cmd-sep {
width: 1px;
height: 20px;
flex: 0 0 auto;
background: var(--border);
}
.xlsx-save-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.xlsx-save-primary:disabled {
opacity: 0.5;
}
.xlsx-tabs {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
@@ -10956,26 +11039,149 @@ body.desktop-mode .editor-container {
border-color: var(--accent, #4a90d9);
color: #fff;
}
.xlsx-table th.xlsx-corner,
.xlsx-table th.xlsx-rownum {
.xlsx-tab-add {
border: 1px dashed var(--border);
background: transparent;
color: var(--text-secondary);
border-radius: 4px;
padding: 4px 10px;
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.xlsx-tab-add:hover {
border-color: var(--accent);
color: var(--accent);
}
.xlsx-status-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.xlsx-status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.72rem;
white-space: nowrap;
}
.xlsx-status-icon {
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.xlsx-status-readonly {
border-color: var(--warning, #e0a800);
color: var(--warning, #e0a800);
}
.xlsx-status-formula {
color: var(--accent);
}
/* #154-A2 — unsaved-change feedback: the primary button and the owning tab. */
.xlsx-save-primary.is-dirty {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.xlsx-tab-dirty::after {
content: "•";
margin-left: 5px;
color: var(--warning, #e0a800);
}
/* #154-A2 — non-blocking banners (conflict, …). */
.xlsx-banner-host {
display: flex;
flex-direction: column;
gap: 8px;
}
.xlsx-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 4px;
background: var(--surface);
color: var(--text-secondary);
font-weight: 400;
font-size: 0.82rem;
}
.xlsx-banner-conflict {
border-left-color: var(--warning, #e0a800);
}
.xlsx-banner-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: var(--warning, #e0a800);
}
.xlsx-banner-text {
flex: 1;
min-width: 0;
}
.xlsx-banner-dismiss {
padding: 2px 8px;
}
.modal-confirm-text {
color: var(--text-primary);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-line;
}
.xlsx-table {
background: var(--grid-bg);
}
.xlsx-table td {
border-bottom: 1px solid var(--grid-border);
}
/* #154-A1 — zebra + hover make rows scannable; headers use dedicated tokens so
they are visually distinct from the cells. */
.xlsx-table tbody tr:nth-child(even) td {
background: var(--grid-zebra);
}
.xlsx-table tbody tr:hover td {
background: var(--bg-hover);
}
.xlsx-table th.xlsx-corner,
.xlsx-table th.xlsx-rownum {
background: var(--grid-header-bg);
color: var(--grid-header-text);
font-weight: 500;
text-align: right;
padding: 6px 8px;
border-bottom: 2px solid var(--border);
border-right: 1px solid var(--border-light, var(--border));
border-bottom: 2px solid var(--grid-border);
border-right: 1px solid var(--grid-border);
position: sticky;
left: 0;
z-index: 1;
/* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th`
at `top: 0`, so a row number left sticky on both axes piles up in the
top-left corner instead of tracking its own row. */
top: auto;
z-index: 2;
}
.xlsx-table th.xlsx-corner {
left: 0;
top: 0;
z-index: 2;
z-index: 4;
}
/* #153 A8 — the column headers stay visible while the sheet scrolls down.
Declared explicitly (and not inherited from `.csv-table th`) so the stacking
order is intentional: thead (3) < row numbers (2) < corner (4). */
.xlsx-table thead th {
position: sticky;
top: 0;
z-index: 3;
background: var(--grid-header-bg);
color: var(--grid-header-text);
border-bottom: 2px solid var(--grid-border);
}
.xlsx-table td[contenteditable] {
cursor: text;
/* #155 — Excel-style cross cursor over the cells. */
cursor: cell;
min-width: 40px;
white-space: pre-wrap;
}
@@ -10983,10 +11189,578 @@ body.desktop-mode .editor-container {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
.xlsx-table td.xlsx-dirty {
/* #153 A7 — the active cell keeps its outline even when focus moves to the
formula bar, so the user never loses track of what the bar edits.
#154-A1 — solid (not dashed) for a stronger active-cell affordance. */
.xlsx-table td.xlsx-active:not(:focus) {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
/* #153 A7 — formula bar under the toolbar: [ A1 | > | input ] */
.xlsx-formula-bar {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
/* #156-A7 — the Name Box is editable (“Atteindre”): a plain input styled like
the read-only badge it replaced. */
.xlsx-active-cell {
width: 84px;
box-sizing: border-box;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.xlsx-active-cell:focus {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
.xlsx-formula-sep {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: var(--text-muted);
}
.xlsx-formula-input {
flex: 1;
min-width: 0;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.82rem;
}
.xlsx-formula-input:focus {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
.xlsx-formula-input:disabled {
opacity: 0.55;
}
/* #153 A13 — find-in-sheet + filter/sort controls */
.xlsx-find-group {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
min-width: 0;
}
.xlsx-find-input {
flex: 1;
min-width: 120px;
max-width: 220px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.8rem;
}
.xlsx-find-input:focus {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
.xlsx-find-count {
color: var(--text-muted);
font-size: 0.75rem;
white-space: nowrap;
}
.xlsx-find-btn,
.xlsx-find-case,
.xlsx-sort-reset {
padding: 3px 8px;
font-size: 0.78rem;
}
.xlsx-find-hit {
background: var(--warning, #e0a800);
color: var(--text-primary);
border-radius: 2px;
}
.xlsx-find-current {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: 1px;
}
/* #156-A11 — export menu (CSV stays a button; MD/HTML/Print live here). */
.xlsx-export-dropdown {
position: relative;
display: inline-block;
}
.xlsx-export-menu {
position: absolute;
right: 0;
top: 100%;
z-index: 1000;
min-width: 200px;
padding: 4px;
background: var(--bg, #fff);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
}
.xlsx-export-menu .export-menu-item {
display: block;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 4px;
background: none;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
}
.xlsx-export-menu .export-menu-item:hover {
background: var(--surface-hover, rgba(127, 127, 127, 0.12));
}
.xlsx-print-frame {
position: fixed;
width: 0;
height: 0;
border: 0;
}
/* #156-A8 — formatting menu: the colour rows pair a label with the native
picker (data-driven colours, no hardcoded swatch). */
.xlsx-format-menu {
min-width: 230px;
max-height: 70vh;
overflow-y: auto;
}
.xlsx-format-colour {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.xlsx-format-colour input[type="color"] {
width: 34px;
height: 22px;
padding: 0;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
cursor: pointer;
}
/* #153 A15 — freeze panes: frozen rows sit UNDER the sticky thead, frozen
columns stay left. z-index mirrors thead (3) without covering it. */
.xlsx-table tr.xlsx-frozen-row td,
.xlsx-table tr.xlsx-frozen-row th {
position: sticky;
top: 33px; /* thead height — keeps the frozen row below the header */
z-index: 2;
background: var(--grid-header-bg);
}
.xlsx-table td.xlsx-frozen-col {
position: sticky;
left: 44px; /* the row-number column width */
background: var(--grid-bg);
}
/* #153 A14 — structure menu (sheets / rows / columns) */
.xlsx-structure-menu {
position: absolute;
z-index: 30;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 240px;
margin-top: 4px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
box-shadow: 0 8px 24px var(--shadow, rgba(0, 0, 0, 0.25));
}
.xlsx-structure-item {
text-align: left;
border: none;
background: transparent;
color: var(--text-primary);
}
/* #154-A3 — right-hand inspector (dashboard, assistant) beside the grid. */
.xlsx-body {
display: flex;
align-items: stretch;
gap: 10px;
}
.xlsx-main {
flex: 1 1 auto;
min-width: 0;
}
.xlsx-inspector {
flex: 0 0 340px;
width: 340px;
max-width: 45%;
display: none;
flex-direction: column;
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
max-height: 70vh;
}
.xlsx-inspector.open {
display: flex;
}
.xlsx-inspector-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.xlsx-inspector-title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-primary);
}
.xlsx-inspector-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.xlsx-inspector-body {
flex: 1 1 auto;
overflow: auto;
padding: 10px;
}
/* #154-A5 — resize handle on the inspector's left edge. */
.xlsx-inspector-resize {
position: absolute;
left: -3px;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 2;
}
.xlsx-inspector-resize:hover {
background: color-mix(in srgb, var(--accent) 40%, transparent);
}
.xlsx-range-row {
cursor: pointer;
}
.xlsx-range-row:hover td {
background: var(--bg-hover);
}
.xlsx-inspector .xlsx-dashboard {
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
@media (max-width: 900px) {
.xlsx-body {
flex-direction: column;
}
.xlsx-inspector {
flex-basis: auto;
width: auto;
max-width: none;
}
}
/* #153 A17 — workbook dashboard panel: named ranges table + KPI cards.
Colors come from the existing CSS variables (no hardcoded values). */
.xlsx-dashboard {
margin: 8px 0;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
.xlsx-dashboard-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.xlsx-dashboard-head h3 {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 0.95rem;
color: var(--text-primary);
}
.xlsx-dashboard-objects {
font-size: 0.8rem;
color: var(--text-muted);
}
.xlsx-ranges-table {
margin-bottom: 10px;
font-size: 0.82rem;
}
.xlsx-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 8px;
}
.xlsx-kpi-sheet {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
}
.xlsx-kpi-sheet h4 {
margin: 0 0 4px;
font-size: 0.85rem;
color: var(--text-primary);
}
.xlsx-kpi-meta {
margin: 0 0 6px;
font-size: 0.75rem;
color: var(--text-muted);
}
.xlsx-kpi-cards {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.xlsx-kpi {
display: inline-flex;
flex-direction: column;
min-width: 64px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
.xlsx-kpi-label {
font-size: 0.7rem;
color: var(--text-muted);
}
.xlsx-kpi-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--text-primary);
}
.xlsx-kpi-empty {
color: var(--text-muted);
font-size: 0.8rem;
}
.xlsx-dashboard-hint {
margin: 10px 0 0;
font-size: 0.78rem;
color: var(--text-muted);
}
.xlsx-dashboard-loading {
color: var(--text-muted);
font-size: 0.8rem;
}
.xlsx-structure-item:hover {
background: var(--bg-secondary);
}
.xlsx-structure-sep {
height: 1px;
margin: 4px 0;
background: var(--border);
}
/* #155 — Excel-like range selection + right-click / long-press context menu. */
.xlsx-table tbody td.xlsx-selected {
background: var(--accent-bg);
}
.xlsx-viewer.xlsx-selecting,
.xlsx-viewer.xlsx-selecting td {
user-select: none;
}
.xlsx-viewer.xlsx-selecting td[contenteditable] {
cursor: crosshair;
}
.xlsx-context-menu {
position: fixed;
z-index: 10050;
display: flex;
flex-direction: column;
min-width: 220px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
}
.xlsx-context-item {
text-align: left;
border: none;
background: transparent;
color: var(--text-primary);
border-radius: 4px;
padding: 6px 10px;
font-size: 0.82rem;
cursor: pointer;
}
.xlsx-context-item:hover:not(:disabled) {
background: var(--bg-hover);
}
.xlsx-context-item:disabled {
opacity: 0.5;
cursor: default;
}
.xlsx-context-danger {
color: var(--danger);
}
.xlsx-context-sep {
height: 1px;
margin: 4px 0;
background: var(--border);
}
/* JSDOM shims for the tests that click anchors */
mark {
font: inherit;
}
/* #154-A1 — scoped to tbody so a dirty cell keeps its highlight even on the
zebra/hover backgrounds (specificity beats the zebra + hover rules). */
.xlsx-table tbody td.xlsx-dirty {
background: rgba(255, 196, 0, 0.18);
}
/* #153 A12 — last result Excel computed, shown under a formula cell.
Discreet by design: the formula is what the user edits, the cached value is
context (stale until Excel recalculates). */
.xlsx-cached {
display: block;
margin-top: 2px;
padding-left: 6px;
border-left: 2px solid var(--border, #d0d7de);
color: var(--text-muted);
font-size: 0.85em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* #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;
}
/* #153 A8 — "feuille tronquée" notice. Deliberately NOT the `.xlsx-warning`
look: that one is a data-loss alert, this one only says part of the sheet is
out of view. */
.xlsx-truncated {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
margin-bottom: 8px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent, #4a90d9);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.45;
}
.xlsx-truncated-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
margin-top: 1px;
color: var(--accent, #4a90d9);
}
/* #153 A9bis — “charger la suite” footnote under a truncated sheet. Also the
scroll sentinel target: clickable whole, disabled look once the sheet is
fully loaded. */
.xlsx-load-more {
display: block;
margin: 6px 0 10px;
padding: 6px 12px;
border: 1px dashed var(--border);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.82rem;
text-align: center;
cursor: pointer;
user-select: none;
}
.xlsx-load-more:hover {
border-color: var(--accent, #4a90d9);
color: var(--text-primary);
}
.xlsx-load-more.done {
display: none;
}
.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;
+5 -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 = 'v29';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
@@ -30,6 +30,10 @@ const PRECACHE_URLS = [
'/static/js/ui.js',
'/static/js/sidebar.js',
'/static/js/viewer.js',
'/static/js/xlsx/refs.js',
'/static/js/xlsx/command-bar.js',
'/static/js/xlsx/dashboard.js',
'/static/js/xlsx/context-menu.js',
'/static/js/search.js',
'/static/js/config.js',
'/static/js/utils.js',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.28.8",
"version": "2.47.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).
+5 -4
View File
@@ -9,12 +9,13 @@ export default defineConfig({
reporter: process.env.CI ? 'github' : 'list',
timeout: 60000,
expect: { timeout: 10000 },
// BUG-080 : la suite (~130 tests, workers: 1) ne doit jamais pendre toute
// la nuit. Au-delà du timeout global, Playwright abandonne avec un échec
// explicite au lieu de bloquer. Surchargable : E2E_GLOBAL_TIMEOUT_MS.
// BUG-080 : la suite (~120 tests, workers: 1, ~10-15 s/test sur un poste
// chargé) ne doit jamais pendre toute la nuit. Au-delà du timeout global,
// Playwright abandonne avec un échec explicite au lieu de bloquer.
// Surchargable : E2E_GLOBAL_TIMEOUT_MS.
globalTimeout: Number(
process.env.E2E_GLOBAL_TIMEOUT_MS ??
(process.env.CI ? 30 * 60 * 1000 : 15 * 60 * 1000),
(process.env.CI ? 30 * 60 * 1000 : 25 * 60 * 1000),
),
reportSlowTests: process.env.CI ? null : { max: 5, threshold: 30000 },
+28 -12
View File
@@ -10,7 +10,7 @@
conditions que le job CI `e2e`), lance la suite Playwright puis nettoie.
.PARAMETER PlaywrightArgs
Arguments transmis à `npx playwright test`, ex. `-g "image viewer"`,
Arguments transmis à `playwright test` (via `node`), ex. `-g "image viewer"`,
`--headed`.
.EXAMPLE
@@ -35,15 +35,20 @@ $ServerLog = "data/e2e-server.log"
$ServerErrLog = "data/e2e-server.err.log"
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
$TestTimeoutSec = if ($env:E2E_TIMEOUT_SEC) { [int]$env:E2E_TIMEOUT_SEC } else { 900 }
# E2E_TIMEOUT_SEC dépasse volontairement le globalTimeout Playwright (25 min en
# local) pour que ce soit Playwright qui abandonne proprement (avec rapport) en premier.
$TestTimeoutSec = if ($env:E2E_TIMEOUT_SEC) { [int]$env:E2E_TIMEOUT_SEC } else { 1800 }
$BrowserTimeoutSec = if ($env:E2E_BROWSER_INSTALL_TIMEOUT_SEC) { [int]$env:E2E_BROWSER_INSTALL_TIMEOUT_SEC } else { 600 }
function Invoke-NativeWithTimeout([string]$Label, [int]$TimeoutSec, [string]$Exe, [string[]]$Args) {
function Invoke-NativeWithTimeout([string]$Label, [int]$TimeoutSec, [string]$Exe, [string[]]$Arguments) {
# Lance un processus natif en gardant la sortie console en direct, et le
# tue après $TimeoutSec s'il n'a pas terminé (exit 124, comme `timeout`).
# NOTE : le paramètre NE DOIT PAS s'appeler `$Args` (variable automatique
# PowerShell qui l'écraserait → `node` lancé sans arguments, exit 0
# silencieux immédiat en lisant un stdin vide).
$stamp = Get-Date -Format "HH:mm:ss"
Write-Host "[$stamp] $Label (timeout ${TimeoutSec}s)..."
$proc = Start-Process -FilePath $Exe -ArgumentList $Args -NoNewWindow -PassThru
$proc = Start-Process -FilePath $Exe -ArgumentList $Arguments -NoNewWindow -PassThru
$proc | Wait-Process -Timeout $TimeoutSec -ErrorAction SilentlyContinue
if (-not $proc.HasExited) {
Write-Host "[ERR] $Label : timeout après ${TimeoutSec}s, arrêt du processus (PID $($proc.Id))."
@@ -70,7 +75,7 @@ function Assert-Command([string]$Name, [string]$Hint) {
}
Assert-Command "uv" "Installez-le : https://docs.astral.sh/uv/"
Assert-Command "npx" "Installez Node.js (>= 20)."
Assert-Command "node" "Installez Node.js (>= 20)."
# ----- Venv Python 3.11 (créé une seule fois) -----
$Python = ".venv-e2e/Scripts/python.exe"
@@ -130,21 +135,32 @@ try {
}
Write-Host "[OK] Serveur prêt."
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
# BUG-080 : `--yes` (jamais de prompt interactif npx qui pend), skip si un
# chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1), timeout dédié.
# ----- Playwright via node direct (pas npx) -----
# BUG-080 : `Start-Process` ne peut pas lancer `npx` (ni le `.ps1` ni le
# `.cmd` ne sont des applications Win32 directes) → on appelle la CLI
# locale via `node.exe`, sans prompt interactif possible. Skip de
# l'install si un chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1),
# timeouts dédiés sur chaque étape.
$PlaywrightCli = Join-Path $Root "node_modules/@playwright/test/cli.js"
if (-not (Test-Path -LiteralPath $PlaywrightCli)) {
throw "[ERR] $PlaywrightCli introuvable. Lancez d'abord : npm ci"
}
if (($env:E2E_INSTALL_BROWSERS -eq "1") -or (-not (Test-ChromiumInstalled))) {
$code = Invoke-NativeWithTimeout "npx playwright install chromium" $BrowserTimeoutSec "npx" @("--yes", "playwright", "install", "chromium")
$code = Invoke-NativeWithTimeout "playwright install chromium" $BrowserTimeoutSec "node" @($PlaywrightCli, "install", "chromium")
if ($code -ne 0) { exit $code }
} else {
Write-Host "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
}
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
Write-Host "[INFO] BASE_URL=$BaseUrl node $PlaywrightCli test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
$env:BASE_URL = $BaseUrl
$testArgs = @("--yes", "playwright", "test", "--project=chromium-desktop") + @($PlaywrightArgs)
$exitCode = Invoke-NativeWithTimeout "playwright test" $TestTimeoutSec "npx" $testArgs
$testArgs = @($PlaywrightCli, "test", "--project=chromium-desktop")
if ($PlaywrightArgs) { $testArgs += @($PlaywrightArgs) }
$exitCode = Invoke-NativeWithTimeout "playwright test" $TestTimeoutSec "node" $testArgs
} catch {
Write-Host "[ERR] $($_.Exception.Message)"
$exitCode = 1
} finally {
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
+70
View File
@@ -0,0 +1,70 @@
# ObsiGate — règles Semgrep locales (#87 T7).
#
# Volontairement LOCALES (aucun `--config auto`/registre) : le runner CI a un
# accès réseau fragile, et ces règles n'ont besoin d'aucun téléchargement.
# Exécution : `semgrep --config semgrep-rules/ backend/` (job CI `lint`,
# bloquant). Chaque règle est un garde-fou : aucun code existant ne doit
# la déclencher (vérifié à l'ajout) ; toute violation future échoue le CI.
rules:
- id: obsigate-no-eval-exec
message: "Interdit : eval()/exec() sur du contenu dynamique (injection de code). Restructurer sans exécution de code."
severity: ERROR
languages: [python]
pattern-either:
- pattern: eval(...)
- pattern: exec(...)
- id: obsigate-no-shell-true
message: "Interdit : subprocess avec shell=True (injection shell). Passer argv en liste, shell=False."
severity: ERROR
languages: [python]
pattern-either:
- pattern: subprocess.run(..., shell=True, ...)
- pattern: subprocess.Popen(..., shell=True, ...)
- pattern: subprocess.call(..., shell=True, ...)
- pattern: subprocess.check_output(..., shell=True, ...)
- pattern: subprocess.check_call(..., shell=True, ...)
- id: obsigate-no-os-system
message: "Interdit : os.system() (shell implicite). Utiliser subprocess avec argv en liste."
severity: ERROR
languages: [python]
pattern: os.system(...)
- id: obsigate-no-pickle-load
message: "Interdit : pickle.load/loads sur des données non fiables (exécution arbitraire). Utiliser JSON."
severity: ERROR
languages: [python]
pattern-either:
- pattern: pickle.load(...)
- pattern: pickle.loads(...)
- id: obsigate-no-yaml-unsafe-load
message: "Interdit : yaml.load() sans Loader (exécution arbitraire). Utiliser yaml.safe_load()."
severity: ERROR
languages: [python]
patterns:
- pattern: yaml.load(...)
- pattern-not: yaml.load(..., Loader=...)
- id: obsigate-no-unverified-tls
message: "Interdit : verify=False (MITM). Ne jamais désactiver la vérification TLS."
severity: ERROR
languages: [python]
pattern-either:
- pattern: requests.$METHOD(..., verify=False, ...)
- pattern: httpx.$METHOD(..., verify=False, ...)
- pattern: httpx.Client(..., verify=False, ...)
- pattern: httpx.AsyncClient(..., verify=False, ...)
- id: obsigate-no-markupsafe-markup
message: "Interdit : markupsafe.Markup() (contourne l'échappement XSS, BUG-021/022). Le sanitizer serveur est la seule voie."
severity: ERROR
languages: [python]
pattern: Markup(...)
- id: obsigate-no-tempfile-mktemp
message: "Interdit : tempfile.mktemp() (race symlink, CWE-377). Utiliser NamedTemporaryFile/mkdtemp."
severity: ERROR
languages: [python]
pattern: tempfile.mktemp(...)
Binary file not shown.
Binary file not shown.
+13
View File
@@ -143,6 +143,19 @@ test.describe('Full-Text Search', () => {
// Not always present — depends on vault content
});
test('should show the extensions facet and collapse the facets panel (#157)', async ({ page }) => {
await goHome(page);
await searchFor(page, 'test');
const extFacet = page.locator('.search-facets__group[data-facet="extensions"]');
await expect(extFacet).toBeVisible();
await expect(extFacet.locator('.search-facets__item').first()).toBeVisible();
// discreet collapse/expand of the whole vaults+tags+extensions section
await page.locator('.search-facets__toggle').click();
await expect(page.locator('.search-facets__body')).toBeHidden();
await page.locator('.search-facets__toggle').click();
await expect(page.locator('.search-facets__body')).toBeVisible();
});
test('should navigate results with floating bar arrows', async ({ page }) => {
await goHome(page);
await searchFor(page, 'test');
+316
View File
@@ -0,0 +1,316 @@
/**
* 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 value Excel last computed is shown under the formula (#153 A12).
*
* Second describe block — `test_vault/sample-xlsx-large.xlsx` (520 rows) :
* - a sheet over the render caps SAYS it instead of looking complete (#153 A8) ;
* - the column headers stay pinned while the sheet scrolls (#153 A8) ;
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
* with the real A1 coordinates (#153 A9).
*
* 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';
// #153 A8 — 520 rows x 3 columns: the sheet exceeds the 500-row render cap, so
// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
// committed next to the other fixture; nothing in the suite writes to it.
const LARGE = 'sample-xlsx-large.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) {
return openXlsx(page, FIXTURE);
}
async function openXlsx(page, file) {
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${file}"]`);
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 et valeurs calculées (#153)', () => {
test.beforeAll(() => {
if (existsSync(FIXTURE_PATH)) originalBytes = readFileSync(FIXTURE_PATH);
});
test.afterAll(() => {
if (originalBytes) writeFileSync(FIXTURE_PATH, originalBytes);
});
// Read-only assertions come FIRST, before the mutating tests: saving through
// the viewer rewrites the workbook and an openpyxl round-trip drops the cached
// formula results (BUG-085), so the shadow line only exists on a pristine
// fixture.
test('affiche la valeur calculée en cache sous la formule (#153 A12)', async ({ page }) => {
await login(page);
await openFixture(page);
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>).
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
await expect(formulaCell).toContainText('=B1*2');
const cached = formulaCell.locator('.xlsx-cached');
await expect(cached).toHaveCount(1);
await expect(cached).toHaveText('200');
// The tooltip is translated client-side, never hardcoded by the backend.
await expect(cached).toHaveAttribute('title', /Excel/);
// A plain value cell must not be duplicated with a shadow line.
await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0);
});
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);
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();
// #154-A2 — the lossy confirmation is a themed in-app dialog, not a native one.
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(dialog).toContainText('segments');
await dialog.locator('[data-dialog="confirm"]').click();
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 thémée, puis reprise avec force (le toggle reste désactivé).
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();
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
// A lossy workbook asks for confirmation. A previous test's save may already
// have dropped the slicers part, in which case the PUT succeeds directly.
await dialog.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
if (await dialog.isVisible()) {
await dialog.locator('[data-dialog="confirm"]').click();
}
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
});
});
// ── A8 — troncature annoncée + en-têtes figés ───────────────────────────────
test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
test('annonce la feuille tronquée au lieu de la couper en silence', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
const note = page.locator('#content-area .xlsx-truncated');
await expect(note).toBeVisible();
// Libellé traduit (jamais de texte UI backend, jamais de couleur en dur).
await expect(note).toContainText('Feuille tronquée');
await expect(note).toContainText('500 lignes affichées sur 520');
// La dernière ligne rendue est la 500e ; les suivantes ne sont pas là.
await expect(page.locator('#content-area td[data-cell="A500"]')).toHaveCount(1);
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
});
test('garde les en-têtes de colonnes visibles au défilement', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
const header = page.locator('#content-area .xlsx-table thead th').nth(1);
const before = await header.boundingBox();
await page.locator('#content-area .csv-table-wrapper').evaluate((el) => { el.scrollTop = 800; });
await expect.poll(async () => (await header.boundingBox()).y, { timeout: 5000 })
.toBeLessThanOrEqual(before.y + 1);
// Les numéros de ligne ne se superposent pas en haut à gauche (le `top: auto`
// de A8) et la première ligne de données reste lisible sous l'en-tête.
const first = await page.locator('#content-area th.xlsx-rownum').first().boundingBox();
const second = await page.locator('#content-area th.xlsx-rownum').nth(1).boundingBox();
expect(second.y - first.y).toBeGreaterThan(4);
});
test('l\'endpoint de fenêtre sert les lignes au-delà du plafond (#153 A9)', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
const res = await page.request.get(
`${BASE}/api/file/${VAULT}/xlsx/sheet?path=${encodeURIComponent(LARGE)}&sheet=Journal&offset=500&limit=50`
);
expect(res.status()).toBe(200);
const win = await res.json();
expect(win.total_rows).toBe(520);
expect(win.offset).toBe(500);
expect(win.has_more).toBe(false);
// Les coordonnées A1 sont celles de la feuille, pas celles de la fenêtre :
// la ligne 520 est servie comme A520, pas comme A20.
expect(win.html).toContain('data-cell="A520"');
expect(win.html).toContain('Operation 519');
expect(win.html).not.toContain('data-cell="A1"');
});
test('le bouton « charger la suite » ajoute les lignes cachées (#153 A9bis)', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
// La ligne 500 est la dernière rendue ; le pied de page l'annonce.
const foot = page.locator('#content-area .xlsx-load-more');
await expect(foot).toBeVisible();
await expect(foot).toContainText('Charger la suite');
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
// Un clic fetch la suite (offset 500, 20 lignes) et l'insère dans la table.
await foot.click();
await expect(page.locator('#content-area td[data-cell="A520"]')).toBeVisible({ timeout: 10000 });
// Une ligne nouvellement arrivée est éditable comme les autres.
const cell = page.locator('#content-area td[data-cell="A520"]');
await cell.click();
await expect(cell).toBeFocused();
// Tout est chargé → le pied de page est masqué.
await expect(foot).toBeHidden();
});
test('barre de formule et navigation clavier (#153 A7)', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
// Un clic sur une cellule active la barre avec son nom et son contenu.
// (#156-A7 : la zone Nom est un input, donc on lit sa valeur.)
const cell = page.locator('#content-area td[data-cell="B2"]');
await cell.click();
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B2');
const bar = page.locator('#xlsx-formula-input');
await expect(bar).toHaveValue(/Operation 1/);
// Les flèches déplacent la cellule active.
await cell.press('ArrowDown');
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B3');
await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
await expect(page.locator('#xlsx-active-cell')).toHaveValue('C3');
// Éditer depuis la barre marque la cellule dirty, Échap annule.
await bar.fill('Operation 2 modifiee');
await expect(page.locator('#content-area td[data-cell="C3"]')).toHaveClass(/xlsx-dirty/);
await page.locator('#content-area td[data-cell="C3"]').press('Escape');
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
});
});
// ── #156 P1 — presse-papiers de plage, clavier complet, zone Nom ────────────
test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)', () => {
test('coller une plage, la navigation clavier et la zone Nom (#156 A5/A6/A7)', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
const nameBox = page.locator('#xlsx-active-cell');
const cellOf = (ref) => page.locator(`#content-area td[data-cell="${ref}"]`);
const textOf = async (ref) => (await cellOf(ref).innerText()).trim();
// — A7 : la zone Nom est éditable et sert à « Atteindre ».
await nameBox.fill('B12');
await nameBox.press('Enter');
await expect(nameBox).toHaveValue('B12');
await expect(cellOf('B12')).toHaveClass(/xlsx-active/);
// — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins.
await cellOf('B12').click();
await page.keyboard.press('Control+a');
await expect(nameBox).toHaveValue('A1:C500');
await page.keyboard.press('Control+Home');
await expect(nameBox).toHaveValue('A1');
await page.keyboard.press('Control+End');
await expect(nameBox).toHaveValue('C500');
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
// utilise le presse-papiers interne de la grille : aucune permission
// navigateur n'est requise.
const src = { a1: await textOf('A1'), b1: await textOf('B1'), b2: await textOf('B2'), c10: await textOf('C10') };
await cellOf('A1').click();
await cellOf('B2').click({ modifiers: ['Shift'] });
await expect(nameBox).toHaveValue('A1:B2');
await cellOf('A1').click({ button: 'right' });
await page.locator('.xlsx-context-item', { hasText: 'Copier' }).click();
await cellOf('A10').click({ button: 'right' });
await page.locator('.xlsx-context-item', { hasText: 'Coller' }).click();
expect(await textOf('A10')).toBe(src.a1);
expect(await textOf('B10')).toBe(src.b1);
expect(await textOf('B11')).toBe(src.b2);
expect(await textOf('C10')).toBe(src.c10);
// — A6 : Suppr vide la sélection (et seulement elle).
await cellOf('A10').click();
await cellOf('B11').click({ modifiers: ['Shift'] });
await expect(nameBox).toHaveValue('A10:B11');
await page.keyboard.press('Delete');
expect(await textOf('A10')).toBe('');
expect(await textOf('B11')).toBe('');
expect(await textOf('C10')).toBe(src.c10);
});
});
BIN
View File
Binary file not shown.
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* ObsiGate - JSDOM tests for the search results facets panel (#157):
* vault, tag and file-extension facets rendered by
* `renderAdvancedSearchResults()` (frontend/js/search.js).
*
* Covers:
* - the three facet groups are rendered (Vaults / Tags / Extensions)
* - the extensions facet appears even when tags and vaults are empty
* - clicking an extension replaces any `ext:` operator in the search
* input and triggers a new search
*
* Usage: node tests/frontend/search-facets.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
const dom = new JSDOM(
`<!DOCTYPE html>
<html><body>
<input id="search-input" value="" />
<select id="vault-filter"><option value="all">all</option></select>
<div id="results-bar" style="display:none"></div>
<span id="rb-count"></span>
<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.KeyboardEvent = w.KeyboardEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
globalThis.MutationObserver = w.MutationObserver;
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
try {
await fn();
passCount++;
console.log(` ✓ ${name}`);
} catch (err) {
console.error(` ✗ ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
// Backend payload shape for /api/search/advanced (facets only matter here).
const PAYLOAD = {
results: [],
total: 0,
offset: 0,
limit: 50,
query_time_ms: 1,
facets: {
vaults: { IT: 4, Recettes: 2 },
tags: { linux: 3 },
extensions: { md: 5, pdf: 2, xlsx: 1 },
},
};
globalThis.fetch = async () => ({
ok: true,
status: 200,
json: async () => PAYLOAD,
});
const { renderAdvancedSearchResults } = await import(
pathToFileURL(path.join(JS_DIR, "search.js")).href
);
const facetGroups = () =>
Array.from(document.querySelectorAll(".search-facets__group"));
const extGroup = () =>
document.querySelector('.search-facets__group[data-facet="extensions"]');
const extItems = () =>
Array.from(extGroup()?.querySelectorAll(".search-facets__item") || []);
console.log("Search facets (#157) — Vaults / Tags / Extensions");
await test("renders the three facet groups", async () => {
renderAdvancedSearchResults(PAYLOAD, "python", null);
assert.equal(facetGroups().length, 3, "vault, tag and extension groups");
const labels = facetGroups().map((g) =>
g.querySelector(".search-facets__label").textContent,
);
assert.deepEqual(labels, ["Vaults", "Tags", "Extensions"]);
});
await test("lists extensions with their counts", async () => {
const texts = extItems().map((i) => i.textContent.trim());
assert.deepEqual(texts, [".md 5", ".pdf 2", ".xlsx 1"]);
assert.equal(
extItems()[0].querySelector(".facet-count").textContent,
"5",
"count lives in .facet-count",
);
});
await test("extension facet shows even with empty tags and vaults", async () => {
renderAdvancedSearchResults(
{ ...PAYLOAD, facets: { vaults: {}, tags: {}, extensions: { md: 1 } } },
"",
null,
);
assert.ok(extGroup(), "extensions group rendered alone");
assert.equal(extItems().length, 1);
});
await test("clicking an extension adds the ext: operator", async () => {
document.getElementById("search-input").value = "python ext:md";
renderAdvancedSearchResults(PAYLOAD, "python", null);
extItems()[1].click(); // .pdf
assert.equal(
document.getElementById("search-input").value,
"python ext:pdf",
"existing ext: operator replaced, not duplicated",
);
});
await test("facets panel collapses and restores via the toggle", async () => {
localStorage.removeItem("obsigate_facets_collapsed");
renderAdvancedSearchResults(PAYLOAD, "python", null);
const panel = document.querySelector(".search-facets");
const toggle = panel.querySelector(".search-facets__toggle");
assert.ok(toggle, "collapse toggle rendered");
assert.equal(panel.getAttribute("data-collapsed"), "false", "expanded by default");
assert.ok(panel.querySelector(".search-facets__body"), "groups live in the body");
toggle.click();
assert.equal(panel.getAttribute("data-collapsed"), "true", "click collapses");
assert.equal(
localStorage.getItem("obsigate_facets_collapsed"),
"1",
"collapsed state persisted",
);
renderAdvancedSearchResults(PAYLOAD, "python", null);
const panel2 = document.querySelector(".search-facets");
assert.equal(
panel2.getAttribute("data-collapsed"),
"true",
"collapsed state survives a re-render",
);
panel2.querySelector(".search-facets__toggle").click();
assert.equal(panel2.getAttribute("data-collapsed"), "false", "toggle restores");
localStorage.removeItem("obsigate_facets_collapsed");
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
process.exit(1);
}
File diff suppressed because it is too large Load Diff
+80 -1
View File
@@ -402,4 +402,83 @@ class TestAvatar:
"username": "admin", "password": "chab30",
})
assert resp.status_code == 200
assert resp.json()["user"]["avatar"] == TINY_PNG
assert resp.json()["user"]["avatar"] == TINY_PNG
# ═══════════════════════════════════════════════════════════════════
# Secure cookies (#87 T8)
# ═══════════════════════════════════════════════════════════════════
class TestSecureCookies:
"""`Secure` auto par défaut : https → flag, http → pas de flag
(les navigateurs jettent les cookies Secure sur http)."""
@staticmethod
def _req(scheme="http", forwarded_proto=None):
from types import SimpleNamespace
headers = {}
if forwarded_proto is not None:
headers["x-forwarded-proto"] = forwarded_proto
return SimpleNamespace(
url=SimpleNamespace(scheme=scheme),
headers=headers,
)
def test_forced_true(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "true")
assert is_secure_cookies(self._req("http")) is True
def test_forced_false(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "false")
assert is_secure_cookies(self._req("https")) is False
def test_auto_http(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
assert is_secure_cookies(self._req("http")) is False
def test_auto_https(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
assert is_secure_cookies(self._req("https")) is True
def test_auto_forwarded_proto_trusted(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true")
assert is_secure_cookies(self._req("http", "https")) is True
def test_auto_forwarded_proto_untrusted(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
monkeypatch.delenv("OBSIGATE_TRUST_PROXY", raising=False)
assert is_secure_cookies(self._req("http", "https")) is False
def test_login_http_sets_cookie_without_secure(self, auth_client, monkeypatch):
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
resp = auth_client.post("/api/auth/login", json={
"username": "admin", "password": "chab30",
})
assert resp.status_code == 200
set_cookie = resp.headers.get("set-cookie", "")
assert "access_token" in set_cookie
assert "secure" not in set_cookie.lower()
def test_login_https_sets_secure_cookie(self, auth_client, monkeypatch):
"""Même app servie en https → flag Secure présent."""
from backend.main import app
from fastapi.testclient import TestClient
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
https_client = TestClient(app, base_url="https://testserver",
raise_server_exceptions=False)
try:
resp = https_client.post("/api/auth/login", json={
"username": "admin", "password": "chab30",
})
assert resp.status_code == 200
assert "secure" in resp.headers.get("set-cookie", "").lower()
finally:
if hasattr(https_client, "close"):
https_client.close()
+276
View File
@@ -0,0 +1,276 @@
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083, BUG-091, BUG-093).
Sans dépendance (pas de PyYAML) : analyse ligne à ligne de
`.gitea/workflows/ci.yml`, suffisante pour les conventions de ce fichier.
"""
from __future__ import annotations
import re
from pathlib import Path
CI_YML = Path(__file__).resolve().parent.parent / ".gitea" / "workflows" / "ci.yml"
REQUIREMENTS = Path(__file__).resolve().parent.parent / "backend" / "requirements.txt"
REPO_ROOT = Path(__file__).resolve().parent.parent
def _job_text(job: str) -> str:
"""Corps YAML du job `job` (jusqu'au job suivant ou à la fin du fichier)."""
text = CI_YML.read_text(encoding="utf-8")
start = text.index(f"\n {job}:")
rest = text[start + 1 :]
nxt = re.search(r"\n {2}[A-Za-z][A-Za-z0-9_-]*:\s*\n", rest)
return rest[: nxt.start()] if nxt else rest
def _steps(job: str) -> list[tuple[str, str]]:
"""[(nom d'étape, corps YAML)] pour un job donné."""
chunks = re.split(r"\n {6}- name: ", "\n" + _job_text(job))[1:]
steps = []
for chunk in chunks:
name, _, body = chunk.partition("\n")
steps.append((name.strip(), body))
return steps
def _run_bodies() -> list[tuple[int, str]]:
"""Toutes les lignes shell de chaque bloc `run:` → [(n° ligne, code)]."""
lines = CI_YML.read_text(encoding="utf-8").splitlines()
bodies: list[tuple[int, str]] = []
i = 0
while i < len(lines):
m = re.match(r"^(\s*)run:(?:\s*\|\s*)?$", lines[i])
inline = re.match(r"^(\s*)run:\s+(\S.*)$", lines[i])
if m:
base = len(m.group(1))
i += 1
while i < len(lines):
cur = lines[i]
if not cur.strip():
i += 1
continue
if len(cur) - len(cur.lstrip()) <= base:
break
bodies.append((i + 1, cur.strip()))
i += 1
elif inline:
bodies.append((i + 1, inline.group(2).strip()))
i += 1
else:
i += 1
return bodies
class TestRunnerProofScripts:
def test_no_hash_inside_run_bodies(self):
"""BUG-083 : aucun `#` dans le code shell des `run:`.
Le runner Gitea Act tronque naïvement au premier `#` (même entre
guillemets) : `echo "... see #87)"` devenait une citation non
fermée → `unexpected EOF while looking for matching '"'` (job
`security` rouge). Les lignes-commentaires shell (`# ...`) restent
autorisées : leur troncature est sémantiquement neutre.
"""
offenders = [
f"L{n}: {code}"
for n, code in _run_bodies()
if not code.startswith("#") and "#" in code
]
assert not offenders, (
"BUG-083 : `#` interdit dans le code des `run:` "
f"(tronqué par le runner) :\n" + "\n".join(offenders)
)
class TestSemgrepStep:
"""BUG-091 : semgrep-core est inexécutable sur le runner (exit 127).
L'étape est donc désactivée (avertissement, non bloquante) au lieu d'être
supprimée : elle documente pourquoi, et se réactive telle quelle dès que le
runner dispose d'un CPU x86-64-v2.
"""
SEMGREP_STEP_PREFIX = "Semgrep"
def _semgrep_step(self) -> tuple[str, str]:
matches = [
(n, b) for n, b in _steps("security") if n.startswith(self.SEMGREP_STEP_PREFIX)
]
assert len(matches) == 1, (
"BUG-091 : une unique étape Semgrep (désactivée) attendue dans le "
f"job security, trouvé {len(matches)}"
)
return matches[0]
@staticmethod
def _run_commands(body: str) -> list[str]:
"""Commandes shell du bloc `run:` de l'étape (hors lignes vides)."""
m = re.search(r"^\s*run:\s*\|?\s*$", body, re.M)
assert m, "étape sans bloc `run:`"
rest = body[m.end() :]
lines: list[str] = []
for line in rest.splitlines():
if not line.strip():
continue
# le bloc run: est indenté de 2 spaces de plus que la clef
if len(line) - len(line.lstrip()) <= 8:
break
lines.append(line.strip())
return lines
def test_semgrep_step_does_not_execute_core(self):
"""Le core natif ne doit plus être lancé (exit 127 bloquant le job).
Seule commande admise : l'avertissement d'activation. Le message
mentionne voluntaryirement « semgrep » — c'est l'**exécution** qui
est interdite, pas le mot.
"""
name, body = self._semgrep_step()
commands = self._run_commands(body)
assert commands, f"BUG-091 : l'étape « {name} » n'a plus de commande"
for cmd in commands:
assert cmd.startswith('echo "::warning::'), (
f"BUG-091 : l'étape « {name} » ne doit exécuter qu'un avertissement, "
f"trouvé : {cmd!r}"
)
def test_semgrep_step_is_non_blocking_and_explains_itself(self):
"""Désactivée = `continue-on-error` + avertissement explicite."""
name, body = self._semgrep_step()
assert re.search(r"^\s*continue-on-error:\s*true\s*$", body, re.M), (
f"BUG-091 : l'étape « {name} » doit porter continue-on-error: true"
)
assert "::warning::" in body, (
f"BUG-091 : l'étape « {name} » doit émettre un ::warning:: "
"expliquant la désactivation"
)
assert "BUG-091" in body, (
f"BUG-091 : l'étape « {name} » doit référencer BUG-091"
)
def test_bandit_and_pip_audit_stay_blocking(self):
"""La désactivation de semgrep ne doit rien dégraver d'autre (#87)."""
found = {}
for name, body in _steps("security"):
low = name.lower()
if low.startswith("bandit"):
found["bandit"] = body
elif low.startswith("pip-audit"):
found["pip-audit"] = body
assert set(found) == {"bandit", "pip-audit"}, (
f"étapes Bandit et Pip-audit attendues dans le job security, "
f"trouvé {sorted(found)}"
)
for tool, body in found.items():
assert "continue-on-error: true" not in body, (
f"BUG-091 : l'étape {tool} doit rester bloquante (#87)"
)
def test_semgrep_rules_still_shipped_and_documented(self):
"""Les règles locales restent versionnées et documentées (#87 T7)."""
rules = REPO_ROOT / "semgrep-rules" / "obsigate-python.yaml"
assert rules.exists(), "ruleset semgrep manquant"
text = CI_YML.read_text(encoding="utf-8")
# La commande locale est documentée (commentaire de l'étape), pas exécutée.
assert re.search(r"semgrep --config semgrep-rules/\s*\n?\s*#?\s*backend/", text), (
"#87 T7 : commande locale `semgrep --config semgrep-rules/ backend/` "
"attendue en commentaire dans le workflow"
)
class TestFrontendStepsHaveTheirDeps:
@staticmethod
def _root_step_files() -> list[str]:
"""Fichiers `node tests/frontend/<f>` de l'étape racine (sans jsdom)."""
text = CI_YML.read_text(encoding="utf-8")
root_part = text.split("Frontend JSDOM tests", 1)[0]
root_steps = root_part.split("Frontend unit tests", 1)[1]
return re.findall(r"node tests/frontend/(\S+\.mjs)", root_steps)
@staticmethod
def _has_static_jsdom_import(rel: str) -> bool:
path = REPO_ROOT / "tests" / "frontend" / rel
return any(
re.match(r"^\s*import\b.*\bfrom\s+['\"]jsdom['\"]", line)
or re.match(r"""\brequire\(\s*['"]jsdom['"]\s*\)""", line)
for line in path.read_text(encoding="utf-8").splitlines()
)
def test_root_step_files_need_no_jsdom(self):
"""BUG-082 : l'étape racine tourne sans `tests/frontend/node_modules`
(installé seulement par l'étape JSDOM) : aucun de ses fichiers ne
doit importer `jsdom` statiquement — sinon `ERR_MODULE_NOT_FOUND`
et `lint` rouge (cas `upload.test.mjs`, puis `config-ai-keys.test.mjs`).
"""
offenders = [f for f in self._root_step_files() if self._has_static_jsdom_import(f)]
assert not offenders, (
"BUG-082 : ces fichiers importent `jsdom` mais tournent dans "
"l'étape racine (sans node_modules) — les déplacer dans l'étape "
f"JSDOM :\n" + "\n".join(offenders)
)
def test_jsdom_dependent_tests_run_in_jsdom_step(self):
"""BUG-082 : les suites à import statique `jsdom` tournent bien dans
l'étape JSDOM (les deux branches)."""
text = CI_YML.read_text(encoding="utf-8")
jsdom_part = text.split("Frontend JSDOM tests", 1)[1]
for suite in ("node upload.test.mjs", "node config-ai-keys.test.mjs"):
assert jsdom_part.count(suite) >= 2, (
f"BUG-082 : `{suite}` attendu dans les deux branches de "
"l'étape JSDOM"
)
_SPEC_RE = re.compile(
r"^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(>=|==|~=|>|<)\s*([0-9][^\s;#]*)"
)
def _floor(pkg: str) -> tuple[int, ...] | None:
"""Plancher `>=` déclaré pour `pkg` dans backend/requirements.txt."""
for raw in REQUIREMENTS.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = _SPEC_RE.match(line)
if not m or m.group(1).lower() != pkg or m.group(2) != ">=":
continue
return tuple(int(p) for p in re.match(r"[0-9]+(?:\.[0-9]+)*", m.group(3)).group(0).split("."))
return None
class TestDependencySecurityFloors:
"""Planchers de sécurité des dépendances (#87, BUG-091, BUG-093).
`pip-audit` est bloquant dans le job `security`. Comme l'image du runner
(`catthehacker/ubuntu:act-latest`) embarque des paquets *préinstallés* dans
sa toolcache Python, un plancher trop bas est « already satisfied » et
n'est jamais mis à niveau : c'est exactement ce qui a fait échouer le
job sur pypdf 6.16.0 (PYSEC-2026-3910 / PYSEC-2026-3911, DoS de ressources
atteignables via backend/pdf_reader.py).
"""
#: (paquet, plancher minimal, advisories corrigées au-dessus)
FLOORS = {
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
}
def test_security_floors_are_declared(self):
missing = [p for p in self.FLOORS if _floor(p) is None]
assert not missing, (
"plancher `>=` manquant dans backend/requirements.txt pour : "
f"{missing}"
)
def test_security_floors_are_high_enough(self):
too_low = {
p: (_floor(p), minimum)
for p, minimum in self.FLOORS.items()
if (_floor(p) or ()) < minimum
}
assert not too_low, (
"BUG-093 : plancher(s) sous le correctif de sécurité, "
f"le job `security` (pip-audit bloquant) échouerait : {too_low}"
)
+29 -9
View File
@@ -23,18 +23,34 @@ def _read(rel: str) -> str:
class TestE2ELocalPs:
SCRIPT = "scripts/run-e2e-local.ps1"
def test_npx_never_prompts(self):
"""`npx --yes` partout : aucun prompt « Ok to proceed? » qui pend."""
def test_playwright_via_node_no_npx(self):
"""Playwright est lancé via `node` direct, jamais via `npx`.
`Start-Process` ne peut pas exécuter `npx` (ni le `.ps1` ni le
`.cmd` ne sont des applications Win32 directes : "%1 is not a valid
Win32 application"), et `npx` sans `--yes` peut pendre sur un prompt
interactif. Seules les mentions en commentaires/logs sont tolérées.
"""
content = _read(self.SCRIPT)
# Aucune invocation nue `npx ...` / `& npx ...` (toujours via le helper
# avec `--yes`) ; les mentions dans commentaires/Write-Host sont OK.
bare = [
line.strip()
for line in content.splitlines()
if re.match(r"^\s*(?:&\s*)?npx\s", line)
]
assert not bare, f"invocations npx nues (sans --yes) : {bare}"
assert content.count("--yes") >= 2, "au moins install + test en --yes"
assert not bare, f"invocations npx nues : {bare}"
assert "node_modules/@playwright/test/cli.js" in content, (
"CLI Playwright locale attendue (via node)"
)
# `$Args` est une variable automatique PowerShell : un paramètre de
# ce nom serait écrasé (helper lancé sans arguments → exit 0 muet).
# (commentaires `#` exclus : la mise en garde elle-même le cite).
code_lines = [
line for line in content.splitlines()
if not line.strip().startswith("#")
]
assert not re.search(r"\$Args\b", "\n".join(code_lines)), (
"BUG-080 : paramètre `$Args` interdit (shadowing par $args automatique)"
)
def test_browser_install_skippable(self):
"""Install navigateurs sautée si chromium déjà présent (sauf forçage)."""
@@ -43,12 +59,16 @@ class TestE2ELocalPs:
assert "E2E_INSTALL_BROWSERS" in content
def test_test_step_has_timeout(self):
"""L'étape `playwright test` est bornée (E2E_TIMEOUT_SEC, défaut 900)."""
"""L'étape `playwright test` est bornée (E2E_TIMEOUT_SEC, défaut 1800).
Le défaut dépasse le globalTimeout Playwright (25 min en local) pour
que ce soit Playwright qui abandonne proprement (avec rapport) en premier.
"""
content = _read(self.SCRIPT)
assert "E2E_TIMEOUT_SEC" in content
assert "Wait-Process -Timeout" in content
assert re.search(r"E2E_TIMEOUT_SEC.*else\s*\{\s*900\s*\}", content), (
"défaut E2E_TIMEOUT_SEC=900 attendu"
assert re.search(r"E2E_TIMEOUT_SEC.*else\s*\{\s*1800\s*\}", content), (
"défaut E2E_TIMEOUT_SEC=1800 attendu"
)
+34
View File
@@ -0,0 +1,34 @@
"""Parité i18n FR/EN des locales du frontend (#87 T9).
`frontend/locales/fr.json` et `en.json` doivent exposer exactement les mêmes
clés (comparaison profonde) : toute clé manquante fait afficher la clé brute
dans l'UI au lieu du libellé.
"""
from __future__ import annotations
import json
from pathlib import Path
LOCALES = Path(__file__).resolve().parent.parent / "frontend" / "locales"
def _flat(d: dict, prefix: str = "") -> set[str]:
keys = set()
for k, v in d.items():
name = f"{prefix}.{k}" if prefix else str(k)
if isinstance(v, dict):
keys |= _flat(v, name)
else:
keys.add(name)
return keys
def _load(lang: str) -> set[str]:
return _flat(json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8")))
class TestI18nParity:
def test_fr_en_same_keys(self):
fr, en = _load("fr"), _load("en")
assert not (fr - en), f"clés sans traduction EN : {sorted(fr - en)[:10]}"
assert not (en - fr), f"clés sans traduction FR : {sorted(en - fr)[:10]}"
+61
View File
@@ -367,3 +367,64 @@ class TestMfaApiEndpoints:
data = login_resp.json()
assert "access_token" in data
assert data.get("mfa_required") is None
# ── BUG-081 : /api/auth/mfa/status avec auth désactivée ──────────────────
@pytest.fixture
def mfa_client_noauth():
"""TestClient avec auth DÉSACTIVÉE (OBSIGATE_AUTH_ENABLED=false)."""
tmp = Path(tempfile.mkdtemp())
data_dir = tmp / "data"
data_dir.mkdir()
orig_cwd = os.getcwd()
test_vault_path = os.path.abspath("test-vault")
os.chdir(str(tmp))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = test_vault_path
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.main import app
from backend.indexer import build_index, index
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from backend.search import init_inverted_index
init_inverted_index()
from fastapi.testclient import TestClient
client = TestClient(app, raise_server_exceptions=False)
yield client
if hasattr(client, 'close'):
client.close()
loop.run_until_complete(asyncio.sleep(0))
os.chdir(orig_cwd)
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_WATCHER_ENABLED"]:
os.environ.pop(k, None)
class TestMfaStatusAuthDisabled:
"""BUG-081 : `GET /api/auth/mfa/status` ne doit pas répondre 500 quand
l'auth est désactivée (pseudo-user `anonymous` sans entrée en store)."""
def test_mfa_status_anonymous_returns_disabled(self, mfa_client_noauth):
resp = mfa_client_noauth.get("/api/auth/mfa/status")
assert resp.status_code == 200, f"BUG-081: {resp.status_code} {resp.text[:200]}"
body = resp.json()
assert body["mfa_enabled"] is False
assert body["totp_enabled"] is False
assert body["webauthn_credentials"] == 0
+15 -5
View File
@@ -204,7 +204,7 @@ class TestAdvancedSearchAPI:
pytest.skip(f"Advanced search non-JSON response: {resp.text[:200]}")
def test_basic(self, client):
resp = client.get("/api/advanced-search?q=python&vault=all")
resp = client.get("/api/search/advanced?q=python&vault=all")
data = self._check(resp)
assert data["total"] >= 1
assert len(data["results"]) > 0
@@ -217,17 +217,27 @@ class TestAdvancedSearchAPI:
assert "path" in r
def test_pagination(self, client):
resp = client.get("/api/advanced-search?q=python&limit=1&offset=0")
resp = client.get("/api/search/advanced?q=python&limit=1&offset=0")
data = self._check(resp)
assert len(data["results"]) <= 1
def test_facets(self, client):
resp = client.get("/api/advanced-search?q=python&vault=all")
data = self._check(resp)
resp = client.get("/api/search/advanced?q=python&vault=all")
# Hard assertion: the facet contract must hold through the response_model
# (regression #157: `extensions` used to be stripped by SearchFacets).
assert resp.status_code == 200, resp.text[:200]
data = resp.json()
assert "facets" in data
assert "extensions" in data["facets"]
exts = data["facets"]["extensions"]
assert isinstance(exts, dict)
assert all(not e.startswith(".") for e in exts)
if data["total"] > 0:
assert len(exts) >= 1
assert sum(exts.values()) <= data["total"]
def test_empty_query(self, client):
resp = client.get("/api/advanced-search?q=")
resp = client.get("/api/search/advanced?q=")
data = self._check(resp)
# Empty query should return 0 results
assert data["total"] == 0
+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
+35 -13
View File
@@ -1,31 +1,34 @@
"""Tests — cookies Secure, CORS same-origin implicite, avertissement bind (ROADMAP #87 T3).
"""Tests — cookies Secure, CORS same-origin explicite, avertissement bind (ROADMAP #87 T3/T8).
- `is_secure_cookies()` suit `OBSIGATE_SECURE_COOKIES` (défaut `false` :
compatibilité logins en HTTP local — les navigateurs ignorent les cookies
`Secure` en clair).
- Aucun en-tête CORS permissif n'est émis : sans `CORSMiddleware`, les
navigateurs appliquent le same-origin par défaut (politique explicite par
l'absence — vérifiée ici pour qu'un ajout futur de CORS soit conscient).
- `is_secure_cookies()` : `OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut
`auto` : Secure si la requête arrive en https, sinon pas de flag — les
navigateurs ignorent les cookies `Secure` en clair).
- CORS same-origin EXPLICITE : `CORSMiddleware(allow_origins=[])` — aucun
`Access-Control-Allow-*` n'est émis même avec un `Origin` cross-origin,
et les preflights sont rejetés (400).
"""
from __future__ import annotations
def test_secure_cookies_default_false(monkeypatch):
"""Défaut `false` (logins HTTP locaux préservés)."""
def test_secure_cookies_default_auto(monkeypatch):
"""Défaut `auto` : sans requête → pas de flag (logins HTTP locaux préservés)."""
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
assert is_secure_cookies() is False
def test_secure_cookies_opt_in(monkeypatch):
"""`OBSIGATE_SECURE_COOKIES=true` → flag actif (insensible à la casse)."""
def test_secure_cookies_forced_values(monkeypatch):
"""`true`/`1`/`yes` → flag ; `false`/`0`/`no` → pas de flag (insensible à la casse)."""
from backend.auth.router import is_secure_cookies
for value in ("true", "True", "TRUE", "1", "yes"):
for value in ("true", "True", "TRUE", "1", "yes", "on"):
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value)
assert is_secure_cookies() is (value.lower() == "true")
assert is_secure_cookies() is True
for value in ("false", "False", "FALSE", "0", "no", "off"):
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value)
assert is_secure_cookies() is False
def test_no_cors_headers_on_api(client):
@@ -42,6 +45,25 @@ def test_no_cors_headers_on_public_share(client):
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
def test_cross_origin_get_emits_no_acao(client):
"""#87 T8 : même avec un `Origin` cross-origin, aucun ACAO (refus explicite)."""
resp = client.get("/api/health", headers={"Origin": "http://evil.example"})
assert resp.status_code == 200
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
def test_cross_origin_preflight_rejected(client):
"""#87 T8 : preflight cross-origin → 400 (origine non autorisée)."""
resp = client.options(
"/api/health",
headers={
"Origin": "http://evil.example",
"Access-Control-Request-Method": "GET",
},
)
assert resp.status_code == 400
def test_security_headers_present(client):
"""En-têtes de durcissement posés par le middleware (non-régression)."""
resp = client.get("/api/health")
+419
View File
@@ -0,0 +1,419 @@
"""Unit tests for the existing-workbook AI tools (#153 A6).
Covers ``list_xlsx_sheets``, ``xlsx_to_markdown``, ``update_xlsx_cells`` and
``append_xlsx_rows`` — risk levels, confirmation gating, vault persistence and
the reuse of the guarded mutation service (P0 guards, formula neutralisation).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from backend.tools.api import (
ToolConfirmationRequired,
ToolContext,
ToolError,
call_tool,
get_tool,
)
from backend.tools.context import ToolRisk
openpyxl = pytest.importorskip("openpyxl")
@pytest.fixture
def vault(tmp_path, monkeypatch):
"""A minimal configured vault (index entry patched, no full build)."""
vault_dir = tmp_path / "Vault"
vault_dir.mkdir()
monkeypatch.setitem(
__import__("backend.indexer", fromlist=["index"]).index,
"Vault",
{"name": "Vault", "path": str(vault_dir), "config": {}},
)
return vault_dir
@pytest.fixture
def workbook(vault: Path) -> Path:
"""A two-sheet workbook: Budget (values + a formula) and Notes."""
path = vault / "classeur.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Budget"
ws.append(["Poste", "Montant"])
ws.append(["Loyer", 900])
ws.append(["Courses", 250])
notes = wb.create_sheet("Notes")
notes["A1"] = "bonjour"
wb.save(path)
return path
def _ctx() -> ToolContext:
return ToolContext(
user={"username": "tester", "role": "admin", "vaults": ["*"]},
audit_enabled=False,
)
class TestRegistry:
def test_read_tools_are_read_risk(self):
for name in ("list_xlsx_sheets", "xlsx_to_markdown"):
spec = get_tool(name)
assert spec is not None
assert spec.risk == ToolRisk.READ
def test_mutation_tools_require_confirmation(self):
for name in ("update_xlsx_cells", "append_xlsx_rows"):
spec = get_tool(name)
assert spec is not None
assert spec.risk == ToolRisk.WRITE
assert spec.requires_confirmation is True
def test_mutation_tools_raise_without_confirmation(self, vault, workbook):
with pytest.raises(ToolConfirmationRequired):
call_tool("update_xlsx_cells", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "cells": {"B3": "300"},
})
with pytest.raises(ToolConfirmationRequired):
call_tool("append_xlsx_rows", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "rows": [["Total", 1150]],
})
class TestListXlsxSheets:
def test_lists_names_and_dimensions(self, vault, workbook):
out = call_tool("list_xlsx_sheets", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
})
assert out.ok
data = out.data
assert [s["name"] for s in data["sheets"]] == ["Budget", "Notes"]
budget = data["sheets"][0]
assert budget["total_rows"] == 3 and budget["total_cols"] == 2
assert budget["truncated"] is False
def test_wrong_extension_rejected(self, vault):
with pytest.raises(ToolError):
call_tool("list_xlsx_sheets", _ctx(), {
"vault": "Vault", "path": "note.md",
})
def test_missing_file_rejected(self, vault):
with pytest.raises(ToolError):
call_tool("list_xlsx_sheets", _ctx(), {
"vault": "Vault", "path": "absent.xlsx",
})
class TestXlsxToMarkdown:
def test_renders_a_bounded_markdown_table(self, vault, workbook):
out = call_tool("xlsx_to_markdown", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
})
assert out.ok
data = out.data
assert data["sheet"] == "Budget"
assert data["rows"] == 3 and data["truncated"] is False
assert "| Poste | Montant |" in data["markdown"]
assert "| Loyer | 900 |" in data["markdown"]
def test_specific_sheet(self, vault, workbook):
out = call_tool("xlsx_to_markdown", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "sheet": "Notes",
})
assert out.ok
data = out.data
assert data["sheet"] == "Notes"
assert "bonjour" in data["markdown"]
def test_unknown_sheet_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("xlsx_to_markdown", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "sheet": "Nope",
})
def test_big_sheet_is_flagged_truncated(self, vault):
path = vault / "gros.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
for i in range(150):
ws.append([f"r{i}", i])
wb.save(path)
out = call_tool("xlsx_to_markdown", _ctx(), {
"vault": "Vault", "path": "gros.xlsx",
})
assert out.ok
data = out.data
assert data["rows"] == 100
assert data["truncated"] is True
class TestUpdateXlsxCells:
def test_edits_cells_and_survives_a_reload(self, vault, workbook):
out = call_tool("update_xlsx_cells", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "cells": {"B3": "300"},
}, confirm=True)
assert out.ok and out.data["status"] == "ok"
wb = openpyxl.load_workbook(workbook)
assert wb["Budget"]["B3"].value == 300 # coerced like the viewer
wb.close()
def test_formula_stays_text_by_default(self, vault, workbook):
out = call_tool("update_xlsx_cells", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "cells": {"C1": "=B2+B3"},
}, confirm=True)
assert out.ok
wb = openpyxl.load_workbook(workbook)
assert wb["Budget"]["C1"].data_type == "s" # A4 guard inherited
wb.close()
def test_empty_cells_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("update_xlsx_cells", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "cells": {},
})
class TestAppendXlsxRows:
def test_appends_below_the_last_row(self, vault, workbook):
out = call_tool("append_xlsx_rows", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "rows": [["Total", 1150]],
}, confirm=True)
assert out.ok
assert out.data["first_row"] == 4
wb = openpyxl.load_workbook(workbook)
ws = wb["Budget"]
assert ws["A4"].value == "Total"
assert ws["B4"].value == 1150 and isinstance(ws["B4"].value, int)
wb.close()
def test_values_are_coerced(self, vault, workbook):
out = call_tool("append_xlsx_rows", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "rows": [["VRAI", "01/02/2026", 12.5]],
}, confirm=True)
assert out.ok
wb = openpyxl.load_workbook(workbook)
ws = wb["Budget"]
assert ws["A4"].value is True
assert (ws["B4"].value.month, ws["B4"].value.day) == (2, 1)
assert ws["C4"].value == 12.5
wb.close()
def test_unknown_sheet_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("append_xlsx_rows", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Nope", "rows": [["x"]],
})
def test_empty_rows_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("append_xlsx_rows", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "rows": [],
})
# ── #156-A14 — formats étendus, recherche, analyse de plage, structure ──────
@pytest.fixture
def notes_csv(vault: Path) -> Path:
"""A `;`-separated CSV — the locale the viewer sniffs (BUG-098)."""
path = vault / "notes.csv"
path.write_text("Poste;Montant\nLoyer;900\nCourses;250\n", encoding="utf-8")
return path
class TestRegistryExtended:
def test_read_tools_include_search_and_analyze(self):
for name in ("search_workbook", "analyze_range"):
spec = get_tool(name)
assert spec is not None
assert spec.risk == ToolRisk.READ
def test_structure_tool_requires_confirmation(self, vault, workbook):
spec = get_tool("edit_xlsx_structure")
assert spec is not None
assert spec.risk == ToolRisk.WRITE
with pytest.raises(ToolConfirmationRequired):
call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
})
class TestXlsmSupport:
def test_reads_and_edits_an_xlsm(self, vault):
path = vault / "macro.xlsm"
wb = openpyxl.Workbook()
wb.active.title = "Donnees"
wb.active.append(["A", "B"])
wb.active.append([1, 2])
wb.save(path)
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
assert out.ok and out.data["sheets"][0]["name"] == "Donnees"
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
assert "| A | B |" in md.data["markdown"]
edit = call_tool("update_xlsx_cells", _ctx(), {
"vault": "Vault", "path": "macro.xlsm", "sheet": "Donnees",
"cells": {"A2": "9"},
}, confirm=True)
assert edit.ok
wb2 = openpyxl.load_workbook(path)
assert wb2["Donnees"]["A2"].value == 9
wb2.close()
class TestCsvSupport:
def test_lists_reads_and_searches_a_csv(self, vault, notes_csv):
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "notes.csv"})
assert out.ok
assert out.data["sheets"] == [
{"name": "notes", "total_rows": 3, "total_cols": 2, "truncated": False}
]
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "notes.csv"})
assert "| Poste | Montant |" in md.data["markdown"]
found = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "notes.csv", "query": "Loyer",
})
assert found.ok
assert found.data["total"] == 1
assert found.data["matches"][0] == {"sheet": "notes", "cell": "A2", "value": "Loyer"}
assert found.data["by_sheet"] == {"notes": 1}
def test_updates_a_csv_keeping_its_delimiter(self, vault, notes_csv):
out = call_tool("update_xlsx_cells", _ctx(), {
"vault": "Vault", "path": "notes.csv", "cells": {"B2": "1000"},
}, confirm=True)
assert out.ok
assert "Loyer;1000" in notes_csv.read_text(encoding="utf-8-sig")
def test_append_rows_refuses_a_csv(self, vault, notes_csv):
with pytest.raises(ToolError):
call_tool("append_xlsx_rows", _ctx(), {
"vault": "Vault", "path": "notes.csv",
"sheet": "notes", "rows": [["x"]],
}, confirm=True)
def test_structure_refuses_a_csv(self, vault, notes_csv):
with pytest.raises(ToolError):
call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "notes.csv",
"actions": [{"op": "sheet_add", "name": "X"}],
}, confirm=True)
def test_wrong_extension_is_still_refused(self, vault):
with pytest.raises(ToolError):
call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "note.md", "query": "a",
})
class TestSearchWorkbook:
def test_scans_every_sheet_with_counts(self, vault, workbook):
out = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": "o",
})
assert out.ok
assert out.data["by_sheet"]["Budget"] > 0
assert out.data["by_sheet"]["Notes"] == 1 # « bonjour » : une seule cellule
assert out.data["truncated"] is False
assert out.data["total"] == sum(out.data["by_sheet"].values())
def test_case_sensitive_and_sheet_scope(self, vault, workbook):
loose = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
})
assert loose.data["total"] == 1
strict = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
"case_sensitive": True,
})
assert strict.data["total"] == 0
scoped = call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": "bonjour",
"sheet": "Budget",
})
assert scoped.data["total"] == 0
def test_empty_query_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("search_workbook", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "query": " ",
})
class TestAnalyzeRange:
def test_aggregates_a_column(self, vault, workbook):
out = call_tool("analyze_range", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"sheet": "Budget", "range": "B2:B3",
})
assert out.ok
stats = out.data["numeric"]
assert stats["count"] == 2
assert stats["sum"] == 1150
assert stats["mean"] == 575
assert stats["min"] == 250 and stats["max"] == 900
def test_whole_sheet_and_fr_separators(self, vault):
path = vault / "prix.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["Article", "Prix"])
ws.append(["A", "1 234,5"])
ws.append(["B", 10])
wb.save(path)
out = call_tool("analyze_range", _ctx(), {"vault": "Vault", "path": "prix.xlsx"})
assert out.ok
assert out.data["numeric"]["count"] == 2
assert out.data["numeric"]["max"] == 1234.5
assert out.data["cells"] == 6 # 2 en-têtes + 4 valeurs
def test_bad_range_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("analyze_range", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "range": "pas-une-plage",
})
class TestEditXlsxStructure:
def test_inserts_a_row_and_survives_a_reload(self, vault, workbook):
out = call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
}, confirm=True)
assert out.ok and out.data["actions"] == 1
wb = openpyxl.load_workbook(workbook)
assert wb["Budget"]["A3"].value == "Loyer" # la ligne 2 a été poussée
wb.close()
def test_adds_a_sheet(self, vault, workbook):
out = call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx",
"actions": [{"op": "sheet_add", "name": "Trois"}],
}, confirm=True)
assert out.ok
wb = openpyxl.load_workbook(workbook)
assert "Trois" in wb.sheetnames
wb.close()
def test_empty_actions_rejected(self, vault, workbook):
with pytest.raises(ToolError):
call_tool("edit_xlsx_structure", _ctx(), {
"vault": "Vault", "path": "classeur.xlsx", "actions": [],
})
+15 -2
View File
@@ -10,6 +10,19 @@ from backend.tools.context import ToolContext, ToolError, ToolMode, ToolRisk
from backend.tools.registry import get_tool
@pytest.fixture
def no_dns(monkeypatch):
"""Neutralise la résolution DNS réelle du garde SSRF (runner au réseau fragile).
Seuls les tests qui vérifient l'extraction HTML mockent ``httpx.get`` ; sans
ce mock, ``_assert_public_http_url`` résolvait ``example.com`` pour de vrai et
le test échouait en ``dns_error`` sur un runner dont le DNS est instable.
Les tests de garde SSRF (``test_private_address_rejected``) n'utilisent PAS
la fixture : ils doivent au contraire traverser le vrai garde.
"""
monkeypatch.setattr(web, "_assert_public_http_url", lambda url: url)
class FakeResponse:
def __init__(self, payload: Any = None, json_data: Any = None, status_code: int = 200,
content: bytes = b"", headers: dict | None = None, url: str = "https://example.com/x"):
@@ -171,7 +184,7 @@ class TestWebSearch:
class TestFetchUrl:
def test_html_converted_to_text(self, monkeypatch):
def test_html_converted_to_text(self, monkeypatch, no_dns):
html = (b"<html><head><title>T&</title><style>b{}</style>"
b"<script>evil()</script></head><body><p>hello</p><ul>"
b"<li>one</li><li>two</li></ul></body></html>")
@@ -196,7 +209,7 @@ class TestFetchUrl:
web.fetch_url(_ctx(), web.FetchUrlInput(url="file:///etc/passwd"))
assert ei.value.code == "invalid_scheme"
def test_binary_content_rejected(self, monkeypatch):
def test_binary_content_rejected(self, monkeypatch, no_dns):
monkeypatch.setattr(web.httpx, "get",
lambda *a, **k: FakeResponse(content=b"%PDF-1.4...",
headers={"content-type": "application/pdf"}))
+16 -3
View File
@@ -20,7 +20,7 @@ class TestRegistration:
class TestRenderUnavailable:
def test_missing_playwright_clear_error(self, monkeypatch):
def test_missing_playwright_clear_error(self, monkeypatch, no_dns):
monkeypatch.setattr(webrender, "_playwright_available", lambda: False)
with pytest.raises(ToolError) as ei:
web.fetch_url(_ctx(), web.FetchUrlInput(
@@ -35,8 +35,21 @@ class TestRenderUnavailable:
assert ei.value.code in ("ssrf_blocked", "dns_error")
@pytest.fixture
def no_dns(monkeypatch):
"""Neutralise la résolution DNS réelle du garde SSRF.
``fetch_url`` appelle ``_assert_public_http_url`` (getaddrinfo) *avant* le
rendu : sur un runner au DNS instable le test échouait en ``dns_error``
au lieu d'atteindre le worker Playwright mocké. ``webrender`` importe la
fonction dans son propre namespace : les deux références sont mockées.
"""
monkeypatch.setattr(web, "_assert_public_http_url", lambda url: url)
monkeypatch.setattr(webrender, "_assert_public_http_url", lambda url: url)
class TestRenderSuccess:
def test_fetch_url_delegates_to_worker(self, monkeypatch):
def test_fetch_url_delegates_to_worker(self, monkeypatch, no_dns):
captured = {}
def fake_render(url):
@@ -51,7 +64,7 @@ class TestRenderSuccess:
assert out["rendered"] is True
assert "dynamic content" in out["text"]
def test_worker_failure_maps_to_tool_error(self, monkeypatch):
def test_worker_failure_maps_to_tool_error(self, monkeypatch, no_dns):
monkeypatch.setattr(webrender, "_playwright_available", lambda: True)
def boom(url):
+122
View File
@@ -0,0 +1,122 @@
"""Workbook dashboard: named ranges, chart/pivot objects and per-sheet KPI
stats (#153 A17), plus the ``GET …/xlsx/dashboard`` endpoint wiring."""
from __future__ import annotations
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
VAULT = "TestVault"
@pytest.fixture
def dash_book(test_vault_dir: str) -> str:
"""A workbook with a named range, a chart and numeric KPI cells."""
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference
from openpyxl.workbook.defined_name import DefinedName
path = Path(test_vault_dir) / "dash.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "Ventes"
ws["A2"] = 12
ws["A3"] = 48
ws["B2"] = "=SUM(A2:A3)"
wb.defined_names.add(DefinedName("MaPlage", attr_text="Data!$A$1:$B$5"))
chart = BarChart()
chart.add_data(Reference(ws, min_col=1, min_row=1, max_row=3))
ws.add_chart(chart, "D2")
wb.save(path)
return str(path)
@pytest.fixture
def plain_book(test_vault_dir: str) -> str:
"""A workbook without any dashboard-worthy feature."""
from openpyxl import Workbook
path = Path(test_vault_dir) / "plain.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Vide"
ws["A1"] = "texte seul"
wb.save(path)
return str(path)
class TestDashboardReading:
def test_named_range_is_detected(self, dash_book):
from backend.xlsx_reader import read_workbook_dashboard
dash = read_workbook_dashboard(Path(dash_book))
names = [r["name"] for r in dash["named_ranges"]]
assert "MaPlage" in names
entry = next(r for r in dash["named_ranges"] if r["name"] == "MaPlage")
assert "Data!" in entry["ref"]
def test_charts_are_counted(self, dash_book):
from backend.xlsx_reader import read_workbook_dashboard
dash = read_workbook_dashboard(Path(dash_book))
assert dash["objects"]["charts"] >= 1
assert dash["objects"]["pivots"] == 0
def test_sheet_kpis_carry_numeric_cells(self, dash_book):
from backend.xlsx_reader import read_workbook_dashboard
dash = read_workbook_dashboard(Path(dash_book))
sheet = next(s for s in dash["sheets"] if s["name"] == "Data")
assert sheet["cells"] >= 4
assert sheet["formulas"] == 1
assert sheet["numeric"] >= 2
values = [k["value"] for k in sheet["kpi"]]
assert 12 in values and 48 in values
def test_plain_book_yields_empty_dashboard(self, plain_book):
from backend.xlsx_reader import read_workbook_dashboard
dash = read_workbook_dashboard(Path(plain_book))
assert dash["named_ranges"] == []
assert dash["objects"]["charts"] == 0
sheet = dash["sheets"][0]
assert sheet["numeric"] == 0
assert sheet["kpi"] == []
def test_broken_book_yields_empty_payload(self, test_vault_dir):
from backend.xlsx_reader import read_workbook_dashboard
bad = Path(test_vault_dir) / "broken-dash.xlsx"
bad.write_bytes(b"not a zip")
dash = read_workbook_dashboard(bad)
assert dash["named_ranges"] == []
assert dash["sheets"] == []
class TestDashboardEndpoint:
def test_endpoint_serves_the_dashboard(self, client, dash_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": dash_book})
assert resp.status_code == 200 # sanity: file is readable
dash = client.get(
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": dash_book}
).json()
assert any(r["name"] == "MaPlage" for r in dash["named_ranges"])
assert dash["objects"]["charts"] >= 1
assert any(s["kpi"] for s in dash["sheets"])
def test_unknown_file_is_404(self, client):
resp = client.get(
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "nope.xlsx"}
)
assert resp.status_code == 404
def test_non_workbook_is_415(self, client, test_vault_dir):
(Path(test_vault_dir) / "texte.txt").write_text("hello", encoding="utf-8")
resp = client.get(
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "texte.txt"}
)
assert resp.status_code == 415
+294
View File
@@ -0,0 +1,294 @@
"""Additional spreadsheet formats in the viewer (#153 A16): .xlsm editable
with macros preserved, .xls/.ods read-only renders and .csv A1-addressed
saves."""
from __future__ import annotations
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
VAULT = "TestVault"
@pytest.fixture
def formats_vault(test_vault_dir: str) -> Path:
"""Directory of the test vault, for fixture files written in-place."""
return Path(test_vault_dir)
@pytest.fixture
def xlsm_book(formats_vault: Path) -> str:
"""A macro-enabled workbook: one sheet, two cells, a fake VBA blob."""
path = formats_vault / "macro.xlsm"
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "Nom"
ws["A2"] = "Ada"
wb.save(path)
# Inject a minimal vbaProject.bin part so the archive really IS a .xlsm
# (keep_vba only matters when macros exist).
import shutil
import zipfile
real = path.with_suffix(".tmp.xlsm")
with zipfile.ZipFile(path) as zin, zipfile.ZipFile(real, "w") as zout:
for item in zin.namelist():
zout.writestr(item, zin.read(item))
zout.writestr("xl/vbaProject.bin", b"VBA-FAKE-CONTENT")
shutil.move(real, path)
return "macro.xlsm"
@pytest.fixture
def ods_book(formats_vault: Path) -> str:
"""A small ODS spreadsheet."""
# The odfpy distribution installs a top-level module named `odf`.
pytest.importorskip("odf")
from odf.opendocument import OpenDocumentSpreadsheet
from odf.table import Table, TableCell, TableRow
from odf.text import P
path = formats_vault / "classeur.ods"
doc = OpenDocumentSpreadsheet()
table = Table(name="Feuille1")
for values in (["Ville", "Pop"], ["Paris", "2100000"]):
tr = TableRow()
for v in values:
tc = TableCell(valuetype="string")
tc.addElement(P(text=str(v)))
tr.addElement(tc)
table.addElement(tr)
doc.spreadsheet.addElement(table)
doc.save(str(path))
return "classeur.ods"
class TestXlsmEditable:
def test_read_serves_the_xlsx_viewer_payload(self, client, xlsm_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book})
assert resp.status_code == 200
data = resp.json()
assert data["is_xlsx"] is True
assert data["extension"] == ".xlsm"
# Macros are NOT lossy for .xlsm: keep_vba preserves them, so no
# confirmation round-trip is ever triggered.
assert data["xlsx_lossy_features"] == []
assert 'data-cell="A1"' in data["xlsx_sheets"][0]["html"]
def test_save_round_trips_and_keeps_vba(self, client, xlsm_book):
resp = client.put(
f"/api/file/{VAULT}/xlsx/save",
params={"path": xlsm_book},
json={"sheet": "Data", "cells": {"A2": "Grace"}},
)
assert resp.status_code == 200
import zipfile
# Re-read through the API instead of guessing the root on disk.
reread = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book})
assert "Grace" in reread.json()["xlsx_sheets"][0]["html"]
# The fake VBA part survived the round-trip.
with zipfile.ZipFile(_vault_file(client, xlsm_book)) as zf:
assert "xl/vbaProject.bin" in zf.namelist()
def test_sheet_window_is_served_for_xlsm(self, client, xlsm_book):
"""BUG-097 — a .xlsm is editable, so its lazy row windows must work."""
resp = client.get(
f"/api/file/{VAULT}/xlsx/sheet",
params={"path": xlsm_book, "sheet": "Data", "offset": 0, "limit": 100},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["sheet"] == "Data"
assert 'data-cell="A2"' in body["html"]
assert body["has_more"] is False
def test_sheet_window_still_refuses_other_formats(self, client, formats_vault):
(formats_vault / "plain.csv").write_text("a,b\n", encoding="utf-8")
resp = client.get(
f"/api/file/{VAULT}/xlsx/sheet",
params={"path": "plain.csv", "sheet": "Feuille1"},
)
assert resp.status_code == 415
def test_lossy_gate_is_skipped_for_xlsm(self, client, formats_vault, xlsm_book):
from backend.services.mutations import edit_xlsx_cells
# `client` is required: the service resolves the vault through the app
# registry (the test used to pass only thanks to state leaked by an
# earlier test in this class).
# No force flag: the save must succeed despite the vbaProject part
# (it would raise xlsx_lossy_content on a plain .xlsx).
result = edit_xlsx_cells(VAULT, xlsm_book, "Data", {"A2": "Alan"})
assert result["success"] is True
class TestLegacyReadOnly:
def test_xls_renders_cells(self, client, formats_vault):
pytest.importorskip("xlrd")
import shutil
shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls")
resp = client.get(f"/api/file/{VAULT}", params={"path": "sample.xls"})
assert resp.status_code == 200
data = resp.json()
assert data["is_xlsx"] is True
assert data["xlsx_readonly"] is True
sheet = data["xlsx_sheets"][0]
assert sheet["name"] == "Data"
assert "Produit" in sheet["html"]
assert "Café" in sheet["html"]
def test_ods_renders_cells(self, client, ods_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": ods_book})
assert resp.status_code == 200
data = resp.json()
assert data["xlsx_readonly"] is True
html = data["xlsx_sheets"][0]["html"]
assert "Paris" in html
assert "2100000" in html
def test_readonly_meta_is_empty(self, client, formats_vault):
pytest.importorskip("xlrd")
import shutil
shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls")
sheet = client.get(
f"/api/file/{VAULT}", params={"path": "sample.xls"}
).json()["xlsx_sheets"][0]
assert sheet["styles"] == {}
assert sheet["merges"] == []
assert sheet["freeze"] == ""
def test_broken_legacy_file_yields_one_empty_sheet(self, client, formats_vault):
(formats_vault / "broken.xls").write_bytes(b"not an ole file")
resp = client.get(f"/api/file/{VAULT}", params={"path": "broken.xls"})
assert resp.status_code == 200
sheet = resp.json()["xlsx_sheets"][0]
assert sheet["rows"] == 0
class TestCsvDelimiter:
"""BUG-098 — the delimiter is detected, not assumed to be a comma."""
def test_sniffing_handles_the_common_separators(self):
from backend.xlsx_reader import sniff_csv_delimiter
assert sniff_csv_delimiter("a;b\n1;2\n") == ";"
assert sniff_csv_delimiter("a,b\n1,2\n") == ","
assert sniff_csv_delimiter("a\tb\n1\t2\n") == "\t"
def test_single_column_falls_back_to_a_comma(self):
from backend.xlsx_reader import sniff_csv_delimiter
assert sniff_csv_delimiter("juste\nune\ncolonne\n") == ","
class TestCsvEditable:
def test_read_renders_the_xlsx_shaped_table(self, client, formats_vault):
(formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8")
resp = client.get(f"/api/file/{VAULT}", params={"path": "liste.csv"})
assert resp.status_code == 200
data = resp.json()
assert data["is_csv"] is True
html = data["html"]
assert 'data-cell="A1"' in html
assert "xlsx-table" in html
def test_save_cells_and_grow_the_grid(self, client, formats_vault):
(formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8")
resp = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "liste.csv"},
json={"cells": {"B1": "modifié", "C3": "nouveau"}},
)
assert resp.status_code == 200
text = _vault_file(client, "liste.csv").read_text(encoding="utf-8")
assert "modifié" in text
assert "nouveau" in text
# Existing rows survive, and the reference beyond the extent grew the
# grid to 3 rows x 3 cols.
assert text.startswith("a,modifié")
assert "1,2" in text
assert text.count("\n") >= 2
def test_csv_values_are_stored_verbatim(self, client, formats_vault):
(formats_vault / "formules.csv").write_text("x\n", encoding="utf-8")
client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "formules.csv"},
json={"cells": {"A1": "=1+1"}},
)
text = _vault_file(client, "formules.csv").read_text(encoding="utf-8")
assert "=1+1" in text # no formula engine: stored as text
def test_quoting_survives_a_round_trip(self, client, formats_vault):
(formats_vault / "quotes.csv").write_text('nom\n"Dupont, Jean"\n', encoding="utf-8")
client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "quotes.csv"},
json={"cells": {"A2": "Martin, Pierre"}},
)
text = _vault_file(client, "quotes.csv").read_text(encoding="utf-8")
assert '"Martin, Pierre"' in text
def test_semicolon_csv_renders_multiple_columns(self, client, formats_vault):
"""BUG-098 — a French (semicolon) CSV is no longer a single column."""
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
html = client.get(
f"/api/file/{VAULT}", params={"path": "fr.csv"}
).json()["html"]
assert 'data-cell="B1"' in html and 'data-cell="B2"' in html
assert "Paris" in html
def test_semicolon_is_preserved_when_saving(self, client, formats_vault):
"""BUG-098 — the write-back keeps the file's own delimiter."""
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
resp = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "fr.csv"},
json={"cells": {"B2": "Lyon"}},
)
assert resp.status_code == 200
text = _vault_file(client, "fr.csv").read_text(encoding="utf-8")
assert "Nom;Ville" in text
assert "Ada;Lyon" in text
assert "," not in text # the source delimiter is not switched to a comma
def test_quoted_semicolon_survives_a_round_trip(self, client, formats_vault):
(formats_vault / "quote-fr.csv").write_text(
'Nom;Note\n"Dupont; Jean";ok\n', encoding="utf-8"
)
client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "quote-fr.csv"},
json={"cells": {"B2": "modifié"}},
)
text = _vault_file(client, "quote-fr.csv").read_text(encoding="utf-8")
assert '"Dupont; Jean"' in text
assert ";modifié" in text
def test_bad_reference_is_refused(self, client, formats_vault):
(formats_vault / "liste.csv").write_text("a\n", encoding="utf-8")
resp = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "liste.csv"},
json={"cells": {"XX": "v"}}, # missing row number
)
assert resp.status_code == 400
def _vault_file(client, rel: str) -> Path:
"""Resolve a vault file path for on-disk assertions."""
root = None
for route in client.app.routes:
pass
# The service layer exposes the vault root; use it directly.
from backend.services.vaults import get_vault_root
root = get_vault_root(VAULT)
return root / rel
+188
View File
@@ -0,0 +1,188 @@
"""Structural mutations of an .xlsx workbook (#153 A14): service + endpoint.
Covers sheet add/rename/delete/duplicate and row/col insert/delete, the
atomicity of the batch (one locked rewrite) and the shared P0 guards
(lossy 409 gate, backup, path safety).
"""
from __future__ import annotations
import zipfile
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
VAULT = "TestVault"
@pytest.fixture
def book(test_vault_dir: str) -> str:
path = Path(test_vault_dir) / "struct.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Data"
ws.append(["Nom", "Valeur"])
ws.append(["a", 1])
ws.append(["b", 2])
wb.create_sheet("Vide")
wb.save(path)
return str(path)
def _put(client, path="struct.xlsx", actions=None, **extra):
return client.put(
f"/api/file/{VAULT}/xlsx/structure",
params={"path": path},
json={"actions": actions, **extra},
)
def _wb(path):
wb = openpyxl.load_workbook(path)
try:
return wb
finally:
pass
class TestSheetOps:
def test_add_rename_delete_sheet(self, client, book):
resp = _put(client, actions=[
{"op": "sheet_add", "name": "Extra", "at": 0},
{"op": "sheet_rename", "from": "Vide", "to": "Renommée"},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(book)
assert wb.sheetnames[0] == "Extra" # inserted at position 0
assert "Renommée" in wb.sheetnames and "Vide" not in wb.sheetnames
wb.close()
resp = _put(client, actions=[{"op": "sheet_delete", "name": "Extra"}])
assert resp.status_code == 200
wb = openpyxl.load_workbook(book)
assert "Extra" not in wb.sheetnames
wb.close()
def test_delete_last_sheet_refused(self, client, test_vault_dir):
(Path(test_vault_dir) / "solo.xlsx").write_bytes(book_bytes("Solo"))
resp = _put(client, path="solo.xlsx", actions=[
{"op": "sheet_delete", "name": "Solo"},
])
assert resp.status_code == 400
def test_duplicate_copies_values(self, client, book):
resp = _put(client, actions=[
{"op": "sheet_duplicate", "name": "Data", "as": "Data copie"},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(book)
assert wb["Data copie"]["A1"].value == "Nom"
assert wb["Data copie"]["B3"].value == 2
wb.close()
def book_bytes(sheet_name: str) -> bytes:
import io
wb = openpyxl.Workbook()
wb.active.title = sheet_name
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
class TestRowColOps:
def test_row_insert_shifts_and_delete_removes(self, client, book):
resp = _put(client, actions=[
{"op": "row_insert", "sheet": "Data", "at": 2, "count": 1},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(book)
ws = wb["Data"]
assert ws["A2"].value is None # the new blank row
assert ws["A3"].value == "a" # shifted down
wb.close()
resp = _put(client, actions=[
{"op": "row_delete", "sheet": "Data", "at": 2, "count": 1},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(book)
assert wb["Data"]["A2"].value == "a"
wb.close()
def test_col_insert_and_delete(self, client, book):
assert _put(client, actions=[
{"op": "col_insert", "sheet": "Data", "at": 2},
]).status_code == 200
wb = openpyxl.load_workbook(book)
assert wb["Data"]["B1"].value is None
assert wb["Data"]["C1"].value == "Valeur"
wb.close()
assert _put(client, actions=[
{"op": "col_delete", "sheet": "Data", "at": 2},
]).status_code == 200
wb = openpyxl.load_workbook(book)
assert wb["Data"]["B1"].value == "Valeur"
wb.close()
class TestGuards:
def test_unknown_sheet_is_400(self, client, book):
resp = _put(client, actions=[{"op": "row_insert", "sheet": "Nope", "at": 1}])
assert resp.status_code == 400
def test_unknown_op_is_400(self, client, book):
resp = _put(client, actions=[{"op": "sheet_explode", "name": "X"}])
assert resp.status_code == 400
def test_bad_position_is_400(self, client, book):
resp = _put(client, actions=[
{"op": "row_insert", "sheet": "Data", "at": "deux"},
])
assert resp.status_code == 400
def test_empty_actions_is_400(self, client, book):
assert _put(client, actions=[]).status_code == 400
def test_lossy_workbook_refused_without_force(self, client, test_vault_dir):
"""Same 409 gate as the cell edits (A1)."""
path = Path(test_vault_dir) / "lossy-struct.xlsx"
wb = openpyxl.Workbook()
wb.active.title = "S"
wb["S"]["A1"] = "=A2" # no cached value -> add one via the raw XML
wb.save(path)
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"))
items[sheet] = items[sheet].decode("utf-8").replace(
"<f>A2</f>", "<f>A2</f><v>7</v>"
).encode("utf-8")
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
for name, blob in items.items():
zf.writestr(name, blob)
resp = _put(client, path="lossy-struct.xlsx", actions=[
{"op": "sheet_add", "name": "X"},
])
assert resp.status_code == 409
assert resp.json()["code"] == "xlsx_lossy_content"
resp = _put(client, path="lossy-struct.xlsx", actions=[
{"op": "sheet_add", "name": "X"},
], force=True)
assert resp.status_code == 200
def test_atomicity_one_bad_action_writes_nothing(self, client, book):
"""A batch with a valid action followed by a bad one writes nothing."""
resp = _put(client, actions=[
{"op": "sheet_add", "name": "Temp"},
{"op": "sheet_delete", "name": "Inexistante"},
])
assert resp.status_code == 400
wb = openpyxl.load_workbook(book)
assert "Temp" not in wb.sheetnames
wb.close()
+267
View File
@@ -0,0 +1,267 @@
"""Style metadata in the .xlsx viewer (#153 A15): bold, colors, number formats,
merged ranges and freeze panes — plus the endpoint wiring."""
from __future__ import annotations
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
VAULT = "TestVault"
@pytest.fixture
def styled_book(test_vault_dir: str) -> str:
"""A workbook with a bold red-font header, a filled cell, a merge and a freeze."""
from openpyxl.styles import Font, PatternFill
path = Path(test_vault_dir) / "styles.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "En-tête"
ws["A1"].font = Font(bold=True, italic=True, color="FFCC0000")
ws["B1"] = "Total"
ws["B1"].fill = PatternFill("solid", fgColor="FFFFEE99")
ws["B2"] = 1234.5
ws["B2"].number_format = "#,##0.00"
ws["C3"] = "fusionnée"
ws.merge_cells("C3:D3")
ws["C3"].value = "fusionnée"
ws.freeze_panes = "A2"
wb.save(path)
return str(path)
@pytest.fixture
def plain_book(test_vault_dir: str) -> str:
"""An unstyled workbook — the write side starts from a blank slate."""
path = Path(test_vault_dir) / "plain.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Data"
ws.append(["Nom", "Valeur"])
ws.append(["a", 1])
ws.append(["b", 2])
wb.save(path)
return str(path)
class TestStyleReading:
def test_bold_italic_and_font_color(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
styles = read_sheet_styles(Path(styled_book), "Data")
a1 = styles["A1"]
assert "font-weight:600" in a1["style"]
assert "font-style:italic" in a1["style"]
assert a1["style"].startswith("color:#cc0000")
def test_background_fill_is_read(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
styles = read_sheet_styles(Path(styled_book), "Data")
assert "background:#ffee99" in styles["B1"]["style"]
def test_number_format_is_signalled(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
styles = read_sheet_styles(Path(styled_book), "Data")
assert "font-family" in styles["B2"]["style"]
def test_plain_cell_has_no_entry(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
styles = read_sheet_styles(Path(styled_book), "Data")
assert "A2" not in styles # untouched cell
def test_merges_and_freeze(self, styled_book):
from backend.xlsx_reader import read_sheet_freeze, read_sheet_merges
assert read_sheet_merges(Path(styled_book), "Data") == ["C3:D3"]
assert read_sheet_freeze(Path(styled_book), "Data") == "A2"
def test_unknown_sheet_yields_empty(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
assert read_sheet_styles(Path(styled_book), "Nope") == {}
def test_broken_file_yields_empty(self, test_vault_dir):
from backend.xlsx_reader import read_sheet_styles
bad = Path(test_vault_dir) / "broken-styles.xlsx"
bad.write_bytes(b"not a zip")
assert read_sheet_styles(bad, "Data") == {}
class TestStyleWriting:
"""#156-A8 — the viewer can now WRITE formatting, not just read it.
One locked, atomic rewrite through ``PUT …/xlsx/style``: styles (bold,
italic, underline, colours, alignment, number format), merges, column
widths, row heights and frozen panes.
"""
def _style(self, client, path, ops, **extra):
return client.put(
f"/api/file/{VAULT}/xlsx/style",
params={"path": path},
json={"ops": ops, **extra},
)
def test_styles_survive_a_reload(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{
"op": "cell", "sheet": "Data", "range": "A1:B1",
"style": {
"bold": True, "italic": True, "underline": True,
"font_color": "#cc0000", "fill_color": "#ffe08a",
"align": "center", "number_format": "0.00",
},
},
])
assert resp.status_code == 200
assert resp.json()["revision"]
wb = openpyxl.load_workbook(plain_book)
ws = wb["Data"]
assert ws["A1"].font.bold is True and ws["A1"].font.italic is True
assert ws["A1"].font.underline == "single"
assert ws["A1"].font.color.rgb == "FFCC0000"
assert ws["A1"].fill.start_color.rgb == "FFFFE08A"
assert ws["A1"].alignment.horizontal == "center"
assert ws["B1"].number_format == "0.00"
assert ws["A1"].value == "Nom", "the value is untouched"
assert not ws["A2"].font.bold, "a neighbour outside the range is untouched"
wb.close()
def test_the_written_style_is_read_back_by_the_viewer(self, client, plain_book):
self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "fill_color": "#ffe08a"}},
])
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
assert resp.status_code == 200
sheet = resp.json()["xlsx_sheets"][0]
style = sheet["styles"]["A1"]
assert "font-weight:600" in style and "background:#ffe08a" in style
def test_merges_sizes_and_freeze(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{"op": "merge", "sheet": "Data", "range": "A1:B1"},
{"op": "col_width", "sheet": "Data", "col": "A", "width": 24},
{"op": "row_height", "sheet": "Data", "row": 2, "height": 30},
{"op": "freeze", "sheet": "Data", "cell": "A2"},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
ws = wb["Data"]
assert [str(r) for r in ws.merged_cells.ranges] == ["A1:B1"]
assert ws.column_dimensions["A"].width == 24
assert ws.row_dimensions[2].height == 30
assert str(ws.freeze_panes) == "A2"
wb.close()
resp = self._style(client, "plain.xlsx", [
{"op": "unmerge", "sheet": "Data", "range": "A1:B1"},
{"op": "freeze", "sheet": "Data", "cell": ""},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
ws = wb["Data"]
assert not list(ws.merged_cells.ranges)
assert ws.freeze_panes is None
wb.close()
def test_a_style_can_be_cleared(self, client, plain_book):
self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "font_color": "#cc0000", "fill_color": "#ffe08a"}},
])
resp = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": False, "font_color": "", "fill_color": "", "number_format": "General"}},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
ws = wb["Data"]
assert ws["A1"].font.bold in (None, False)
assert ws["A1"].font.color is None
assert ws["A1"].fill.fill_type is None
assert ws["A1"].number_format == "General"
wb.close()
def test_invalid_inputs_are_refused(self, client, plain_book):
bad_style = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"blink": True}},
])
assert bad_style.status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {}},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"fill_color": "rouge"}},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "pas-une-plage", "style": {"bold": True}},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Nope", "range": "A1", "style": {"bold": True}},
]).status_code == 400
assert self._style(client, "plain.xlsx", [{"op": "spin", "sheet": "Data"}]).status_code == 400
assert self._style(client, "plain.xlsx", []).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "col_width", "sheet": "Data", "col": "A", "width": 900},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"align": "diagonal"}},
]).status_code == 400
def test_a_csv_is_refused(self, client, test_vault_dir):
(Path(test_vault_dir) / "style.csv").write_text("a,b\n1,2\n", encoding="utf-8")
resp = self._style(client, "style.csv", [
{"op": "cell", "sheet": "x", "range": "A1", "style": {"bold": True}},
])
assert resp.status_code == 400
def test_a_stale_revision_is_refused_and_the_fresh_one_accepted(self, client, plain_book):
first = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
])
assert first.status_code == 200
stale = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
], if_match="deadbeef-1")
assert stale.status_code == 409
assert stale.json()["details"]["reason"] == "stale_revision"
fresh = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
], if_match=first.json()["revision"])
assert fresh.status_code == 200
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A3", "style": {"bold": True}},
], if_match=42).status_code == 400
def test_a_backup_is_written_before_the_rewrite(self, client, plain_book):
from backend.services.backups import get_backup_dir
assert self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
]).status_code == 200
backups = list(get_backup_dir(VAULT, "plain.xlsx").glob("plain.xlsx.*.bak"))
assert backups, "the rewrite is backed up first"
class TestEndpointWiring:
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
assert resp.status_code == 200
sheet = resp.json()["xlsx_sheets"][0]
assert sheet["styles"]["A1"].startswith("color:#cc0000")
assert sheet["aligns"] == {} or isinstance(sheet["aligns"], dict)
assert sheet["merges"] == ["C3:D3"]
assert sheet["freeze"] == "A2"
def test_rendered_html_carries_the_inline_style(self, client, styled_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
html = resp.json()["xlsx_sheets"][0]["html"]
assert 'data-cell="A1" style="' in html
assert "font-weight:600" in html
+813 -3
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 ───────────────────────────────────────────────────────────────
@@ -53,6 +96,29 @@ class TestXlsxDisplay:
resp = client.get(f"/api/file/{VAULT}", params={"path": "corrupt.xlsx"})
assert resp.status_code == 500
def test_empty_sheet_renders_an_editable_blank_grid(self, client, test_vault_dir):
"""BUG-094 — a blank sheet used to render as a bare « Feuille vide »
paragraph with no cell, so a freshly added sheet could not be filled
and had no way to insert a row/column. It now exposes a small editable
grid with real A1 coordinates."""
from openpyxl import Workbook
path = Path(test_vault_dir) / "blank.xlsx"
wb = Workbook()
wb.active.title = "Vide"
wb.create_sheet("Vide2")
wb.save(str(path))
resp = client.get(f"/api/file/{VAULT}", params={"path": "blank.xlsx"})
assert resp.status_code == 200
vide = next(s for s in resp.json()["xlsx_sheets"] if s["name"] == "Vide")
assert "Feuille vide" not in vide["html"]
assert 'data-cell="A1"' in vide["html"]
assert 'data-cell="H20"' in vide["html"] # last cell of the blank grid
assert vide["rows"] == 20
assert vide["cols"] == 8
assert vide["truncated"] is False
# ── Index parity (tree visibility) ────────────────────────────────────────
@@ -63,16 +129,208 @@ class TestXlsxIndexing:
assert ".xlsx" in SUPPORTED_EXTENSIONS
def test_xlsx_indexed_metadata_only(self, test_vault_dir, xlsx_file):
def test_xlsx_indexes_sheet_names_and_headers(self, test_vault_dir, xlsx_file):
"""#153 A5 — a workbook is searchable by its cell values."""
from backend.indexer import _index_single_file_sync
info = _index_single_file_sync(VAULT, test_vault_dir, xlsx_file)
assert info is not None
assert info["extension"] == ".xlsx"
assert info["content"] == "" # binary: never read into TF-IDF
# Sheet names + header rows reach TF-IDF (was metadata-only before A5).
assert "Budget" in info["content"]
assert "Poste" in info["content"]
assert info["content_preview"]
assert info["title"] # filename-derived title
class TestXlsxCachedValues:
"""#153 A12 — show what Excel last computed next to each formula."""
def test_cached_result_is_shown_beside_the_formula(self, client, lossy_xlsx):
from backend.xlsx_reader import render_sheets
html = render_sheets(Path(lossy_xlsx))[0]["html"]
# A2 is "=A1*3" with <v>9</v> in the fixture.
assert 'data-cell="A2"' in html
assert "=A1*3" in html
assert "xlsx-cached" in html
assert ">9<" in html # the cached result Excel computed
def test_no_shadow_when_no_formula_carries_a_result(self, client, xlsx_file):
from backend.xlsx_reader import render_sheets
html = render_sheets(Path(xlsx_file))[0]["html"]
assert "xlsx-cached" not in html # budget.xlsx has no <v> at all
def test_plain_cells_are_never_duplicated(self, client, lossy_xlsx):
from backend.xlsx_reader import render_sheets
html = render_sheets(Path(lossy_xlsx))[0]["html"]
# A1 is the literal 3: the two reads agree, so only one value shows.
assert 'data-cell="A1"' in html
assert html.count("xlsx-cached") == 1 # only the formula cell
class TestXlsxValueCoercion:
"""#153 A10 — a typed value comes back with the type Excel would infer."""
def _write(self, client, xlsx_file, ref, value):
return client.put(
f"/api/file/{VAULT}/xlsx/save",
params={"path": "budget.xlsx"},
json={"sheet": "Budget", "cells": {ref: value}, "force": True},
)
def test_number_and_bool_are_stored_as_typed(self, client, xlsx_file):
resp = self._write(
client, xlsx_file, "D1", "42"
)
assert resp.status_code == 200
resp = self._write(client, xlsx_file, "D2", "VRAI")
assert resp.status_code == 200
resp = self._write(client, xlsx_file, "D3", "12/03/2026")
assert resp.status_code == 200
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
ws = wb["Budget"]
assert ws["D1"].value == 42 and isinstance(ws["D1"].value, int)
assert ws["D2"].value is True
assert ws["D3"].value.year == 2026 and ws["D3"].value.month == 3
assert ws["D3"].value.day == 12 # FR day-first, not 3 December
wb.close()
def test_day_first_date_is_not_read_as_us(self, client, xlsx_file):
"""'01/02/2026' is 1 February in French, not 2 January."""
assert self._write(client, xlsx_file, "E1", "01/02/2026").status_code == 200
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
d = wb["Budget"]["E1"].value
wb.close()
assert (d.month, d.day) == (2, 1)
def test_ambiguous_text_is_left_alone(self, client, xlsx_file):
"""A version string or a partial date stays text, never a date."""
assert self._write(client, xlsx_file, "F1", "3.14.2").status_code == 200
assert self._write(client, xlsx_file, "F2", "Ref 12/34").status_code == 200
assert self._write(client, xlsx_file, "F3", "12/2026").status_code == 200
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
ws = wb["Budget"]
assert ws["F1"].value == "3.14.2"
assert ws["F2"].value == "Ref 12/34"
assert ws["F3"].value == "12/2026"
wb.close()
def test_plain_integer_text_becomes_a_number(self, client, xlsx_file):
"""Typing a bare number yields a number, as it did before #153 A10."""
assert self._write(client, xlsx_file, "H1", "75001").status_code == 200
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
assert wb["Budget"]["H1"].value == 75001
wb.close()
def test_formula_looking_date_stays_text(self, client, xlsx_file):
"""A formula is never mistaken for a date (BUG-088 must not regress)."""
assert self._write(client, xlsx_file, "G1", "=12/03/2026").status_code == 200
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
c = wb["Budget"]["G1"]
wb.close()
assert c.data_type == "s"
assert c.value == "=12/03/2026"
class TestXlsxSearchable:
"""#153 A5 — a keyword living in a CELL must make the file findable."""
def test_search_finds_a_word_stored_in_a_cell(self, test_vault_dir):
"""A word typed in a CELL must make the workbook findable.
Deliberately hermetic: it drives the indexer and the inverted index
directly instead of going through the HTTP reload, because both are
process-wide singletons that other test modules mutate (some reload the
module outright), which would make this test order-dependent.
"""
from openpyxl import Workbook
import backend.indexer as indexer
import backend.search as search_mod
path = Path(test_vault_dir) / "fournisseurs.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Contacts"
ws["A1"] = "Fournisseur"
ws["A2"] = "Menuiserie Beaulieu"
wb.save(path)
# Index exactly this vault, from scratch.
indexer.index[VAULT] = {
"files": [indexer._index_single_file_sync(VAULT, test_vault_dir, str(path))],
"tags": {},
"paths": [],
"config": {},
}
entries = [f for f in indexer.index[VAULT]["files"] if f]
assert entries, "le tableur n'a pas ete indexe"
assert "Menuiserie" in entries[0]["content"], (
f"contenu indexe : {entries[0]['content'][:80]!r}"
)
search_mod.init_inverted_index()
hits = [r["path"] for r in search_mod.search("Menuiserie", "all")]
assert "fournisseurs.xlsx" in hits
def test_indexable_text_is_capped(self, tmp_path):
"""A data dump must not flood the index."""
from openpyxl import Workbook
from backend.xlsx_reader import MAX_INDEX_CHARS, extract_indexable_text
path = tmp_path / "huge.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Big"
for r in range(1, 400):
ws.cell(row=r, column=1, value=f"ligne {r} " + "x" * 60)
wb.save(path)
text = extract_indexable_text(path)
assert len(text) <= MAX_INDEX_CHARS
assert "Big" in text # the sheet name survives
def test_corrupt_workbook_indexes_as_empty_not_a_crash(self, tmp_path):
from backend.xlsx_reader import extract_indexable_text
path = tmp_path / "broken.xlsx"
path.write_bytes(b"not a zip at all")
assert extract_indexable_text(path) == ""
def test_blank_rows_are_skipped(self, tmp_path):
from openpyxl import Workbook
from backend.xlsx_reader import extract_indexable_text
path = tmp_path / "sparse.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "S"
ws["A1"] = "Alpha"
ws["A50"] = "Omega" # beyond the indexed prefix -> ignored on purpose
wb.save(path)
text = extract_indexable_text(path)
assert "Alpha" in text
assert "Omega" not in text
assert "\t\t" not in text # no run of empty columns
# ── Edit ──────────────────────────────────────────────────────────────────
@@ -150,3 +408,555 @@ 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) == []
# ── BUG-099 — the cached-value probe must never fail silently ──────────────
def _probe_zip(tmp_path: Path, sheets: list[bytes]) -> Path:
"""Build a minimal archive holding the given worksheet XML parts."""
path = tmp_path / "probe.xlsx"
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
for i, blob in enumerate(sheets, start=1):
zf.writestr(f"xl/worksheets/sheet{i}.xml", blob)
return path
class TestXlsxCachedValueProbe:
"""BUG-099 — an exhausted probe is reported, never mistaken for clean."""
def test_exhausted_budget_is_reported_as_unverified(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from backend import xlsx_reader
# Budget smaller than the only sheet: the scan cannot reach its end.
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 64)
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 64)
book = _probe_zip(tmp_path, [b"<x/>" + b"filler" * 1024])
# Contre-preuve: the old code returned [] here — an interrupted scan was
# indistinguishable from a completed one, so the write guard stayed
# silent and the cached values could be dropped without warning.
assert xlsx_reader.inspect_workbook(book) == ["cached_values_unverified"]
def test_a_huge_first_sheet_does_not_starve_the_next_one(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from backend import xlsx_reader
# Per-sheet cap (1 KiB) well under the total allowance (4 KiB): a single
# shared budget would spend everything on sheet 1 and never look at
# sheet 2, where the only cached formula lives.
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 1024)
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 4096)
cached = b'<sheetData><c r="A1"><f>1+1</f><v>2</v></c></sheetData>'
book = _probe_zip(tmp_path, [b"filler" * 2048, cached])
assert "cached_values" in xlsx_reader.inspect_workbook(book)
def test_a_completed_clean_scan_reports_nothing(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from backend import xlsx_reader
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 4096)
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 8192)
book = _probe_zip(tmp_path, [b"<x/>", b"<x/>"])
# No cached value and no budget issue: no false positive either.
assert xlsx_reader.inspect_workbook(book) == []
# ── #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"
@pytest.fixture
def wide_xlsx(test_vault_dir: str) -> str:
"""Workbook whose sheet exceeds BOTH render caps (501 rows x 45 cols).
Sparse on purpose: a cell in A501 and one in AS1 are enough for openpyxl
to declare those dimensions, without writing 20 000 cells to disk.
"""
from openpyxl import Workbook
path = Path(test_vault_dir) / "grand.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "tête"
ws["A501"] = "dernière ligne"
ws["AS1"] = "colonne 45"
wb.save(path)
return str(path)
@pytest.fixture
def edge_xlsx(test_vault_dir: str) -> str:
"""Sheet exactly on the caps (500 rows x 40 cols) — must NOT be truncated."""
from openpyxl import Workbook
path = Path(test_vault_dir) / "limite.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "bord"
ws["A500"] = "ligne 500"
ws["AN1"] = "colonne 40"
wb.save(path)
return str(path)
# ── #153 A8 — silent truncation made visible ─────────────────────────────
class TestXlsxTruncationNotice:
"""A sheet bigger than the caps must SAY so instead of looking complete."""
def test_render_reports_the_real_dimensions(self, client, wide_xlsx):
resp = client.get(f"/api/file/{VAULT}", params={"path": "grand.xlsx"})
sheet = resp.json()["xlsx_sheets"][0]
assert (sheet["total_rows"], sheet["total_cols"]) == (501, 45)
assert sheet["truncated"] is True
# The caps are the coverage the banner announces — `rows`/`cols` are
# post-trim and would understate it on a sparse sheet.
assert (sheet["max_rows"], sheet["max_cols"]) == (500, 40)
assert (sheet["rows"], sheet["cols"]) == (1, 1) # only 3 filled cells
def test_a_sheet_on_the_caps_is_not_flagged(self, client, edge_xlsx):
"""Boundary: 500x40 is exactly what the renderer supports."""
resp = client.get(f"/api/file/{VAULT}", params={"path": "limite.xlsx"})
sheet = resp.json()["xlsx_sheets"][0]
assert sheet["truncated"] is False
assert (sheet["total_rows"], sheet["total_cols"]) == (500, 40)
def test_a_normal_sheet_is_not_flagged(self, client, xlsx_file):
resp = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"})
assert all(not s["truncated"] for s in resp.json()["xlsx_sheets"])
def test_blank_tail_is_not_reported_as_truncation(self, client, test_vault_dir):
"""A sheet with empty rows below its data fits in the caps."""
from openpyxl import Workbook
path = Path(test_vault_dir) / "blanc.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "seule ligne"
ws["A300"] = None # formatted-but-empty row inside the caps
wb.save(path)
resp = client.get(f"/api/file/{VAULT}", params={"path": "blanc.xlsx"})
sheet = resp.json()["xlsx_sheets"][0]
assert sheet["truncated"] is False
assert sheet["rows"] == 1 # trailing blanks dropped by _trim
# ── #153 A9 — lazy per-sheet loading ─────────────────────────────────────
class TestXlsxSheetWindow:
"""GET /api/file/{vault}/xlsx/sheet — one window of one sheet."""
def _get(self, client, path="budget.xlsx", **params):
return client.get(
f"/api/file/{VAULT}/xlsx/sheet", params={"path": path, **params}
)
def test_window_returns_rows_and_totals(self, client, xlsx_file):
resp = self._get(client, sheet="Budget", offset=0, limit=10)
assert resp.status_code == 200
data = resp.json()
assert data["sheet"] == "Budget"
assert (data["offset"], data["limit"]) == (0, 10)
assert data["rows"] == 2 and data["cols"] == 2
assert data["total_rows"] == 2 and data["truncated"] is False
assert (data["max_rows"], data["max_cols"]) == (500, 40)
assert data["has_more"] is False
assert 'data-cell="A1"' in data["html"]
def test_window_keeps_the_real_a1_coordinates(self, client, xlsx_file):
"""A window must be indistinguishable from a full render: the A1
references and the row numbers have to be the sheet's, not the
window's, or an edit would land on the wrong cell. The CONTENT matters
as much as the label — row 2 of "Budget" is "Total", not "Poste"."""
data = self._get(client, sheet="Budget", offset=1, limit=1).json()
assert data["rows"] == 1
assert 'data-cell="A2"' in data["html"]
assert 'data-cell="A1"' not in data["html"]
assert "<th class=\"xlsx-rownum\">2</th>" in data["html"]
assert "Total" in data["html"] and "Poste" not in data["html"]
def test_window_offsets_walk_the_whole_sheet(self, client, wide_xlsx):
first = self._get(client, path="grand.xlsx", sheet="Data", offset=0, limit=10).json()
last = self._get(client, path="grand.xlsx", sheet="Data", offset=500, limit=10).json()
assert first["truncated"] is True and first["has_more"] is True
assert 'data-cell="A501"' in last["html"] # the row the caps used to hide
assert "dernière ligne" in last["html"]
assert "dernière ligne" not in first["html"]
assert last["rows"] == 1 and last["has_more"] is False
def test_offset_past_the_end_is_empty_not_an_error(self, client, xlsx_file):
resp = self._get(client, sheet="Budget", offset=9999, limit=10)
assert resp.status_code == 200
data = resp.json()
assert data["rows"] == 0
assert data["has_more"] is False
def test_cached_result_survives_the_window(self, client, lossy_xlsx):
"""A12 must not be lost on the lazy path."""
data = self._get(client, path="risky.xlsx", sheet="Data", offset=0, limit=10).json()
assert "xlsx-cached" in data["html"]
def test_unknown_sheet_is_404(self, client, xlsx_file):
resp = self._get(client, sheet="Nope")
assert resp.status_code == 404
assert "Feuille introuvable" in resp.json()["detail"]
def test_missing_file_is_404(self, client, test_vault_dir):
assert self._get(client, path="absent.xlsx", sheet="Budget").status_code == 404
def test_non_xlsx_file_is_415(self, client, test_vault_dir):
(Path(test_vault_dir) / "note.md").write_text("# hi", encoding="utf-8")
resp = self._get(client, path="note.md", sheet="Budget")
assert resp.status_code == 415
def test_limit_above_the_server_cap_is_rejected(self, client, xlsx_file):
"""The cap is a contract, not a silent truncation of the request."""
assert self._get(client, sheet="Budget", limit=100_000).status_code == 422
def test_reader_clamps_a_hostile_limit(self, xlsx_file):
"""Belt and braces: the reader caps too, whoever calls it."""
from backend.xlsx_reader import MAX_WINDOW_ROWS, read_sheet_window
window = read_sheet_window(Path(xlsx_file), "Budget", offset=0, limit=10**9)
assert window["limit"] == MAX_WINDOW_ROWS
def test_reader_rejects_a_negative_offset(self, xlsx_file):
from backend.xlsx_reader import read_sheet_window
window = read_sheet_window(Path(xlsx_file), "Budget", offset=-5, limit=10)
assert window["offset"] == 0
# ── #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
class TestMetaCache:
"""#156-A13 — ``read_workbook_meta`` is cached on (path, mtime_ns, size).
A truncated sheet is fetched window by window: without the cache every
window paid a full normal-mode workbook load just for the style / merge /
freeze maps of one sheet.
"""
def test_second_call_is_served_from_the_cache(self, xlsx_file, monkeypatch):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
real_load = xlsx_reader.load_workbook
loads = []
def counting(*args, **kwargs):
loads.append(1)
return real_load(*args, **kwargs)
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
first = xlsx_reader.read_workbook_meta(path)
assert len(loads) == 1, "la première lecture charge le classeur"
second = xlsx_reader.read_workbook_meta(path)
assert len(loads) == 1, "la seconde lecture ne recharge pas le classeur"
assert second is first, "la même carte est servie (lecture seule côté appelant)"
assert "Budget" in first
def test_invalidation_forces_a_reload(self, xlsx_file, monkeypatch):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
real_load = xlsx_reader.load_workbook
loads = []
def counting(*args, **kwargs):
loads.append(1)
return real_load(*args, **kwargs)
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
xlsx_reader.read_workbook_meta(path)
xlsx_reader.invalidate_workbook_meta(path)
xlsx_reader.read_workbook_meta(path)
assert len(loads) == 2, "l'invalidation explicite force un rechargement"
def test_rewriting_the_file_invalidates_the_cache(self, xlsx_file):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == []
wb = openpyxl.load_workbook(xlsx_file)
wb["Budget"].merge_cells("A1:B1")
wb.save(xlsx_file)
wb.close()
# (mtime_ns, size) changed: the cached maps cannot be served any more.
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == ["A1:B1"]
def test_two_windows_share_one_metadata_load(self, xlsx_file, monkeypatch):
from backend import xlsx_reader
path = Path(xlsx_file)
xlsx_reader.invalidate_workbook_meta(path)
real_load = xlsx_reader.load_workbook
normal = []
def counting(*args, **kwargs):
if not kwargs.get("read_only"):
normal.append(1)
return real_load(*args, **kwargs)
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
assert xlsx_reader.read_sheet_window(path, "Budget", 0, 1) is not None
assert xlsx_reader.read_sheet_window(path, "Budget", 1, 1) is not None
assert len(normal) == 1, "les deux fenêtres partagent une seule lecture de métadonnées"
class TestOptimisticConcurrency:
"""#156-A12 — the read exposes a revision, a write may require it.
A stale ``if_match`` (the file changed since it was read: Excel, the
watcher, another worker) is refused with 409 ``conflict`` instead of
silently overwriting the other writer.
"""
def _save(self, client, body, path="budget.xlsx"):
return client.put(
f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body
)
def _revision(self, client, path="budget.xlsx"):
resp = client.get(f"/api/file/{VAULT}", params={"path": path})
assert resp.status_code == 200
return resp.json()["xlsx_revision"]
def test_the_read_exposes_a_revision_and_the_write_refreshes_it(self, client, xlsx_file):
rev = self._revision(client)
assert isinstance(rev, str) and rev
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev})
assert resp.status_code == 200
assert resp.json()["revision"] and resp.json()["revision"] != rev
def test_a_stale_if_match_is_refused_and_writes_nothing(self, client, xlsx_file):
rev = self._revision(client)
assert self._save(
client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev}
).status_code == 200
# The token is stale now: the client must re-read before writing again.
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "deux"}, "if_match": rev})
assert resp.status_code == 409
assert resp.json()["code"] == "conflict"
assert resp.json()["details"]["reason"] == "stale_revision"
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value is None
def test_omitting_if_match_keeps_last_writer_wins(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "sans-jeton"}})
assert resp.status_code == 200
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value == "sans-jeton"
def test_an_invalid_if_match_is_rejected(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "x"}, "if_match": 42})
assert resp.status_code == 400
def test_the_structure_endpoint_honours_if_match(self, client, xlsx_file):
rev = self._revision(client)
actions = [{"op": "row_insert", "sheet": "Budget", "at": 1}]
ok = client.put(
f"/api/file/{VAULT}/xlsx/structure",
params={"path": "budget.xlsx"},
json={"actions": actions, "if_match": rev},
)
assert ok.status_code == 200
stale = client.put(
f"/api/file/{VAULT}/xlsx/structure",
params={"path": "budget.xlsx"},
json={"actions": actions, "if_match": rev},
)
assert stale.status_code == 409
assert stale.json()["code"] == "conflict"
def test_the_csv_endpoint_honours_if_match(self, client, test_vault_dir):
csv_path = Path(test_vault_dir) / "notes.csv"
csv_path.write_text("a,b\n1,2\n", encoding="utf-8")
rev = self._revision(client, "notes.csv")
ok = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "notes.csv"},
json={"cells": {"A1": "x"}, "if_match": rev},
)
assert ok.status_code == 200
assert ok.json()["revision"] != rev
stale = client.put(
f"/api/file/{VAULT}/csv/save",
params={"path": "notes.csv"},
json={"cells": {"A1": "y"}, "if_match": rev},
)
assert stale.status_code == 409
assert csv_path.read_text(encoding="utf-8").startswith("x")