Compare commits

...
75 Commits
Author SHA1 Message Date
bruno 431d6e9338 docs: i18n et suivi #187 - assistant agent toujours actif
CI / lint (push) Failing after 2m51s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 2m14s
2026-10-07 08:18:45 -04:00
bruno d453969708 docs: i18n et suivi #187 - assistant agent toujours actif 2026-10-07 08:17:59 -04:00
bruno 778fa65b4c fix: assistant IA toujours en mode agent, retrait du bouton toggle #187
- Bouton « mode agent » du panneau supprimé : l'assistant est toujours
  agent (toute requête texte part sur /api/ai/bookslm/agent, les images
  restent sur /chat multimodal). Les mutations gardent la confirmation
  two-step. Deep Research et les quick actions `agent: true` basculaient
  déjà le mode en silence : le toggle ne protégeait plus rien.
- /agent résout le provider comme /chat (req.provider brut transmis à
  l'adapter pouvait désigner un fournisseur indisponible et retomber
  silencieusement sur un autre que l'étiquette SSE affichée).
- Schémas des outils mis en cache par (scope, taille du registre) avec
  copies fraîches par appelant (~27 ms de pydantic économisées par
  requête agent/MCP).
- Aide in-app réécrite, i18n FR/EN (retrait de ai.agent_mode_*),
  tests frontend adaptés (ai.test.mjs 100/100) + non-régression pytest
  (provider résolu, cache sûr).
2026-10-07 08:16:56 -04:00
bruno 69c3817579 docs: diagramme de flux de l'agent AI (boucle run_agent + pipeline call_tool + 50 outils)
CI / lint (push) Successful in 3m1s
CI / security (push) Successful in 2m12s
CI / test (push) Successful in 4m56s
CI / build (push) Successful in 2m5s
CI / e2e (push) Failing after 17m3s
2026-10-05 12:59:51 -04:00
bruno b89af917b1 fix: ordre du flag --no-cache dans build.ps1 (procedure Test refonctionne)
CI / lint (push) Successful in 2m54s
CI / security (push) Successful in 2m10s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 1m59s
CI / e2e (push) Failing after 17m13s
2026-10-04 19:58:38 -04:00
bruno d79202e698 feat: menus tableur a icones, fermeture au focus et polish mobile #179
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 2m11s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 2m0s
CI / e2e (push) Failing after 16m50s
2026-10-04 19:27:50 -04:00
bruno 4ce677902a feat: agent IA phase 4 — doublons #166, notifications externes #168 (Discord/Telegram/SMTP/webhook), taches planifiees #170
CI / lint (push) Successful in 2m53s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m52s
CI / build (push) Successful in 2m5s
CI / e2e (push) Successful in 16m14s
2026-10-04 13:11:25 -04:00
bruno fe9f7b49f7 feat: premier lancement ObsiGate + section Configuration harmonisée (#160)
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m44s
CI / build (push) Successful in 1m54s
CI / e2e (push) Successful in 16m25s
- Premier lancement : %USERPROFILE%\ObsiGate créé et monté comme vault de
  démarrage « ObsiGate » (remplace voute_obsidian), vault_path dessus,
  racine home nommée d'après son dernier segment (fin du « bruno » codé en
  dur), champs de fenêtre préservés.
- Document « Prise en main.md » embarqué dans le binaire
  (include_str!) et écrit dans ce répertoire si absent — jamais écrasé.
- Section « 🖥️ Vaults & dossiers (Desktop) » refondue : markup à classes
  (zéro style inline), bloc CSS desktop-roots-* sur variables (motif
  webauthn-key-item), boutons .config-btn-sm primaire/secondaire,
  cibles tactiles 44px en mobile.
- Tests : test_default_first_run_config, test_welcome_doc_embedded
  (cargo test 28 passed), suites frontend vertes, E2E 123/123.
2026-10-03 10:02:16 -04:00
bruno 649f965c52 fix: drag & drop de fichiers — fenêtre créée sans le handler Tauri (BUG-107)
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 4m40s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m25s
2026-10-02 23:42:01 -04:00
bruno d6fa8c7bb1 fix: injecte le nonce CSP dans les pages /docs et /redoc générées par FastAPI (BUG-106)
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m40s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 15m57s
2026-10-02 22:04:25 -04:00
bruno d2734dc8ce feat: gestion desktop des vaults & dossiers — retrait par menu contextuel et ajout en configuration #159
CI / lint (push) Successful in 3m2s
CI / security (push) Successful in 2m2s
CI / test (push) Successful in 4m42s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 15m54s
2026-10-02 21:04:17 -04:00
bruno bab272bb5c fix: desktop — commandes Tauri bloquées par l'ACL et crash heap à l'ouverture (BUG-104, BUG-105)
CI / lint (push) Successful in 2m45s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 4m44s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m16s
2026-10-02 20:17:20 -04:00
bruno 34428ea7b3 test: mobileBlockContaining ignore les commentaires CSS (CI #842)
CI / lint (push) Successful in 2m45s
CI / security (push) Successful in 1m58s
CI / test (push) Successful in 4m41s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m32s
- le helper choisissait le premier bloc media contenant la chaine 'mobile-toolbar' ; un commentaire citant ce selecteur (correctif BUG-103) le faisait retomber sur le mauvais bloc, sans la clearance .main-body (BUG-073)
- les commentaires sont maintenant retires avant la recherche de bloc
2026-10-02 15:46:05 -04:00
bruno 83e6a951dd fix: sidebar mobile passe au-dessus de la barre d outils du bas (BUG-103)
CI / lint (push) Failing after 2m11s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m58s
- en mobile le sidebar de navigation (z-index 200) passait sous .mobile-toolbar (900, 64 px) : les dernieres entrees de l'arbre etaient masquees
- regle mobile .sidebar : z-index 200 -> 950 ; test de non-regression 'mobile sidebar over toolbar' dans unit.test.mjs
2026-10-02 15:34:35 -04:00
bruno 3d618eb660 ci: relance du workflow apres annulation des runs 839 et 840
CI / lint (push) Successful in 2m44s
CI / security (push) Successful in 1m57s
CI / test (push) Successful in 4m37s
CI / build (push) Successful in 1m51s
CI / e2e (push) Successful in 15m58s
2026-10-02 14:42:18 -04:00
bruno 2add24a9c1 feat: onglet Accueil, menus contextuels de navigation et racine vault #158
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
- clic sur le titre: la page d'accueil s'ouvre dans un onglet Accueil (icone Maison) epingle en tete de barre, reactivable d'un clic (onglet propre au panneau actif en split)
- page de navigation: menus contextuels des repertoires et des fichiers identiques a ceux de la sidebar, clic sur la racine d'un vault qui ouvre la vue, icone dossier a la couleur de l'arbre de navigation
- BUG-101: bloc « Keyboard shortcuts for tabs » duplique dans ui.js (un seul Ctrl+W fermait deux onglets)
- BUG-102: le menu contextuel se refermait 10 ms apres son ouverture (scroll de reflow des icones lucide)
2026-10-02 14:05:01 -04:00
bruno a50227849d feat: onglet de navigation avec facettes, tris et retour Home complet #158
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 1m57s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1h50m30s
- clic sur un repertoire de l'arbre (modes focus vault et All) ouvre un onglet de navigation dedie qui coexiste avec les onglets de fichiers ouverts
- vue: sous-repertoires cliquables, facettes Vaults/Tags/Extensions (meme mecanisme que la recherche), tris Pertinence/Date, bouton Sauver; tags indexes exposes par GET /api/vault/{v}/files
- BUG-100: showWelcome re-injecte la section Raccourcis & Astuces et goHome efface la recherche globale + le filtre de la sidebar
2026-10-02 12:27:27 -04:00
bruno 5ffc9d851a fix: bouton de repli des facettes compact et icone seule qui pivote sur mobile #157
CI / lint (push) Successful in 2m44s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m51s
CI / e2e (push) Successful in 15m47s
2026-10-02 08:14:48 -04:00
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
bruno 7d70e0fb75 fix: harnais E2E local anti-blocage BUG-080
CI / lint (push) Failing after 1m51s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1m33s
2026-09-27 09:08:58 -04:00
bruno d70ecd0968 securite: #87 T5b nonces CSP prets pour bascule (sans changement) 2026-09-26 22:44:12 -04:00
bruno 36a4030c09 securite: #87 T5a supprime 16 handlers inline au profit de listeners (CSP inchangee) 2026-09-26 22:32:01 -04:00
bruno 330462e7a5 docs: #87 T4 consigne resultats E2E locaux (108/108 desktop, 10/10 mobiles cibles) 2026-09-26 22:22:31 -04:00
bruno 922dfa2e79 test: #87 T4 E2E XSS partage et lecteur + script serveur E2E avec progression 2026-09-26 21:46:16 -04:00
bruno 34fce932cb securite: #87 T3 cookies Secure centralises + CORS atteste (defaut inchange) 2026-09-26 18:37:19 -04:00
bruno 18b1e13f34 test: #87 T2 durcissement concurrence users.json et budget temps regex 2026-09-26 18:34:06 -04:00
bruno 7bee4a237d ci: #87 T1 bandit et npm audit bloquants, 5 suites frontend au CI 2026-09-26 18:30:04 -04:00
bruno d6cca2b1af feat: #85 T10 persistance etat (verrous stores, ratelimit SQLite) et cloture refonte 2026-09-26 18:10:10 -04:00
bruno 58312e64da refactor: #85 T9 extrait realtime et render vers modules dedies (comportement inchange) 2026-09-26 16:59:25 -04:00
bruno 3b0927a8c9 refactor: #85 T8 extrait vaults-history-conflicts vers backend/routers (comportement inchange) 2026-09-26 14:46:55 -04:00
bruno 6e527c371d refactor: #85 T7 extrait le domaine config vers backend/routers (comportement inchange) 2026-09-26 14:33:40 -04:00
171 changed files with 23532 additions and 2071 deletions
+8 -2
View File
@@ -12,8 +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)
# 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
@@ -23,6 +27,8 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# OBSIGATE_LOGIN_MAX_ATTEMPTS=10
# OBSIGATE_ACCOUNT_MAX_ATTEMPTS=10
# OBSIGATE_LOGIN_WINDOW_SECONDS=900
# Compteurs partagés/persistants (SQLite WAL, multi-workers) — défaut : mémoire.
# OBSIGATE_RATELIMIT_DB=data/ratelimit.db
# IP client derrière un reverse proxy (fait confiance à X-Forwarded-For)
# OBSIGATE_TRUST_PROXY=false
+65 -5
View File
@@ -38,14 +38,19 @@ jobs:
- name: Frontend unit tests
run: |
node tests/frontend/unit.test.mjs
node tests/frontend/navfacets.test.mjs
node tests/frontend/desktop-roots.test.mjs
node tests/frontend/image-viewer.test.mjs
node tests/frontend/pdf-viewer.test.mjs
node tests/frontend/forge-completion.test.mjs
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/pretty.test.mjs
node tests/frontend/media-viewer.test.mjs
node tests/frontend/mfa-settings.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
- 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
@@ -55,6 +60,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
@@ -63,6 +69,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
@@ -72,6 +81,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
@@ -80,6 +90,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 ─────────────────────────────────────────────────────────
@@ -122,15 +135,59 @@ 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
- name: Bandit (SAST)
run: bandit -r backend/ --skip B101,B110,B310 || echo "bandit found issues (non-blocking)"
- name: Bandit (SAST, bloquant — #87)
# B105 est exclu (aligné avec [tool.bandit] de pyproject.toml :
# faux positifs systématiques sur les noms de variables) ; les rares
# vrais positifs restants portent un `# nosec` justifié inline.
run: bandit -r backend/ --skip B101,B105,B110,B310
- name: Pip-audit (dependency vulnerabilities)
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking)"
- 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:
@@ -192,6 +249,9 @@ jobs:
npm ci
npx playwright install --with-deps chromium
- name: Npm audit (bloquant — #87, 0 dépendance prod hors Playwright)
run: npm audit --omit=dev
- name: Start ObsiGate
run: |
docker rm -f obsigate-e2e 2>/dev/null || true
+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
+1202 -29
View File
File diff suppressed because it is too large Load Diff
+7 -7
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.27.9-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.53.2-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 |
@@ -75,7 +75,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md))
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar ; chaque clic sur un répertoire de l'arbre ouvre un **onglet de navigation** — chemin, récents, sous-répertoires cliquables, facettes Vaults · Tags · Extensions, tri Pertinence/Date et enregistrement du répertoire — qui coexiste avec vos fichiers ouverts
- **🔍 Recherche avancée** : Moteur TF-IDF avec stemming français, normalisation des accents, snippets surlignés, facettes, pagination et tri — plus une **recherche sémantique** optionnelle (embeddings `all-MiniLM-L6-v2`, fusion hybride TF-IDF + RRF) activable via le toggle `~` ([détail](docs/features/semantic-search.md))
- **💡 Autocomplétion intelligente** : Suggestions de fichiers, tags et historique avec navigation clavier
- **🧩 Syntaxe de requête** : Opérateurs `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` avec chips visuels
@@ -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.27.9).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.2).
---
*Projet : ObsiGate | Version : 2.27.9 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.53.2 | Dernière mise à jour : Septembre 2026*
+7 -7
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.27.9-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.53.2-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 |
@@ -74,7 +74,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md))
- **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click)
- **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar; clicking a folder in the tree opens a dedicated **navigation tab** — path, recents, clickable subfolders, Vaults · Tags · Extensions facets, Pertinence/Date sorting and save-as-search — coexisting with your open files
- **🔍 Advanced Search** : TF-IDF search engine with French stemming, accent normalization, highlighted snippets, facets, pagination, and sorting — plus an optional **semantic search** (embeddings via `all-MiniLM-L6-v2`, hybrid TF-IDF + RRF fusion) toggled with `~` ([details](docs/features/semantic-search.md))
- **💡 Smart Autocomplete** : Suggestions for files, tags, and history with keyboard navigation
- **🧩 Query Syntax** : Operators `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` with visual chips
@@ -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.27.9).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.2).
---
*Project: ObsiGate | Version: 2.27.9 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.53.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.27.9
2.53.2
+33 -26
View File
@@ -119,25 +119,30 @@ def decode_token(token: str) -> dict | None:
_revoked_map: dict[str, int] = {}
_revoked_loaded = False
# ROADMAP #85 T10a — verrou autour du read-modify-write du store de
# révocation (perte de révocations en cas de logouts concurrents).
_revoked_lock = threading.RLock()
def _load_revoked():
"""Load revoked token JTIs from disk into memory (once)."""
global _revoked_loaded, _revoked_map
if _revoked_loaded:
return
if REVOKED_TOKENS_FILE.exists():
try:
data = json.loads(REVOKED_TOKENS_FILE.read_text())
# Drop entries whose underlying token has itself expired.
now = int(time.time())
_revoked_map = {
jti: int(exp) for jti, exp in data.items()
if int(exp) > now
}
except Exception as e:
logger.warning(f"Failed to load revoked tokens: {e}")
_revoked_map = {}
_revoked_loaded = True
with _revoked_lock:
if _revoked_loaded:
return
if REVOKED_TOKENS_FILE.exists():
try:
data = json.loads(REVOKED_TOKENS_FILE.read_text())
# Drop entries whose underlying token has itself expired.
now = int(time.time())
_revoked_map = {
jti: int(exp) for jti, exp in data.items()
if int(exp) > now
}
except Exception as e:
logger.warning(f"Failed to load revoked tokens: {e}")
_revoked_map = {}
_revoked_loaded = True
def _save_revoked():
@@ -154,24 +159,26 @@ def revoke_token(jti: str, expires_at: int | None = None):
``expires_at`` is the revoked token's own ``exp`` (unix seconds) — the
record is kept at least that long so a long-lived API token cannot
outlive its revocation. ``None`` means the token never expires (API/MCP
"sans fin") → the record is kept forever (capped at ~100 years, the JWT
"sans fin") → the record is kept forever (capped at ~100 years, the JWT
store's practical infinity). Default keeps 7 days (session tokens).
"""
_load_revoked()
now = int(time.time())
if expires_at is None:
until = now + 100 * 365 * 24 * 3600
else:
until = max(int(expires_at), now + REFRESH_TOKEN_EXPIRE_SECONDS)
_revoked_map[jti] = until
_save_revoked()
with _revoked_lock:
_load_revoked()
now = int(time.time())
if expires_at is None:
until = now + 100 * 365 * 24 * 3600
else:
until = max(int(expires_at), now + REFRESH_TOKEN_EXPIRE_SECONDS)
_revoked_map[jti] = until
_save_revoked()
logger.debug(f"Revoked token JTI: {jti[:8]}...")
def is_token_revoked(jti: str) -> bool:
"""Check if a token JTI has been revoked."""
_load_revoked()
return jti in _revoked_map
with _revoked_lock:
_load_revoked()
return jti in _revoked_map
# ---------------------------------------------------------------------------
+54 -14
View File
@@ -5,6 +5,7 @@
import base64
import binascii
import logging
import os
import re
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
@@ -15,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,
@@ -56,6 +57,34 @@ logger = logging.getLogger("obsigate.auth.router")
router = APIRouter(prefix="/api/auth", tags=["auth"])
def is_secure_cookies(request: Request | None = None) -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3/T8).
``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).
"""
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 ──────────────────────────────────────────
class LoginRequest(BaseModel):
@@ -218,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)
@@ -229,9 +259,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
access_token = create_access_token(user)
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
import os
max_age = 2592000 if remember_me else 604800 # 30d or 7d
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
secure = is_secure_cookies(request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
@@ -253,7 +282,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
)
return {
"access_token": access_token,
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
# OAuth2 token_type, pas un mot de passe (B105) :
"token_type": "bearer", # nosec B105
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
"user": {
"username": user["username"],
@@ -299,9 +329,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
if stale:
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
import os
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
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.
@@ -332,7 +360,8 @@ async def refresh_token_endpoint(request: Request, response: Response):
return {
"access_token": new_access_token,
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
# OAuth2 token_type, pas un mot de passe (B105) :
"token_type": "bearer", # nosec B105
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
}
@@ -426,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.
@@ -441,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
@@ -804,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")
@@ -812,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"),
@@ -847,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")
@@ -885,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 ───────────────────────────────────────────────────
+17 -12
View File
@@ -536,18 +536,6 @@ async def api_bookslm_agent(
# run no longer pauses on every subsequent mutating call.
ctx.confirmed = True
async def _llm(msgs, tool_schemas):
return await chat_completion(
msgs,
tools=tool_schemas,
provider=req.provider,
model=req.model,
temperature=0.3,
# Tool-call arguments can carry a whole file body (e.g. a generated
# table): leave more room than the plain-chat default.
max_tokens=8192,
)
async def generate_sse():
import asyncio
@@ -561,6 +549,23 @@ async def api_bookslm_agent(
yield f"event: error\ndata: {error_data}\n\n"
return
# #187: resolve the provider like /chat does — the agent must use
# the same engine the SSE "provider" tag reports (the raw
# req.provider could name an unavailable provider and silently
# fall back to another one via _get_provider_config).
async def _llm(msgs, tool_schemas):
return await chat_completion(
msgs,
tools=tool_schemas,
provider=cfg_name,
model=req.model,
temperature=0.3,
# Tool-call arguments can carry a whole file body (e.g. a
# generated table): leave more room than the plain-chat
# default.
max_tokens=8192,
)
# Stream tool events live: each executed step is pushed on the
# queue by the loop callback and emitted as soon as it happens,
# so the UI can grow its « N steps » block while thinking.
+35
View File
@@ -0,0 +1,35 @@
"""Content-Security-Policy nonces (ROADMAP #87, tranche 5b).
Chaque réponse HTTP reçoit un nonce frais (``request.state.csp_nonce``)
injecté dans ``script-src``. Les routes servant du HTML avec des scripts
inline (index, popout, admin, editor-poc, excalidraw, page de partage)
l'injectent dans le balisage via :func:`inject_csp_nonce` — mêmes
emplacements, aucun script déplacé.
Tant que ``'unsafe-inline'`` reste dans la politique (retrait en T5c),
l'injection est inerte : elle prépare la bascule sans changer le
comportement.
"""
from __future__ import annotations
import re
import secrets
# Balises <script> exécutables sans `src` et sans nonce existant :
# `<script>`, `<script type="module">`, `<script type="importmap">`.
# Les blocs non-JS (ex. `type="text/plain"`) et les scripts externes
# (`src=…`, couverts par 'self'/hôtes CDN) sont laissés intacts.
_SCRIPT_TAG_RE = re.compile(
r"<script(?=>|\s+type=\"(?:module|importmap)\"\s*>)",
)
def new_nonce() -> str:
"""Generate a fresh per-response CSP nonce."""
return secrets.token_urlsafe(16)
def inject_csp_nonce(html: str, nonce: str) -> str:
"""Add ``nonce="…"`` to bare executable inline ``<script>`` tags."""
return _SCRIPT_TAG_RE.sub(f'<script nonce="{nonce}"', html)
+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:
+2 -1
View File
@@ -35,7 +35,8 @@ def diagram_png_for(code: str) -> Path | None:
Mermaid, ou None. Le hash doit rester synchrone avec le script de build :
sha1(unescape(code).strip())[:16]."""
normalized = html.unescape(code).strip()
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16]
# Identifiant de cache déterministe (pas un usage sécurité).
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16] # nosec B324
png = DIAGRAMS_DIR / (sha + ".png")
return png if png.exists() else None
+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)
+190 -1118
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -32,7 +32,8 @@ def thumb_cache_path(file_path: Path, size: int) -> Path:
stamp = f"{st.st_mtime_ns}:{st.st_size}"
except OSError:
stamp = "0:0"
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest()
# Clé de cache miniature (pas un usage sécurité).
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest() # nosec B324
return thumbs_cache_dir() / f"{key}.webp"
+350
View File
@@ -0,0 +1,350 @@
"""External notifications — Discord, Telegram, SMTP, generic webhook (#168).
Configuration is persisted in ``data/notify_channels.json``; secrets live in
``data/notify_secrets.json`` (0600) or in ``OBSIGATE_NOTIFY_SECRET_<ID>``
environment variables — never in the public config file (same pattern as
``backend/webhooks.py``, BUG-026).
Supported channel types:
* ``discord`` — Discord webhook URL (``POST {"content": ...}``).
* ``telegram`` — Bot API (``POST https://api.telegram.org/bot<token>/sendMessage``).
* ``smtp`` — Email via stdlib ``smtplib`` (STARTTLS, auth login).
* ``webhook`` — generic JSON ``POST`` (SSRF-safe, same policy as #9).
Each channel declares ``triggers`` chosen among :data:`VALID_TRIGGERS`.
The scheduler (#170) broadcasts on ``schedule_failure``; file-event fan-out
stays on the historical ``backend/webhooks.py`` path.
"""
from __future__ import annotations
import json
import logging
import os
import smtplib
import threading
import uuid
from datetime import datetime, timezone
from email.message import EmailMessage
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.notify")
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
CHANNELS_FILE = DATA_DIR / "notify_channels.json"
SECRETS_FILE = DATA_DIR / "notify_secrets.json"
CHANNEL_TYPES = ("discord", "telegram", "smtp", "webhook")
VALID_TRIGGERS = ("manual", "schedule_failure", "schedule_success", "duplicate_found")
_lock = threading.RLock()
# ── Store helpers ──────────────────────────────────────────────────────────
def _read_channels() -> list[dict[str, Any]]:
if not CHANNELS_FILE.exists():
return []
try:
data = json.loads(CHANNELS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except (json.JSONDecodeError, OSError):
return []
def _write_channels(channels: list[dict[str, Any]]) -> None:
CHANNELS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = CHANNELS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(channels, indent=2, default=str), encoding="utf-8")
tmp.replace(CHANNELS_FILE)
def _read_secrets() -> dict[str, str]:
if not SECRETS_FILE.exists():
return {}
try:
data = json.loads(SECRETS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
def _write_secrets(secrets: dict[str, str]) -> None:
SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = SECRETS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(secrets, indent=2), encoding="utf-8")
tmp.replace(SECRETS_FILE)
try:
SECRETS_FILE.chmod(0o600)
except OSError:
pass # Windows: pas de permissions Unix
def _secret_key(channel_id: str) -> str:
return "OBSIGATE_NOTIFY_SECRET_" + channel_id.replace("-", "_").upper()
def _get_secret(channel_id: str) -> str | None:
"""Resolve a channel secret: env > dedicated store > legacy inline config."""
env_val = os.environ.get(_secret_key(channel_id))
if env_val:
return env_val
stored = _read_secrets().get(channel_id)
if stored:
return stored
for ch in _read_channels():
if ch.get("id") == channel_id:
cfg = ch.get("config", {})
for key in ("webhook_url", "bot_token", "password"):
if cfg.get(key):
return str(cfg[key])
return None
def _public_view(channel: dict[str, Any]) -> dict[str, Any]:
clean = {k: v for k, v in channel.items() if k != "config"}
cfg = dict(channel.get("config", {}))
for secret_field in ("webhook_url", "bot_token", "password"):
if cfg.get(secret_field):
cfg[secret_field] = "***"
clean["config"] = cfg
clean["has_secret"] = bool(_get_secret(channel["id"]))
return clean
# ── CRUD ───────────────────────────────────────────────────────────────────
def _validate_config(channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
"""Validate (sans secret) and normalize a channel config. Raises ValueError."""
config = dict(config or {})
if channel_type == "discord":
url = str(config.get("webhook_url") or config.get("url") or "").strip()
if not url.startswith(("https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/")):
# Laisse passer les URLs de test locales quand le mode privé est ouvert.
from backend.webhooks import validate_webhook_url
validate_webhook_url(url)
if "discord" not in url and not os.environ.get("OBSIGATE_WEBHOOK_ALLOW_PRIVATE"):
raise ValueError("URL Discord invalide (webhook discord.com attendu)")
config["webhook_url"] = url
elif channel_type == "telegram":
if not str(config.get("chat_id") or "").strip():
raise ValueError("chat_id Telegram requis")
config["chat_id"] = str(config["chat_id"]).strip()
if config.get("bot_token"):
config["bot_token"] = str(config["bot_token"]).strip()
elif channel_type == "smtp":
for field in ("host", "from_addr", "to_addr"):
if not str(config.get(field) or "").strip():
raise ValueError(f"Champ SMTP requis : {field}")
config["port"] = int(config.get("port") or 587)
config["use_tls"] = bool(config.get("use_tls", True))
config["username"] = str(config.get("username") or "").strip()
elif channel_type == "webhook":
from backend.webhooks import validate_webhook_url
url = str(config.get("url") or "").strip()
validate_webhook_url(url)
config["url"] = url
else:
raise ValueError(f"Type de canal inconnu : {channel_type}")
triggers = [t for t in (config.get("triggers") or ["manual"]) if t in VALID_TRIGGERS]
config["triggers"] = triggers or ["manual"]
return config
def list_channels() -> list[dict[str, Any]]:
"""Return public views of all notification channels."""
return [_public_view(ch) for ch in _read_channels()]
def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
"""Create a notification channel. Secrets are split into the secret store."""
if channel_type not in CHANNEL_TYPES:
raise ValueError(f"Type de canal inconnu : {channel_type}")
with _lock:
channels = _read_channels()
channel_id = str(uuid.uuid4())
normalized = _validate_config(channel_type, config)
secrets = _read_secrets()
for field in ("webhook_url", "bot_token", "password"):
if normalized.get(field) and len(str(normalized[field])) > 8:
secrets[channel_id] = str(normalized[field])
normalized[field] = "***" # placeholder : le secret vit dans le store dédié
_write_secrets(secrets)
channel = {
"id": channel_id,
"name": (name or channel_type).strip() or channel_type,
"type": channel_type,
"enabled": True,
"config": normalized,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_sent_at": None,
"last_error": None,
}
channels.append(channel)
_write_channels(channels)
logger.info(f"Created notify channel '{name}' ({channel_type})")
return _public_view(channel)
def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
"""Update a channel (name/enabled/config). Returns None when unknown."""
with _lock:
channels = _read_channels()
for channel in channels:
if channel.get("id") != channel_id:
continue
if updates.get("name"):
channel["name"] = str(updates["name"])
if "enabled" in updates:
channel["enabled"] = bool(updates["enabled"])
if "config" in updates and isinstance(updates["config"], dict):
merged = {**channel.get("config", {}), **updates["config"]}
normalized = _validate_config(channel["type"], merged)
secrets = _read_secrets()
for field in ("webhook_url", "bot_token", "password"):
if updates["config"].get(field):
secrets[channel_id] = str(updates["config"][field])
normalized[field] = "***"
_write_secrets(secrets)
channel["config"] = normalized
_write_channels(channels)
return _public_view(channel)
return None
def delete_channel(channel_id: str) -> bool:
"""Delete a channel and its secret. Returns False when unknown."""
with _lock:
channels = _read_channels()
remaining = [c for c in channels if c.get("id") != channel_id]
if len(remaining) == len(channels):
return False
_write_channels(remaining)
secrets = _read_secrets()
if secrets.pop(channel_id, None) is not None:
_write_secrets(secrets)
return True
# ── Dispatch ───────────────────────────────────────────────────────────────
def _send_discord(webhook_url: str, title: str, message: str) -> None:
import httpx
content = f"**{title}**\n{message}"[:2000]
resp = httpx.post(webhook_url, json={"content": content}, timeout=10.0)
resp.raise_for_status()
def _send_telegram(bot_token: str, chat_id: str, title: str, message: str) -> None:
import httpx
from backend.webhooks import is_safe_target
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
if not is_safe_target(url):
raise RuntimeError("Cible Telegram bloquée par la politique SSRF")
text = f"*{title}*\n{message}"[:4000]
resp = httpx.post(
url,
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
timeout=10.0,
)
resp.raise_for_status()
def _send_smtp(config: dict[str, Any], password: str | None, title: str, message: str) -> None:
msg = EmailMessage()
msg["Subject"] = f"[ObsiGate] {title}"
msg["From"] = config["from_addr"]
msg["To"] = config["to_addr"]
msg.set_content(message)
with smtplib.SMTP(str(config["host"]), int(config.get("port", 587)), timeout=10) as client:
if config.get("use_tls", True):
client.starttls()
if config.get("username") and password:
client.login(str(config["username"]), password)
client.send_message(msg)
def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
import httpx
from backend.webhooks import is_safe_target
if not is_safe_target(url):
raise RuntimeError("Cible webhook bloquée par la politique SSRF")
resp = httpx.post(
url,
json={
"event": trigger,
"title": title,
"message": message,
"timestamp": datetime.now(timezone.utc).isoformat(),
"source": "obsigate-notify",
},
timeout=10.0,
)
resp.raise_for_status()
def send_via_channel(channel: dict[str, Any], title: str, message: str, trigger: str = "manual") -> None:
"""Send a notification through one raw channel record. Raises on failure."""
channel_type = channel.get("type")
cfg = dict(channel.get("config", {}))
secret = _get_secret(channel["id"])
if channel_type == "discord":
url = secret or cfg.get("webhook_url") or ""
if not url or url == "***":
raise RuntimeError("URL webhook Discord manquante")
_send_discord(url, title, message)
elif channel_type == "telegram":
token = secret or cfg.get("bot_token") or os.environ.get("OBSIGATE_TELEGRAM_BOT_TOKEN") or ""
if not token or token == "***":
raise RuntimeError("Token bot Telegram manquant")
_send_telegram(token, str(cfg.get("chat_id", "")), title, message)
elif channel_type == "smtp":
_send_smtp(cfg, secret, title, message)
elif channel_type == "webhook":
url = str(cfg.get("url") or "").strip()
if not url:
raise RuntimeError("URL webhook manquante")
_send_webhook(url, title, message, trigger)
else:
raise RuntimeError(f"Type de canal inconnu : {channel_type}")
def broadcast(trigger: str, title: str, message: str) -> list[dict[str, Any]]:
"""Send to every enabled channel subscribed to *trigger*. Never raises."""
results: list[dict[str, Any]] = []
for channel in _read_channels():
if not channel.get("enabled", True):
continue
if trigger not in channel.get("config", {}).get("triggers", ["manual"]):
continue
try:
send_via_channel(channel, title, message, trigger)
results.append({"channel_id": channel["id"], "ok": True})
_mark_sent(channel["id"], None)
except Exception as e:
logger.warning(f"Notify channel '{channel.get('name')}' failed: {e}")
results.append({"channel_id": channel["id"], "ok": False, "error": str(e)})
_mark_sent(channel["id"], str(e))
return results
def _mark_sent(channel_id: str, error: str | None) -> None:
with _lock:
channels = _read_channels()
for channel in channels:
if channel.get("id") == channel_id:
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
channel["last_error"] = error
_write_channels(channels)
+41 -1
View File
@@ -41,6 +41,9 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
{"name": "Notify", "description": "External notifications: Discord, Telegram, SMTP and generic webhooks (#168)."},
{"name": "Scheduler", "description": "Scheduled automatic tasks reusing the vault mutation services (#170)."},
{"name": "Frontend", "description": "Static assets and SPA fallback routes."},
]
@@ -95,6 +98,9 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^/api/shares"), "Sharing"),
(re.compile(r"^/s/"), "Sharing"),
(re.compile(r"^/api/webhooks"), "Webhooks"),
(re.compile(r"^/api/duplicates"), "Duplicates"),
(re.compile(r"^/api/notify"), "Notify"),
(re.compile(r"^/api/scheduler"), "Scheduler"),
(re.compile(r"^/api/conflicts"), "Conflicts"),
(re.compile(r"^/api/backups"), "Backups"),
(re.compile(r"^/api/file/[^/]+/(backups|diff|restore)"), "Backups"),
@@ -148,6 +154,9 @@ _TAG_ALIASES: dict[str, str] = {
"export": "Export",
"sharing": "Sharing",
"webhooks": "Webhooks",
"duplicates": "Duplicates",
"notify": "Notify",
"scheduler": "Scheduler",
"conflicts": "Conflicts",
"system": "System",
"frontend": "Frontend",
@@ -182,9 +191,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},
+172 -1
View File
@@ -12,14 +12,24 @@ the per-account lockout in ``user_store.py``.
deployment, front this service with a shared store (Redis) or a single
worker. This limitation is intentional and documented (BUG-031).
Opt-in persistence (ROADMAP #85 T10b) : if ``OBSIGATE_RATELIMIT_DB`` points
to a SQLite file, counters are stored there instead (WAL mode, one short
connection per call — safe across threads, processes and restarts sharing
the same file). Semantics (windows, budgets, success reset) are identical
to the in-memory store, which remains the default when the variable is
unset.
Configuration via environment variables:
OBSIGATE_LOGIN_MAX_ATTEMPTS Max failures per IP (default: 10)
OBSIGATE_ACCOUNT_MAX_ATTEMPTS Max failures per account (default: 10)
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900)
OBSIGATE_RATELIMIT_DB SQLite file for shared/persistent counters (default: unset = memory)
"""
import logging
import os
import sqlite3
import threading
import time
from collections import defaultdict
@@ -37,6 +47,127 @@ _last_cleanup = time.time()
CLEANUP_INTERVAL = 60 # seconds
def _db_path() -> str | None:
"""SQLite file for shared counters, or ``None`` for the in-memory store."""
path = os.environ.get("OBSIGATE_RATELIMIT_DB", "").strip()
return path or None
def _db_connect(path: str) -> sqlite3.Connection:
"""Open a short-lived connection (WAL + busy timeout for concurrent workers)."""
_db_ensure_schema(path)
conn = sqlite3.connect(path, timeout=10.0)
conn.execute("PRAGMA busy_timeout=10000")
return conn
_schema_ready: set[str] = set()
_schema_lock = threading.Lock()
def _db_ensure_schema(path: str) -> None:
"""Create the store schema once per file (DDL under a process-wide lock)."""
with _schema_lock:
if path in _schema_ready:
return
conn = sqlite3.connect(path, timeout=10.0)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"CREATE TABLE IF NOT EXISTS attempts"
" (kind TEXT NOT NULL, key TEXT NOT NULL, ts REAL NOT NULL, success INTEGER NOT NULL)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_attempts_kind_key_ts"
" ON attempts (kind, key, ts)"
)
conn.commit()
finally:
conn.close()
_schema_ready.add(path)
def _db_write(fn, *args):
"""Run a write op, retrying once on lock contention (concurrent workers)."""
try:
return fn(*args)
except sqlite3.OperationalError as e:
if "locked" not in str(e).lower():
raise
time.sleep(0.05)
return fn(*args)
def _db_prune(conn: sqlite3.Connection, cutoff: float) -> None:
"""Drop expired entries (best-effort cap on disk growth)."""
conn.execute("DELETE FROM attempts WHERE ts <= ?", (cutoff,))
def _db_record(kind: str, key: str, success: bool) -> int:
"""Record one attempt in SQLite; return the live failure count."""
path = _db_path()
assert path is not None
now = time.time()
cutoff = now - WINDOW_SECONDS
def _write() -> int:
with _db_connect(path) as conn:
_db_prune(conn, cutoff)
if success:
# Mirror the in-memory reset: replace history with one success.
conn.execute("DELETE FROM attempts WHERE kind = ? AND key = ?", (kind, key))
conn.execute(
"INSERT INTO attempts (kind, key, ts, success) VALUES (?, ?, ?, ?)",
(kind, key, now, int(success)),
)
conn.commit()
(failures,) = conn.execute(
"SELECT COUNT(*) FROM attempts WHERE kind = ? AND key = ? AND ts > ? AND success = 0",
(kind, key, cutoff),
).fetchone()
return failures
return _db_write(_write)
def _db_failures(kind: str, key: str) -> int:
"""Live failure count in SQLite (expired entries never count)."""
path = _db_path()
assert path is not None
cutoff = time.time() - WINDOW_SECONDS
with _db_connect(path) as conn:
(failures,) = conn.execute(
"SELECT COUNT(*) FROM attempts WHERE kind = ? AND key = ? AND ts > ? AND success = 0",
(kind, key, cutoff),
).fetchone()
return failures
def _db_tracked(kind: str) -> int:
"""Number of distinct keys ever seen for one budget (SQLite)."""
path = _db_path()
assert path is not None
with _db_connect(path) as conn:
(n,) = conn.execute(
"SELECT COUNT(DISTINCT key) FROM attempts WHERE kind = ?", (kind,)
).fetchone()
return n
def _db_limited_count(kind: str, max_attempts: int) -> int:
"""Number of keys currently over budget (SQLite)."""
path = _db_path()
assert path is not None
cutoff = time.time() - WINDOW_SECONDS
with _db_connect(path) as conn:
rows = conn.execute(
"SELECT key, COUNT(*) FROM attempts"
" WHERE kind = ? AND ts > ? AND success = 0 GROUP BY key",
(kind, cutoff),
).fetchall()
return sum(1 for _, n in rows if n >= max_attempts)
def _prune(store: dict[str, list], cutoff: float) -> None:
"""Drop expired entries from one store in place."""
expired = []
@@ -66,6 +197,12 @@ def record_failure(ip: str) -> tuple[int, int]:
Returns:
(current_failure_count, remaining_attempts)
"""
if _db_path() is not None:
failures = _db_record("ip", ip, False)
remaining = max(0, MAX_ATTEMPTS - failures)
if failures >= MAX_ATTEMPTS:
logger.warning(f"IP {ip} rate-limited after {failures} failed logins")
return failures, remaining
_cleanup_expired()
_ip_attempts[ip].append((time.time(), False))
failures = sum(1 for _, success in _ip_attempts[ip] if not success)
@@ -77,12 +214,17 @@ def record_failure(ip: str) -> tuple[int, int]:
def record_success(ip: str):
"""Clear rate limit state for an IP after successful login."""
if _db_path() is not None:
_db_record("ip", ip, True)
return
_cleanup_expired()
_ip_attempts[ip] = [(time.time(), True)]
def is_rate_limited(ip: str) -> bool:
"""Check if an IP has exceeded the rate limit."""
if _db_path() is not None:
return _db_failures("ip", ip) >= MAX_ATTEMPTS
_cleanup_expired()
failures = sum(1 for _, success in _ip_attempts.get(ip, []) if not success)
return failures >= MAX_ATTEMPTS
@@ -94,8 +236,14 @@ def record_account_failure(account: str) -> tuple[int, int]:
Returns:
(current_failure_count, remaining_attempts)
"""
_cleanup_expired()
key = account.lower()
if _db_path() is not None:
failures = _db_record("account", key, False)
remaining = max(0, ACCOUNT_MAX_ATTEMPTS - failures)
if failures >= ACCOUNT_MAX_ATTEMPTS:
logger.warning(f"Account {account} rate-limited after {failures} failed attempts")
return failures, remaining
_cleanup_expired()
_account_attempts[key].append((time.time(), False))
failures = sum(1 for _, success in _account_attempts[key] if not success)
remaining = max(0, ACCOUNT_MAX_ATTEMPTS - failures)
@@ -106,12 +254,17 @@ def record_account_failure(account: str) -> tuple[int, int]:
def record_account_success(account: str):
"""Clear the per-account rate limit state after a successful login."""
if _db_path() is not None:
_db_record("account", account.lower(), True)
return
_cleanup_expired()
_account_attempts[account.lower()] = [(time.time(), True)]
def is_account_rate_limited(account: str) -> bool:
"""Check if an account has exceeded the per-account rate limit."""
if _db_path() is not None:
return _db_failures("account", account.lower()) >= ACCOUNT_MAX_ATTEMPTS
_cleanup_expired()
failures = sum(
1 for _, success in _account_attempts.get(account.lower(), []) if not success
@@ -121,6 +274,24 @@ def is_account_rate_limited(account: str) -> bool:
def get_status(ip: str | None = None) -> dict:
"""Get rate limit status for an IP (for diagnostics)."""
if _db_path() is not None:
if ip:
failures = _db_failures("ip", ip)
return {
"ip": ip,
"failures": failures,
"max": MAX_ATTEMPTS,
"limited": failures >= MAX_ATTEMPTS,
"window_seconds": WINDOW_SECONDS,
}
return {
"tracked_ips": _db_tracked("ip"),
"tracked_accounts": _db_tracked("account"),
"max_attempts": MAX_ATTEMPTS,
"account_max_attempts": ACCOUNT_MAX_ATTEMPTS,
"window_seconds": WINDOW_SECONDS,
"limited_ips": _db_limited_count("ip", MAX_ATTEMPTS),
}
_cleanup_expired()
if ip:
attempts = _ip_attempts.get(ip, [])
+210
View File
@@ -0,0 +1,210 @@
"""Markdown rendering pipeline (ROADMAP #85, tranche 9).
Helpers extraits de :mod:`backend.main` sans changement de comportement :
slugification des headings, IDs d'ancrage, rendu mistune singleton,
wikilinks, normalisation des sauts de ligne et pipeline complet
:func:`_render_markdown` (rendu + sanitizer XSS BUG-021).
Les noms gardent leur préfixe ``_`` d'origine pour un déplacement
strictement verbatim (tests et routers pointent ici désormais).
"""
from __future__ import annotations
import html as html_mod
import re
import unicodedata
from pathlib import Path
from typing import cast
import mistune
from backend.image_processor import preprocess_images
from backend.indexer import find_file_in_index, get_vault_data
from backend.secret_redactor import redact_file_content
from backend.services.sanitizer import sanitize_html
def _heading_slugify(text: str) -> str:
"""Generate a URL-safe slug from heading text.
Matches the JavaScript slugify algorithm exactly using
Unicode-aware character classification:
1. Strip HTML tags (e.g. wikilink spans rendered inside headings)
2. Decode HTML entities (e.g. ``&amp;`` → ``&``)
3. Lowercase
4. NFD normalize + strip combining marks
5. Keep only Unicode letters, numbers, spaces, hyphens
6. Replace spaces with hyphens, collapse multiple hyphens
Args:
text: The heading text content (may contain inline HTML).
Returns:
A URL-safe slug string.
"""
# Strip any inline HTML so it does not pollute the slug
text = re.sub(r"<[^>]+>", "", text)
# Decode HTML entities so &amp; becomes & before slugification
text = html_mod.unescape(text)
text = text.lower()
text = unicodedata.normalize("NFD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
# Unicode-aware: keep letters (L*), numbers (N*), spaces, and hyphens
cleaned = []
for ch in text:
cat = unicodedata.category(ch)
if cat.startswith('L') or cat.startswith('N') or ch in (' ', '-'):
cleaned.append(ch)
text = "".join(cleaned)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
result = text.strip("-")
return result if result else "heading"
def _add_heading_ids(html: str) -> str:
"""Post-process rendered HTML to add IDs to heading tags.
Adds an ``id`` attribute to every ``<h1>`` through ``<h6>`` tag
using a slug generated from the heading's text content.
Duplicate slugs get a ``-2``, ``-3``, etc. suffix.
Args:
html: Rendered HTML string.
Returns:
HTML with heading IDs injected.
"""
used_ids: dict[str, int] = {}
def _replace_heading(match):
tag = match.group(1)
content = match.group(2)
slug = _heading_slugify(content)
count = used_ids.get(slug, 0)
used_ids[slug] = count + 1
if count > 0:
slug = f"{slug}-{count + 1}"
return f'<{tag} id="{slug}">{content}</{tag}>'
# Match h1-h6 tags with text content (no existing id attribute)
return re.sub(
r'<(h[1-6])>([^<]*(?:<(?!/?h[1-6])[^<]*)*)</h[1-6]>',
_replace_heading,
html,
)
# Cached mistune renderer — avoids re-creating on every request
_markdown_renderer = mistune.create_markdown(
escape=False,
plugins=["table", "strikethrough", "footnotes", "task_lists"],
)
def _convert_wikilinks(content: str, current_vault: str) -> str:
"""Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML.
Supports:
- Internal file links: ``[[My Note]]`` / ``[[My Note|display]]``
- Same-document anchors: ``[[#Heading]]`` / ``[[#Heading|display]]``
Resolved file links get a ``data-vault`` / ``data-path`` attribute pair.
Anchor links target the slugified heading ID in the current document.
Unresolved links are rendered as ``<span class="wikilink-missing">``.
Args:
content: Markdown string potentially containing wikilinks.
current_vault: Active vault name for resolution priority.
Returns:
Markdown string with wikilinks replaced by HTML anchors.
"""
def _replace(match):
target = match.group(1).strip()
display = match.group(2).strip() if match.group(2) else target
# Same-document anchor link: [[#Heading|display]]
if target.startswith("#"):
anchor_text = target[1:].strip()
anchor_slug = _heading_slugify(anchor_text)
link_display = display if display != target else anchor_text
return f'<a class="wikilink-anchor" href="#{anchor_slug}">{link_display}</a>'
found = find_file_in_index(target, current_vault)
if found:
return (
f'<a class="wikilink" href="#" '
f'data-vault="{found["vault"]}" '
f'data-path="{found["path"]}">{display}</a>'
)
return f'<span class="wikilink-missing">{display}</span>'
pattern = r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
return re.sub(pattern, _replace, content)
def _normalize_line_breaks(text: str) -> str:
"""Convert single newlines to hard breaks (matching Obsidian default behavior).
In standard Markdown, a single ``\\n`` is a "soft break" — it renders as a space,
not a visible line break. Obsidian defaults to treating single newlines as hard
breaks (equivalent to ``<br>``). This function pre-processes the Markdown source
so that mistune renders standalone lines on separate rows, while still honouring
blank lines as paragraph separators.
Fenced code blocks (`` ``` ``) are left untouched so their internal newlines are
preserved verbatim.
"""
parts = re.split(r"(```[\s\S]*?```)", text)
for i, part in enumerate(parts):
if part.startswith("```"):
continue # Protect fenced code blocks
# Single \n (not preceded or followed by another \n) → two spaces + \n
parts[i] = re.sub(r"(?<!\n)\n(?!\n)", " \n", part)
return "".join(parts)
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
"""Render a markdown string to HTML with wikilink and image support.
Uses the cached singleton mistune renderer for performance.
Args:
raw_md: Raw markdown text (frontmatter already stripped).
vault_name: Current vault for wikilink resolution context.
current_file_path: Absolute path to the current markdown file.
Returns:
HTML string.
"""
# Get vault data for image resolution
vault_data = get_vault_data(vault_name)
vault_root = Path(vault_data["path"]) if vault_data else None
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
# Redact secrets before rendering (P0 security)
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
# Preprocess images first
if vault_root:
raw_md = preprocess_images(raw_md, vault_name, vault_root, current_file_path, attachments_path)
# Convert wikilinks
converted = _convert_wikilinks(raw_md, vault_name)
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
converted = _normalize_line_breaks(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)
# Sanitize: raw HTML in vault content must never reach the DOM (BUG-021).
rendered = sanitize_html(rendered)
return 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
+531
View File
@@ -0,0 +1,531 @@
"""Configuration, AI keys, diagnostics & dashboard endpoints (ROADMAP #85, tranche 7).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/config*``, ``/api/diagnostics``,
``/api/dashboard``), mêmes modèles de réponse, mêmes dépendances
d'authentification.
Adaptations strictement équivalentes :
- ``_load_config`` / ``_save_config`` / ``_DEFAULT_CONFIG`` /
``_CONFIG_PATH`` / ``_BASE_DIR`` ont déménagé ici : ``main`` les
réimporte pour son lifespan (pas de cycle : ce module ne dépend pas de
``main``).
- ``AI_KEYS_FILE`` / ``_write_ai_keys`` / ``_FALLBACK_MODELS`` ont déménagé
ici (``AI_KEYS_FILE`` garde son chemin relatif ``data/api_keys.json``,
résolu depuis le même CWD au runtime).
"""
import json as _json
import logging
import os
import urllib.request
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
from backend.auth.middleware import require_admin, require_auth
from backend.indexer import index
from backend.media_types import IMAGE_EXTENSIONS
from backend.schemas import (
AIKeyDeleteResponse,
AIKeysResponse,
AIModelsResponse,
AITestResponse,
AppConfigResponse,
DashboardResponse,
DiagnosticsResponse,
StatusResponse,
)
from backend.search_executor import get_search_executor
from backend.tools.secrets import (
TOOL_KEY_NAMES as _TOOL_KEY_NAMES,
)
from backend.tools.secrets import (
delete_tool_key as _delete_tool_key,
)
from backend.tools.secrets import (
get_tool_key as _get_tool_key,
)
from backend.tools.secrets import (
mask_value as _mask_tool_value,
)
from backend.tools.secrets import (
set_tool_key as _set_tool_key,
)
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["System"])
_BASE_DIR = Path(__file__).resolve().parent.parent.parent
_CONFIG_PATH = _BASE_DIR / "data" / "config.json"
_DEFAULT_CONFIG = {
"search_workers": 2,
"debounce_ms": 300,
"results_per_page": 50,
"min_query_length": 2,
"search_timeout_ms": 30000,
"max_content_size": 100000,
"snippet_context_chars": 120,
"max_snippet_highlights": 5,
"title_boost": 3.0,
"path_boost": 1.5,
"watcher_enabled": True,
"watcher_use_polling": False,
"watcher_polling_interval": 5.0,
"watcher_debounce": 2.0,
"tag_boost": 2.0,
"prefix_max_expansions": 50,
"recent_files_limit": 20,
"max_backups_per_file": 10,
"ai_default_provider": "deepseek",
"ai_default_models": {},
}
def _load_config() -> dict:
"""Load config from disk, merging with defaults."""
config = dict(_DEFAULT_CONFIG)
if _CONFIG_PATH.exists():
try:
stored = _json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
config.update(stored)
except Exception as e:
logger.warning(f"Failed to read config.json: {e}")
return config
def _save_config(config: dict) -> None:
"""Persist config to disk."""
try:
_CONFIG_PATH.write_text(
_json.dumps(config, indent=2, ensure_ascii=False),
encoding="utf-8",
)
except Exception as e:
logger.error(f"Failed to write config.json: {e}")
raise HTTPException(status_code=500, detail=f"Failed to save config: {e}")
AI_KEYS_FILE = Path("data/api_keys.json")
def _write_ai_keys(data: dict):
AI_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = AI_KEYS_FILE.with_suffix(".tmp")
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(AI_KEYS_FILE)
@router.get("/api/config", response_model=AppConfigResponse)
async def api_get_config(current_user=Depends(require_auth)):
"""Return current configuration with defaults for missing keys."""
return _load_config()
@router.post("/api/config", response_model=AppConfigResponse)
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
"""Update configuration. Only known keys are accepted.
Keys matching ``_DEFAULT_CONFIG`` are validated and persisted.
Unknown keys are silently ignored.
Returns the full merged config after update.
"""
current = _load_config()
updated_keys = []
for key, value in body.items():
if key in _DEFAULT_CONFIG:
expected_type = type(_DEFAULT_CONFIG[key])
if isinstance(value, expected_type) or (expected_type is float and isinstance(value, (int, float))):
current[key] = value
updated_keys.append(key)
else:
raise HTTPException(
status_code=400,
detail=f"Invalid type for '{key}': expected {expected_type.__name__}, got {type(value).__name__}",
)
_save_config(current)
if any(k.startswith("ai_") for k in updated_keys):
try:
from backend.ai import reload_ai_config
reload_ai_config()
except Exception as e:
logger.warning(f"Failed to reload AI config: {e}")
logger.info(f"Config updated: {updated_keys}")
return current
@router.get("/api/config/ai-keys", response_model=AIKeysResponse)
async def api_get_ai_keys(current_user=Depends(require_admin)):
"""Return stored AI keys (values masked)."""
keys = _read_ai_keys()
masked = {}
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
val = keys.get(k, "") or os.environ.get(k, "")
if val:
masked[k] = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
else:
masked[k] = ""
return masked
@router.post("/api/config/ai-keys", response_model=StatusResponse)
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
keys = _read_ai_keys()
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
if body.get(k):
keys[k] = body[k]
_write_ai_keys(keys)
logger.info("AI keys updated")
return {"status": "ok"}
@router.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse)
async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)):
"""Delete a specific AI provider key from storage."""
allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY",
"NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"}
key_name = provider_env.upper()
if key_name not in allowed:
raise HTTPException(status_code=400, detail=f"Clé inconnue: {provider_env}")
keys = _read_ai_keys()
if key_name in keys:
del keys[key_name]
_write_ai_keys(keys)
# Also clear from env at runtime so get_ai_key() no longer finds it
os.environ.pop(key_name, None)
logger.info(f"AI key deleted: {key_name}")
return {"status": "deleted", "key": key_name}
@router.get("/api/config/tool-keys", response_model=AIKeysResponse)
async def api_get_tool_keys(current_user=Depends(require_admin)):
"""Return tool/connected-source configuration (tokens masked, URLs clear)."""
masked = {}
for name in _TOOL_KEY_NAMES:
masked[name] = _mask_tool_value(name, _get_tool_key(name))
return masked
@router.post("/api/config/tool-keys", response_model=StatusResponse)
async def api_set_tool_keys(body: dict = Body(...), current_user=Depends(require_admin)):
"""Save tool/connected-source keys.
Only whitelisted names (``backend.tools.secrets.TOOL_KEY_NAMES``) are
accepted: Tavily/Brave/SerpAPI/Exa API keys, Gitea URL + token, GitHub
token. Empty values delete the stored entry.
"""
updated = []
for name, value in body.items():
if name not in _TOOL_KEY_NAMES:
raise HTTPException(status_code=400, detail=f"Clé inconnue: {name}")
if value is not None and not isinstance(value, str):
raise HTTPException(status_code=400, detail=f"Type invalide pour {name}")
_set_tool_key(name, value or "")
updated.append(name)
logger.info(f"Tool keys updated: {updated}")
return {"status": "ok"}
@router.delete("/api/config/tool-keys/{name}", response_model=AIKeyDeleteResponse)
async def api_delete_tool_key(name: str, current_user=Depends(require_admin)):
"""Delete a stored tool key (the environment fallback still applies)."""
key_name = name.upper()
try:
existed = _delete_tool_key(key_name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
logger.info(f"Tool key deleted: {key_name} (existed={existed})")
return {"status": "deleted", "key": key_name}
@router.post("/api/config/ai-keys/test", response_model=AITestResponse)
async def api_test_ai_keys(current_user=Depends(require_admin)):
"""Test which AI providers are configured.
Each provider has a dedicated (URL, header-name) test pair.
- Most OpenAI-compatible APIs use `Authorization: Bearer KEY`
- Xiaomi MiMo uses `api-key: KEY`
- Gemini uses a query-string key
"""
results = {}
for key_name, label, test_url_tmpl, header_name in [
# OpenAI-compatible — Authorization: Bearer
("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"),
("OPENROUTER_API_KEY","openrouter", "https://openrouter.ai/api/v1/models", "Authorization"),
("NVIDIA_API_KEY", "nvidia", "https://integrate.api.nvidia.com/v1/models", "Authorization"),
("QWENCLOUD_API_KEY", "qwencloud", "https://dashscope.aliyuncs.com/compatible-mode/v1/models", "Authorization"),
("MISTRAL_API_KEY", "mistral", "https://api.mistral.ai/v1/models", "Authorization"),
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer)
("XIAOMI_API_KEY", "xiaomi", "https://api.xiaomimimo.com/v1/models", "api-key"),
# Gemini — key in query string
("GEMINI_API_KEY", "gemini", "https://generativelanguage.googleapis.com/v1beta/models?key={key}", None),
]:
key = get_ai_key(key_name)
if not key:
results[label] = "non configuré"
continue
try:
url = test_url_tmpl.replace("{key}", key) if "{key}" in test_url_tmpl else test_url_tmpl
if header_name:
req = urllib.request.Request(url, headers={header_name: key})
else:
req = urllib.request.Request(url)
urllib.request.urlopen(req, timeout=5)
results[label] = "ok"
except Exception as e:
# Truncate the error to keep the response small.
results[label] = "erreur: " + str(e)[:80]
return results
@router.get("/api/config/ai-models", response_model=AIModelsResponse)
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
"""List available models for a given AI provider.
Strategy:
1. Try the provider's public models endpoint (OpenAI-compatible /v1/models or Gemini).
2. If the network call fails (timeout, 4xx, 5xx, DNS, etc.), fall back to a
curated static list of known-good models for that provider.
3. Always return a non-empty list when the provider is known, so the UI
dropdown is never empty.
"""
provider = provider.lower()
from backend.model_capabilities import get_capabilities_for_models
from backend.provider_capabilities import remember_declared_capabilities
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
if provider not in all_providers:
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
key_name = f"{provider.upper()}_API_KEY"
key = get_ai_key(key_name)
if not key:
# No key configured — return curated fallback list so the UI can
# still show what WOULD be available once a key is set.
fallback = _FALLBACK_MODELS.get(provider, [])
return {"models": fallback, "source": "fallback",
"capabilities": get_capabilities_for_models(provider, fallback),
"note": "API key not configured — showing default model list"}
# Build URL
if provider == "gemini":
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
elif provider == "deepseek":
url = "https://api.deepseek.com/v1/models"
elif provider == "openrouter":
url = "https://openrouter.ai/api/v1/models"
elif provider == "nvidia":
url = "https://integrate.api.nvidia.com/v1/models"
elif provider == "qwencloud":
url = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
elif provider == "xiaomi":
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer).
# Endpoint: https://api.xiaomimimo.com/v1/models
url = "https://api.xiaomimimo.com/v1/models"
models = [] # parsed below with the custom header
elif provider == "mistral":
url = "https://api.mistral.ai/v1/models"
try:
if provider == "gemini":
req = urllib.request.Request(url)
elif provider == "xiaomi":
# Xiaomi MiMo uses a dedicated api-key header.
req = urllib.request.Request(url, headers={"api-key": key})
else:
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + key})
with urllib.request.urlopen(req, timeout=10) as resp:
data = _json.loads(resp.read().decode())
if provider == "gemini":
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
# Gemini returns names like "models/gemini-1.5-flash" — strip prefix
models = [m.replace("models/", "") for m in models]
else:
models = [m.get("id", "") for m in data.get("data", []) if m.get("id")]
# Cache the capabilities the provider declares for these models
# (BUG-044) — get_capabilities_for_models() below then returns the
# provider's own truth for the flags it declares, the curated table
# for the rest. Providers that declare nothing are left untouched.
remember_declared_capabilities(provider, data)
if models:
# Prepend the configured default if not already present
default = PROVIDERS.get(provider, {}).get("model")
if default and default not in models:
models = [default] + models
return {"models": models, "source": "live", "count": len(models),
"capabilities": get_capabilities_for_models(provider, models)}
# Empty list from API — fall through to fallback
raise ValueError("empty model list from provider API")
except Exception as e:
# Network error, auth error, parsing error — use curated fallback
fallback = _FALLBACK_MODELS.get(provider, [])
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
"capabilities": get_capabilities_for_models(provider, fallback),
"note": "Could not reach provider API — showing default model list"}
# ── Curated fallback model lists ──────────────────────────────────────────
# Used when the provider API is unreachable or returns empty.
# Keep these short and focused on models known to work with the
# OpenAI-compatible chat completions interface (or Gemini's generateContent).
_FALLBACK_MODELS: dict[str, list[str]] = {
"deepseek": [
"deepseek-chat",
"deepseek-reasoner",
],
"openrouter": [
"openai/gpt-4o-mini",
"openai/gpt-4o",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3-haiku",
"google/gemini-2.0-flash-exp:free",
"meta-llama/llama-3.1-70b-instruct",
"meta-llama/llama-3.1-8b-instruct:free",
"mistralai/mistral-large-latest",
],
"gemini": [
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-1.5-flash-8b",
],
"nvidia": [
"meta/llama-3.1-405b-instruct",
"meta/llama-3.1-70b-instruct",
"meta/llama-3.1-8b-instruct",
"mistralai/mistral-large",
"google/gemma-2-27b-it",
"nvidia/llama-3.1-nemotron-70b-instruct",
],
"qwencloud": [
"qwen-max",
"qwen-plus",
"qwen-turbo",
"qwen-long",
"qwen-vl-max",
"qwen-vl-plus",
],
"xiaomi": [
# Xiaomi MiMo models — the public /v1/models endpoint requires the
# `api-key` custom header (NOT Authorization: Bearer), so the live
# call often fails with 401 even with the right key. We ship a
# known-good list as fallback. See https://mimo.mi.com/docs/
"mimo-v2.5-pro",
"mimo-v2.5",
"mimo-v2.5-asr",
"mimo-v2.5-tts",
"mimo-v2.5-tts-voiceclone",
"mimo-v2.5-tts-voicedesign",
],
"mistral": [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"open-mistral-7b",
"open-mixtral-8x7b",
"codestral-latest",
],
}
@router.get("/api/diagnostics", response_model=DiagnosticsResponse)
async def api_diagnostics(current_user=Depends(require_admin)):
"""Return index statistics and system diagnostics.
Includes document counts, token counts, memory estimates,
and inverted index status.
"""
import sys
from backend.search import get_inverted_index
inv = get_inverted_index()
# Per-vault stats
vault_stats = {}
total_files = 0
total_tags = 0
# Snapshot both dicts first: the indexer mutates them from background
# threads, and iterating a live dict raises "dictionary changed size".
for vname, vdata in list(index.items()):
file_count = len(vdata.get("files", []))
tag_count = len(vdata.get("tags", {}))
vault_stats[vname] = {"file_count": file_count, "tag_count": tag_count}
total_files += file_count
total_tags += tag_count
# Memory estimate for inverted index
word_index = inv.word_index.copy()
word_index_entries = sum(len(docs) for docs in word_index.values())
mem_estimate_mb = round(
(sys.getsizeof(inv.word_index) + word_index_entries * 80
+ len(inv.doc_info) * 200
+ len(inv._sorted_tokens) * 60) / (1024 * 1024), 2
)
return {
"index": {
"total_files": total_files,
"total_tags": total_tags,
"vaults": vault_stats,
},
"inverted_index": {
"unique_tokens": len(word_index),
"total_postings": word_index_entries,
"documents": inv.doc_count,
"sorted_tokens": len(inv._sorted_tokens),
"is_ready": inv.is_ready(),
"memory_estimate_mb": mem_estimate_mb,
},
"config": _load_config(),
"search_executor": {
"active": get_search_executor() is not None,
"max_workers": get_search_executor()._max_workers if get_search_executor() else 0,
},
}
@router.get("/api/dashboard", response_model=DashboardResponse)
async def api_dashboard(current_user=Depends(require_auth)):
"""Aggregated dashboard statistics across all accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
vault_stats = []
total_files = 0
total_tags = set()
total_size = 0
total_images = 0
for vname, vdata in index.items():
if "*" not in user_vaults and vname not in user_vaults:
continue
files = vdata.get("files", [])
fc = len(files)
total_files += fc
vtags = set()
vsize = 0
vimages = 0
for f in files:
vtags.update(f.get("tags", []))
vsize += f.get("size", 0)
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
vimages += 1
total_tags.update(vtags)
total_size += vsize
total_images += vimages
vault_stats.append({
"name": vname, "file_count": fc, "tag_count": len(vtags),
"total_size_bytes": vsize, "image_count": vimages,
})
return {
"vaults": vault_stats,
"total_files": total_files,
"total_tags": len(total_tags),
"total_size_bytes": total_size,
"total_images": total_images,
}
+73
View File
@@ -0,0 +1,73 @@
"""Syncthing conflict endpoints (ROADMAP #85, tranche 8).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/conflicts*``), mêmes modèles de
réponse, mêmes dépendances d'authentification.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
et :mod:`backend.services.backups` (pass-through).
"""
import logging
import shutil
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException
from backend.audit import log_file_delete
from backend.auth.middleware import check_vault_access, require_auth
from backend.indexer import get_conflicts, get_vault_data, remove_single_file
from backend.schemas import ConflictResolveResponse, ConflictsResponse
from backend.services.backups import create_backup
from backend.services.paths import resolve_safe_path
from backend.sse import sse_manager
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["conflicts"])
@router.get("/api/conflicts", response_model=ConflictsResponse)
async def api_conflicts(current_user=Depends(require_auth)):
"""List sync-conflict files across accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
all_conflicts = get_conflicts()
if "*" not in user_vaults:
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
@router.post("/api/conflicts/resolve", response_model=ConflictResolveResponse)
async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)):
"""Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original)."""
vault_name = body.get("vault")
conflict_path = body.get("conflict_path")
original_path = body.get("original_path")
action = body.get("action") # "keep_local" or "keep_conflict"
# mypy: narrow down from dict values
assert isinstance(vault_name, str), "'vault' is required and must be a string"
assert isinstance(conflict_path, str), "'conflict_path' is required and must be a string"
assert isinstance(original_path, str), "'original_path' is required and must be a string"
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
conf_file = resolve_safe_path(vault_root, conflict_path)
orig_file = resolve_safe_path(vault_root, original_path)
if not conf_file.exists():
raise HTTPException(404, "Conflict file not found")
try:
if action == "keep_conflict":
create_backup(orig_file, vault_name, original_path)
shutil.copy2(conf_file, orig_file)
logger.info(f"Conflict resolved (keep_conflict): {conflict_path} → {original_path}")
conf_file.unlink()
await remove_single_file(vault_name, conflict_path)
log_file_delete(current_user["username"], vault_name, conflict_path)
await sse_manager.broadcast("file_deleted", {"vault": vault_name, "path": conflict_path})
return {"status": "resolved", "action": action}
except Exception as e:
raise HTTPException(500, f"Error resolving conflict: {e!s}")
+88
View File
@@ -0,0 +1,88 @@
"""Duplicate detection & merge endpoints (#166).
Read endpoints require vault access; the merge endpoint is destructive
(backup first in the service layer) and additionally requires the
confirmation token pattern used by mutating routes — here enforced by an
explicit ``confirm=true`` body flag, mirroring the agent two-step flow.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from backend.auth.middleware import check_vault_access, require_auth
from backend.services import duplicates as _duplicates
from backend.services.errors import ServiceError
router = APIRouter(prefix="/api/duplicates", tags=["duplicates"])
class DuplicatePair(BaseModel):
"""One candidate duplicate pair."""
model_config = ConfigDict(extra="allow")
file_a: str = Field(description="First file (vault-relative)")
file_b: str = Field(description="Second file (vault-relative)")
score: float = Field(description="Blended similarity in [0, 1]")
class DuplicatesResponse(BaseModel):
"""Response for GET /api/duplicates."""
model_config = ConfigDict(extra="allow")
vault: str = Field(description="Vault name")
threshold: float = Field(description="Applied threshold")
files_scanned: int = Field(description="Markdown files compared")
truncated: bool = Field(description="True when the scan hit the file cap")
pairs: list[DuplicatePair] = Field(description="Candidate pairs, best score first")
class MergeResponse(BaseModel):
"""Response for POST /api/duplicates/merge."""
model_config = ConfigDict(extra="allow")
strategy: str = Field(description="Applied merge strategy")
target: str = Field(description="Surviving note")
deleted: str = Field(description="Absorbed note (deleted after merge)")
@router.get("", response_model=DuplicatesResponse)
async def api_duplicates_list(
vault: str = Query(..., description="Vault name"),
threshold: float = Query(0.75, ge=0.3, le=1.0, description="Minimum similarity"),
limit: int = Query(20, ge=1, le=200, description="Max pairs"),
subdir: str = Query("", description="Directory scope"),
current_user: dict[str, Any] = Depends(require_auth),
):
"""List candidate duplicate notes ordered by descending score."""
if not check_vault_access(vault, current_user):
raise HTTPException(403, f"No access to vault '{vault}'")
try:
return _duplicates.find_duplicate_pairs(vault, threshold=threshold, limit=limit, subdir=subdir)
except ServiceError as e:
raise HTTPException(e.status or 400, e.message) from e
@router.post("/merge", response_model=MergeResponse)
async def api_duplicates_merge(
body: dict[str, Any] = Body(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Merge *source_path* into *target_path* (``confirm: true`` required)."""
vault = str(body.get("vault") or "")
if not check_vault_access(vault, current_user):
raise HTTPException(403, f"No access to vault '{vault}'")
if body.get("confirm") is not True:
raise HTTPException(400, "Fusion destructive : confirmez avec {confirm: true}")
try:
return _duplicates.merge_duplicates(
vault,
str(body.get("source_path") or ""),
str(body.get("target_path") or ""),
strategy=str(body.get("strategy") or "append"),
)
except ServiceError as e:
raise HTTPException(e.status or 400, e.message) from e
+3 -3
View File
@@ -9,7 +9,8 @@ d'authentification.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
- ``_render_markdown`` reste dans ``main`` (import différé).
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
d'import).
- ``_resolve_export_target`` / ``_safe_export_name`` (export uniquement)
sont définis ici ; ``stream_file_with_range`` vit dans
:mod:`backend.routers.helpers` (partagé).
@@ -29,6 +30,7 @@ from backend.history import record_open
from backend.indexer import get_vault_data, index, parse_markdown_file
from backend.media_thumbs import generate_thumbnail, is_decodable
from backend.media_types import is_audio, is_image, is_video, media_mime_type
from backend.render import _render_markdown
from backend.routers.helpers import media_max_inline_bytes, stream_file_with_range
from backend.schemas import (
AllVaultSettingsResponse,
@@ -85,8 +87,6 @@ def _safe_export_name(name: str) -> str:
)
async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a markdown file as PDF."""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
if generate_pdf is None:
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
if not check_vault_access(vault_name, current_user):
+191 -26
View File
@@ -7,8 +7,8 @@ lecture), mêmes modèles de réponse (déménagés dans
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
- ``_render_markdown`` reste dans ``main`` (import différé, extraction
prévue dans une tranche ultérieure).
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
d'import).
- ``_content_disposition`` / ``_media_max_inline_bytes`` / ``EXT_TO_LANG``
ont déménagé : helpers partagés dans :mod:`backend.routers.helpers`
(``EXT_TO_LANG`` n'était utilisé que par la vue fichier).
@@ -31,16 +31,20 @@ from backend.indexer import (
parse_markdown_file,
)
from backend.media_types import is_audio, is_image, is_video, media_mime_type
from backend.render import _render_markdown
from backend.routers.helpers import media_max_inline_bytes
from backend.schemas import (
BacklinksResponse,
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")
@@ -177,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.
@@ -192,8 +306,6 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
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)
@@ -242,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 {
@@ -258,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,
}
@@ -375,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)
+160
View File
@@ -0,0 +1,160 @@
"""History endpoints — recent, bookmarks, saved searches (ROADMAP #85, tranche 8).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins, mêmes modèles (``BookmarkToggleRequest``
déménagé dans :mod:`backend.schemas`), mêmes dépendances
d'authentification.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
et :mod:`backend.services.backups` (pass-through).
- ``_load_config`` vient de :mod:`backend.routers.config`.
"""
import logging
from pathlib import Path
import frontmatter
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.auth.middleware import check_vault_access, require_auth
from backend.history import get_bookmarks, toggle_bookmark
from backend.indexer import find_file_in_index, get_vault_data, update_single_file
from backend.routers.config import _load_config
from backend.saved_searches import delete_saved, get_saved, save_search
from backend.schemas import (
BookmarksResponse,
BookmarkToggleRequest,
BookmarkToggleResponse,
RecentResponse,
SavedSearch,
StatusResponse,
)
from backend.services.backups import create_backup
from backend.services.paths import resolve_safe_path
from backend.services.recent import humanize_mtime, list_recent
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["Bookmarks"])
@router.get("/api/recent", response_model=RecentResponse)
async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
config = _load_config()
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
username = current_user.get("username")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
return list_recent(
username,
user_vaults,
vault=vault,
limit=actual_limit,
mode=mode or "opened",
)
@router.get("/api/bookmarks", response_model=BookmarksResponse)
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
username = current_user.get("username")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
if not username:
return {"files": []}
history = get_bookmarks(username, vault_filter=vault)
files_resp = []
for item in history:
v_name = item["vault"]
if "*" not in user_vaults and v_name not in user_vaults:
continue
# Find in index to get metadata
f_idx = find_file_in_index(item["path"], v_name)
if f_idx:
files_resp.append({
"path": f_idx["path"],
"title": f_idx.get("title") or item["path"].split("/")[-1],
"vault": v_name,
"mtime": item["bookmarked_at"],
"mtime_human": humanize_mtime(item["bookmarked_at"]),
"size_bytes": f_idx.get("size", 0),
"tags": [f"#{t}" for t in f_idx.get("tags", [])][:5],
"bookmarked": True
})
else:
files_resp.append({
"path": item["path"],
"title": item.get("title") or item["path"].split("/")[-1],
"vault": v_name,
"mtime": item["bookmarked_at"],
"mtime_human": humanize_mtime(item["bookmarked_at"]),
"tags": [],
"bookmarked": True
})
return {
"files": files_resp,
"total": len(files_resp)
}
@router.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse)
async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(status_code=401, detail="Not authenticated")
# Check vault access
if not check_vault_access(req.vault, current_user):
raise HTTPException(status_code=403, detail="Access denied to vault")
is_now_bookmarked = toggle_bookmark(username, req.vault, req.path, req.title or "")
# Update the file's YAML frontmatter: favoris: true/false
vault_data = get_vault_data(req.vault)
if vault_data:
file_path = resolve_safe_path(Path(vault_data["path"]), req.path)
if file_path.exists() and file_path.suffix == ".md":
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
post = frontmatter.loads(raw)
if is_now_bookmarked:
post.metadata["favoris"] = True
elif "favoris" in post.metadata:
del post.metadata["favoris"]
new_raw = frontmatter.dumps(post)
create_backup(file_path, req.vault, req.path)
file_path.write_text(new_raw, encoding="utf-8")
await update_single_file(req.vault, str(file_path))
except Exception as e:
logger.warning(f"Failed to update favoris metadata on {req.vault}/{req.path}: {e}")
return {"bookmarked": is_now_bookmarked}
@router.get("/api/saved-searches", response_model=list[SavedSearch])
async def api_saved_searches(current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(401)
return get_saved(username)
@router.post("/api/saved-searches", response_model=SavedSearch)
async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(401)
return save_search(username, body)
@router.delete("/api/saved-searches/{search_id}", response_model=StatusResponse)
async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
raise HTTPException(401)
if not delete_saved(username, search_id):
raise HTTPException(404, "Not found")
return {"status": "deleted"}
+96
View File
@@ -0,0 +1,96 @@
"""External notification channels endpoints (#168).
Channel CRUD is admin-only (secrets involved); sending a test notification
requires authentication. Responses mask secrets (``***`` + ``has_secret``).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from backend import notify as _notify
from backend.auth.middleware import require_admin, require_auth
from backend.schemas import StatusResponse
router = APIRouter(prefix="/api/notify", tags=["notify"])
class NotifyChannel(BaseModel):
"""Public view of a notification channel (secrets masked)."""
model_config = ConfigDict(extra="allow")
id: str = Field(description="Channel id")
name: str = Field(description="Display name")
type: str = Field(description="discord | telegram | smtp | webhook")
enabled: bool = Field(description="Whether the channel receives broadcasts")
config: dict[str, Any] = Field(description="Channel config (secrets masked)")
has_secret: bool = Field(description="True when a secret is configured")
class NotifySendResult(BaseModel):
"""Outcome of a test send / broadcast."""
model_config = ConfigDict(extra="allow")
ok: bool = Field(description="True when every delivery succeeded")
deliveries: list[dict[str, Any]] = Field(default_factory=list)
@router.get("/channels", response_model=list[NotifyChannel])
async def api_notify_list(current_user=Depends(require_admin)):
"""List notification channels (admin)."""
return _notify.list_channels()
@router.post("/channels", response_model=NotifyChannel)
async def api_notify_create(body: dict = Body(...), current_user=Depends(require_admin)):
"""Create a channel (``{name, type, config}``). Secrets go to the secret store."""
try:
return _notify.create_channel(
str(body.get("name") or ""),
str(body.get("type") or ""),
dict(body.get("config") or {}),
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/channels/{channel_id}", response_model=NotifyChannel)
async def api_notify_update(channel_id: str, body: dict = Body(...), current_user=Depends(require_admin)):
"""Update a channel (name / enabled / config)."""
try:
result = _notify.update_channel(channel_id, body)
except ValueError as e:
raise HTTPException(400, str(e)) from e
if result is None:
raise HTTPException(404, "Channel not found")
return result
@router.delete("/channels/{channel_id}", response_model=StatusResponse)
async def api_notify_delete(channel_id: str, current_user=Depends(require_admin)):
"""Delete a channel and its secret."""
if not _notify.delete_channel(channel_id):
raise HTTPException(404, "Channel not found")
return {"status": "deleted"}
@router.post("/test", response_model=NotifySendResult)
async def api_notify_test(body: dict = Body(...), current_user=Depends(require_auth)):
"""Send a test notification (broadcast or single ``channel_id``)."""
title = str(body.get("title") or "Test ObsiGate")
message = str(body.get("message") or "Notification de test.")
channel_id = str(body.get("channel_id") or "")
if channel_id:
channel = next((c for c in _notify._read_channels() if c.get("id") == channel_id), None)
if channel is None:
raise HTTPException(404, "Channel not found")
try:
_notify.send_via_channel(channel, title, message, "manual")
except Exception as e:
raise HTTPException(502, f"Envoi échoué : {e}") from e
return {"ok": True, "deliveries": [{"channel_id": channel_id, "ok": True}]}
deliveries = _notify.broadcast("manual", title, message)
return {"ok": all(d.get("ok") for d in deliveries), "deliveries": deliveries}
+105
View File
@@ -0,0 +1,105 @@
"""Real-time endpoints — SSE stream & collaboration WebSocket (ROADMAP #85, tranche 9).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/events``,
``/ws/collab/{vault}/{path}``), même authentification (Depend pour le SSE,
manuelle pour le WebSocket — les ``Depends`` FastAPI ne s'exécutent pas sur
les routes WebSocket).
Pas de tags déclarés : assignation par chemin via
``openapi_docs.tag_for_path`` comme avant (``/api/events`` → System).
"""
import asyncio
import json as _json
from fastapi import APIRouter, Depends, WebSocket
from fastapi.responses import StreamingResponse
from backend.auth.middleware import check_vault_access, require_auth
from backend.collab import authenticate_websocket, collab_manager
from backend.services.paths import resolve_safe_path
from backend.services.vaults import get_vault_root
from backend.sse import sse_manager
router = APIRouter()
@router.get(
"/api/events",
response_class=StreamingResponse,
responses={200: {"content": {"text/event-stream": {}}, "description": "Server-Sent Events stream"}},
)
async def api_events(current_user=Depends(require_auth)):
"""SSE stream for real-time index update notifications.
Sends keepalive comments every 30s. Events:
- ``index_updated``: partial index change (file create/modify/delete/move)
- ``index_reloaded``: full re-index completed
- ``vault_added``: new vault added dynamically
- ``vault_removed``: vault removed dynamically
"""
queue = await sse_manager.connect()
async def event_generator():
try:
# Send initial connection event
yield f"event: connected\ndata: {_json.dumps({'sse_clients': sse_manager.client_count})}\n\n"
while True:
try:
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
except asyncio.TimeoutError:
# Keepalive comment
yield ": keepalive\n\n"
except asyncio.CancelledError:
break
finally:
sse_manager.disconnect(queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.websocket("/ws/collab/{vault_name}/{path:path}")
async def collab_websocket(websocket: WebSocket, vault_name: str, path: str):
"""Real-time collaborative editing over WebSocket (ROADMAP #62).
One *room* is created per ``vault::path``; all clients editing the same
file share Yjs/CRDT updates, awareness (cursors/selection) and a debounced
server-side persistence of the markdown content.
Authentication is performed manually (FastAPI ``Depends`` do not run for
WebSocket routes) and vault access is enforced per connection.
"""
from backend.services.errors import ServiceError
user = authenticate_websocket(websocket)
if user is None:
await websocket.close(code=4401)
return
if not check_vault_access(vault_name, user):
await websocket.close(code=4403)
return
try:
vault_root = get_vault_root(vault_name)
file_path = resolve_safe_path(vault_root, path)
except ServiceError:
await websocket.close(code=4404)
return
if not file_path.exists() or not file_path.is_file():
await websocket.close(code=4404)
return
await websocket.accept()
await collab_manager.connect(websocket, vault_name, path, file_path, user)
+118
View File
@@ -0,0 +1,118 @@
"""Scheduled tasks endpoints (#170).
Tasks reuse the existing mutation/notification services — this router only
validates, persists and triggers. File-writing actions check vault access
at creation time; the background tick re-checks nothing (system context) but
records failures and notifies on ``schedule_failure`` (#168).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from backend import scheduler as _scheduler
from backend.auth.middleware import check_vault_access, require_auth
from backend.schemas import StatusResponse
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
class ScheduledTask(BaseModel):
"""A programmed automatic task."""
model_config = ConfigDict(extra="allow")
id: str = Field(description="Task id")
name: str = Field(description="Display name")
action: dict[str, Any] = Field(description="{kind, params}")
schedule: dict[str, Any] = Field(description="{kind, ...}")
enabled: bool = Field(description="Whether the tick executes it")
created_by: str = Field(description="Owner username")
created_at: str = Field(description="ISO-8601 creation time")
last_run_at: str | None = Field(default=None)
last_status: str | None = Field(default=None)
last_error: str | None = Field(default=None)
run_count: int = Field(default=0)
next_run_at: str = Field(description="ISO-8601 next due time")
class TaskRunResult(BaseModel):
"""Outcome of a manual or due run."""
model_config = ConfigDict(extra="allow")
task_id: str = Field(description="Task id")
ok: bool = Field(description="True on success")
result: dict[str, Any] | None = Field(default=None)
error: str | None = Field(default=None)
def _check_action_vault(action: dict[str, Any], user: dict[str, Any]) -> None:
from backend.services.errors import ServiceError
from backend.services.vaults import get_vault_root
kind = (action or {}).get("kind")
params = (action or {}).get("params") or {}
if kind in ("create_file", "append_to_file"):
vault = str(params.get("vault") or "")
if not check_vault_access(vault, user):
raise HTTPException(403, f"No access to vault '{vault}'")
try:
get_vault_root(vault)
except ServiceError as e:
raise HTTPException(404, f"Unknown vault '{vault}'") from e
@router.get("/tasks", response_model=list[ScheduledTask])
async def api_scheduler_list(current_user: dict[str, Any] = Depends(require_auth)):
"""List scheduled tasks (newest first)."""
return _scheduler.list_tasks()
@router.post("/tasks", response_model=ScheduledTask)
async def api_scheduler_create(body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
"""Create a task (``{name, action, schedule, enabled?}``)."""
action = dict(body.get("action") or {})
_check_action_vault(action, current_user)
try:
return _scheduler.create_task(
str(body.get("name") or ""),
action,
dict(body.get("schedule") or {}),
created_by=str(current_user.get("username", "api")),
enabled=bool(body.get("enabled", True)),
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/tasks/{task_id}", response_model=ScheduledTask)
async def api_scheduler_update(task_id: str, body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
"""Update a task (name / enabled / action / schedule)."""
if "action" in body:
_check_action_vault(dict(body["action"] or {}), current_user)
try:
result = _scheduler.update_task(task_id, body)
except ValueError as e:
raise HTTPException(400, str(e)) from e
if result is None:
raise HTTPException(404, "Task not found")
return result
@router.delete("/tasks/{task_id}", response_model=StatusResponse)
async def api_scheduler_delete(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Delete a task."""
if not _scheduler.delete_task(task_id):
raise HTTPException(404, "Task not found")
return {"status": "deleted"}
@router.post("/tasks/{task_id}/run", response_model=TaskRunResult)
async def api_scheduler_run(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Execute a task immediately (manual run)."""
try:
return _scheduler.run_task(task_id, manual=True)
except KeyError:
raise HTTPException(404, "Task not found") from None
+19 -13
View File
@@ -11,9 +11,8 @@ Adaptations strictement équivalentes (pas de changement de comportement) :
wrappers directs : appelés ici via :mod:`backend.services.paths` et
:mod:`backend.services.backups` (mêmes signatures, mêmes exceptions
``ServiceError`` toujours mappées par le handler global de ``main``).
- ``_render_markdown`` reste défini dans ``main`` (extraction prévue dans
une tranche ultérieure) : import différé à l'intérieur des handlers, donc
sans import circulaire au chargement.
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
d'import).
"""
import html as html_mod
@@ -22,11 +21,12 @@ import logging
from pathlib import Path
import frontmatter
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, Response
from backend.auth.middleware import check_vault_access, require_auth
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
from backend.render import _render_markdown
from backend.schemas import ShareModel, StatusResponse
from backend.secret_redactor import redact_file_content
from backend.services.backups import create_backup
@@ -115,8 +115,6 @@ async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
)
async def public_share_pdf_download(token: str):
"""Download shared document as real PDF via WeasyPrint."""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
if generate_pdf is None:
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
share = get_share_by_token(token)
@@ -166,9 +164,9 @@ async def public_share_raw(token: str):
@router.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(token: str):
async def public_share_view(request: Request, token: str):
"""Public share view — no authentication required."""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
from backend.csp import inject_csp_nonce
share = get_share_by_token(token)
if not share:
@@ -234,7 +232,9 @@ async def public_share_view(token: str):
if fm_items:
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
return HTMLResponse(
inject_csp_nonce(
f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title_esc} — ObsiGate Share</title>
<style>
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
@@ -278,15 +278,15 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
</div>
<div class="toolbar">
<span class="toolbar-title">{title_esc}</span>
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
<button class="toolbar-btn" data-share-theme title="Thème clair/sombre">
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
</button>
<button class="toolbar-btn" onclick="exportMD()" title="Télécharger en Markdown">
<button class="toolbar-btn" data-share-md title="Télécharger en Markdown">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
.md
</button>
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
<button class="toolbar-btn" data-share-pdf title="Télécharger en PDF">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
PDF
</button>
@@ -297,4 +297,10 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
</script></body></html>""")
document.querySelector("[data-share-theme]").addEventListener("click",toggleTheme);
document.querySelector("[data-share-md]").addEventListener("click",exportMD);
document.querySelector("[data-share-pdf]").addEventListener("click",function(){{location.href=location.pathname+"/pdf"}});
</script></body></html>""",
request.state.csp_nonce,
),
)
+107
View File
@@ -0,0 +1,107 @@
"""Vault management endpoints (ROADMAP #85, tranche 8).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/vaults*``), mêmes modèles de réponse
(``VaultInfo`` déménagé dans :mod:`backend.schemas`), mêmes dépendances
d'authentification.
Le handle du file-watcher vit désormais dans :mod:`backend.watcher_state`
(partagé avec le lifespan de ``main``) au lieu du global de ``main``.
"""
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException
from backend.auth.middleware import require_admin, require_auth
from backend.indexer import add_vault_to_index, index, remove_vault_from_index
from backend.schemas import VaultActionResponse, VaultInfo, VaultsStatusResponse, VaultStatsResponse
from backend.services.vaults import list_accessible_vaults
from backend.sse import sse_manager
from backend.watcher_state import get_watcher
router = APIRouter(tags=["vaults"])
@router.get("/api/vaults", response_model=list[VaultInfo])
async def api_vaults(current_user=Depends(require_auth)):
"""List configured vaults the user has access to.
Returns:
List of vault summary objects filtered by user permissions.
"""
return list_accessible_vaults(current_user)
@router.post("/api/vaults/add", response_model=VaultStatsResponse)
async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)):
"""Add a new vault dynamically without restarting.
Body:
name: Display name for the vault.
path: Absolute filesystem path to the vault directory.
"""
name = body.get("name", "").strip()
vault_path = body.get("path", "").strip()
if not name or not vault_path:
raise HTTPException(status_code=400, detail="Both 'name' and 'path' are required")
if name in index:
raise HTTPException(status_code=409, detail=f"Vault '{name}' already exists")
if not Path(vault_path).exists():
raise HTTPException(status_code=400, detail=f"Path does not exist: {vault_path}")
stats = await add_vault_to_index(name, vault_path)
# Start watching the new vault
watcher = get_watcher()
if watcher:
await watcher.add_vault(name, vault_path)
await sse_manager.broadcast("vault_added", {"vault": name, "stats": stats})
return {"status": "ok", "vault": name, "stats": stats}
@router.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse)
async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)):
"""Remove a vault from the index and stop watching it.
Args:
vault_name: Name of the vault to remove.
"""
if vault_name not in index:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
# Stop watching
watcher = get_watcher()
if watcher:
await watcher.remove_vault(vault_name)
await remove_vault_from_index(vault_name)
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
return {"status": "ok", "vault": vault_name}
@router.get("/api/vaults/status", response_model=VaultsStatusResponse)
async def api_vaults_status(current_user=Depends(require_auth)):
"""Detailed status of all vaults including watcher state.
Returns per-vault: file count, tag count, watching status, vault path.
"""
watcher = get_watcher()
statuses = {}
for vname, vdata in index.items():
watching = watcher is not None and vname in watcher.observers
statuses[vname] = {
"file_count": len(vdata.get("files", [])),
"tag_count": len(vdata.get("tags", {})),
"path": vdata.get("path", ""),
"watching": watching,
}
return {
"vaults": statuses,
"watcher_active": watcher is not None,
"sse_clients": sse_manager.client_count,
}
+331
View File
@@ -0,0 +1,331 @@
"""Scheduled tasks — automatic agent actions, type cron (#170).
Tasks are persisted in ``data/scheduled_tasks.json`` (guarded by an RLock,
same pattern as the other JSON stores). Supported actions reuse the existing
mutation/notification services — no new write path:
* ``create_file`` → ``backend.services.mutations.create_file``;
* ``append_to_file`` → ``backend.services.mutations.append_to_file``;
* ``notify`` → ``backend.notify.broadcast`` (trigger ``manual``).
Supported schedules:
* ``interval_hours`` — every N hours (N >= 0.25);
* ``daily_time`` — once a day at ``HH:MM`` (local server time);
* ``once_at`` — one shot at an ISO-8601 datetime (past = due immediately).
On failure the task records ``last_error`` and a ``schedule_failure``
broadcast is emitted to the notification channels (#168) — best effort,
never recursive (a failing ``notify`` action does not rebroadcast).
"""
from __future__ import annotations
import json
import logging
import os
import threading
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.scheduler")
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
TASKS_FILE = DATA_DIR / "scheduled_tasks.json"
ACTION_KINDS = ("create_file", "append_to_file", "notify")
SCHEDULE_KINDS = ("interval_hours", "daily_time", "once_at")
_lock = threading.RLock()
# ── Store ──────────────────────────────────────────────────────────────────
def _read_tasks() -> list[dict[str, Any]]:
if not TASKS_FILE.exists():
return []
try:
data = json.loads(TASKS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except (json.JSONDecodeError, OSError):
return []
def _write_tasks(tasks: list[dict[str, Any]]) -> None:
TASKS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = TASKS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(tasks, indent=2, default=str), encoding="utf-8")
tmp.replace(TASKS_FILE)
def list_tasks() -> list[dict[str, Any]]:
"""Return all scheduled tasks (newest first)."""
return sorted(_read_tasks(), key=lambda t: t.get("created_at", ""), reverse=True)
def get_task(task_id: str) -> dict[str, Any] | None:
"""Return one task by id, or None."""
for task in _read_tasks():
if task.get("id") == task_id:
return task
return None
# ── Validation ─────────────────────────────────────────────────────────────
def _validate_action(action: dict[str, Any]) -> dict[str, Any]:
kind = action.get("kind")
if kind not in ACTION_KINDS:
raise ValueError(f"Action inconnue : {kind} (attendu : {', '.join(ACTION_KINDS)})")
params = dict(action.get("params") or {})
if kind in ("create_file", "append_to_file"):
if not str(params.get("vault") or "").strip():
raise ValueError("params.vault requis pour create_file/append_to_file")
if not str(params.get("path") or "").strip():
raise ValueError("params.path requis pour create_file/append_to_file")
if kind == "append_to_file" and not str(params.get("content") or ""):
raise ValueError("params.content requis pour append_to_file")
elif kind == "notify":
if not str(params.get("title") or "").strip():
raise ValueError("params.title requis pour notify")
if not str(params.get("message") or "").strip():
raise ValueError("params.message requis pour notify")
return {"kind": kind, "params": params}
def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
kind = schedule.get("kind")
if kind not in SCHEDULE_KINDS:
raise ValueError(f"Planification inconnue : {kind} (attendu : {', '.join(SCHEDULE_KINDS)})")
if kind == "interval_hours":
hours = float(schedule.get("hours") or 0)
if hours < 0.25:
raise ValueError("hours doit être >= 0.25")
return {"kind": kind, "hours": hours}
if kind == "daily_time":
at = str(schedule.get("at") or "").strip()
try:
datetime.strptime(at, "%H:%M")
except ValueError:
raise ValueError("at doit être au format HH:MM (ex. 08:30)") from None
return {"kind": kind, "at": at}
# once_at
at = str(schedule.get("at") or "").strip()
try:
parsed = datetime.fromisoformat(at)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
except ValueError:
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
return {"kind": kind, "at": parsed.isoformat()}
# ── CRUD ───────────────────────────────────────────────────────────────────
def create_task(
name: str,
action: dict[str, Any],
schedule: dict[str, Any],
*,
created_by: str = "api",
enabled: bool = True,
) -> dict[str, Any]:
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
validated_action = _validate_action(action)
validated_schedule = _validate_schedule(schedule)
now = datetime.now(timezone.utc)
with _lock:
tasks = _read_tasks()
task = {
"id": str(uuid.uuid4()),
"name": (name or validated_action["kind"]).strip() or validated_action["kind"],
"action": validated_action,
"schedule": validated_schedule,
"enabled": bool(enabled),
"created_by": created_by,
"created_at": now.isoformat(),
"last_run_at": None,
"last_status": None,
"last_error": None,
"run_count": 0,
"next_run_at": compute_next_run(
{"schedule": validated_schedule, "last_run_at": None}, now
).isoformat(),
}
tasks.append(task)
_write_tasks(tasks)
logger.info(f"Scheduled task created: '{task['name']}' ({validated_schedule['kind']})")
return task
def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
"""Update name/enabled/action/schedule. Returns None when unknown."""
with _lock:
tasks = _read_tasks()
for task in tasks:
if task.get("id") != task_id:
continue
if updates.get("name"):
task["name"] = str(updates["name"])
if "enabled" in updates:
task["enabled"] = bool(updates["enabled"])
if "action" in updates:
task["action"] = _validate_action(updates["action"])
if "schedule" in updates:
task["schedule"] = _validate_schedule(updates["schedule"])
task["next_run_at"] = compute_next_run(task).isoformat()
_write_tasks(tasks)
return task
return None
def delete_task(task_id: str) -> bool:
"""Delete a task. Returns False when unknown."""
with _lock:
tasks = _read_tasks()
remaining = [t for t in tasks if t.get("id") != task_id]
if len(remaining) == len(tasks):
return False
_write_tasks(remaining)
return True
# ── Scheduling ─────────────────────────────────────────────────────────────
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
"""Compute the next due datetime for *task*."""
now = now or datetime.now(timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
schedule = task.get("schedule", {})
kind = schedule.get("kind")
last_run_at = task.get("last_run_at")
last = None
if last_run_at:
try:
last = datetime.fromisoformat(str(last_run_at))
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
except ValueError:
last = None
if kind == "interval_hours":
hours = float(schedule.get("hours", 24))
base = last or now
nxt = base + timedelta(hours=hours)
# Première planification : due dès maintenant + intervalle ? Non —
# la tâche démarre au prochain intervalle, sauf retard déjà accumulé.
if last is None:
nxt = now + timedelta(hours=hours)
return max(now, nxt)
if kind == "daily_time":
hour, minute = (str(schedule.get("at", "08:00")) + ":00").split(":")[:2]
candidate = now.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
if candidate <= now:
candidate += timedelta(days=1)
return candidate
if kind == "once_at":
try:
at = datetime.fromisoformat(str(schedule.get("at")))
if at.tzinfo is None:
at = at.replace(tzinfo=timezone.utc)
except ValueError:
return now
if task.get("last_run_at"):
return datetime.max.replace(tzinfo=timezone.utc) # déjà exécutée
return at
return now + timedelta(hours=24)
def _execute_action(task: dict[str, Any]) -> dict[str, Any]:
action = task["action"]
kind = action["kind"]
params = action["params"]
if kind == "create_file":
from backend.services.mutations import create_file
return create_file(
params["vault"],
params["path"],
params.get("content", ""),
overwrite=bool(params.get("overwrite", False)),
)
if kind == "append_to_file":
from backend.services.mutations import append_to_file
return append_to_file(params["vault"], params["path"], params.get("content", ""))
if kind == "notify":
from backend.notify import broadcast
results = broadcast("manual", str(params["title"]), str(params.get("message", "")))
return {"broadcast": results}
raise ValueError(f"Action inconnue : {kind}")
def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
"""Execute one task now (manual or due). Records status; notifies on failure."""
with _lock:
tasks = _read_tasks()
task = next((t for t in tasks if t.get("id") == task_id), None)
if task is None:
raise KeyError(task_id)
if not task.get("enabled", True) and not manual:
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
try:
result = _execute_action(task)
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
task["last_status"] = "ok"
task["last_error"] = None
task["run_count"] = int(task.get("run_count", 0)) + 1
if task.get("schedule", {}).get("kind") == "once_at":
task["enabled"] = False # one-shot consommé
task["next_run_at"] = compute_next_run(task).isoformat()
_write_tasks(tasks)
if manual:
from backend.notify import broadcast
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
return {"task_id": task_id, "ok": True, "result": result}
except Exception as e:
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
task["last_status"] = "error"
task["last_error"] = str(e)
task["run_count"] = int(task.get("run_count", 0)) + 1
task["next_run_at"] = compute_next_run(task).isoformat()
_write_tasks(tasks)
logger.warning(f"Scheduled task '{task.get('name')}' failed: {e}")
if task["action"]["kind"] != "notify":
try:
from backend.notify import broadcast
broadcast(
"schedule_failure",
f"Échec tâche « {task.get('name')} »",
f"{e}",
)
except Exception:
logger.debug("Failure notification broadcast failed", exc_info=True)
return {"task_id": task_id, "ok": False, "error": str(e)}
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
"""Run every due task. Returns per-task outcomes (empty when idle)."""
now = now or datetime.now(timezone.utc)
outcomes: list[dict[str, Any]] = []
for task in _read_tasks():
if not task.get("enabled", True):
continue
try:
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc)
except ValueError:
next_run = compute_next_run(task, now)
if next_run <= now:
outcomes.append(run_task(task["id"]))
return outcomes
+149 -2
View File
@@ -214,6 +214,30 @@ class RestoreResponse(BaseModel):
current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
class BackupEntry(BaseModel):
"""A single backup version of a file (#85 — extrait de backend.main, inchangé)."""
timestamp: int = Field(description="Unix timestamp of when the backup was created")
datetime: str = Field(description="ISO 8601 datetime string")
size: int = Field(description="File size in bytes")
filename: str = Field(description="Backup filename on disk")
class BackupListResponse(BaseModel):
"""Response listing all available backups for a file (#85 — extrait de backend.main, inchangé)."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
backups: list[BackupEntry] = Field(description="Available backups, newest first")
class DiffRequest(BaseModel):
"""Request parameters for generating a diff (#85 — extrait de backend.main, inchangé)."""
version: int = Field(description="Timestamp of the backup version to compare")
compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
# ---------------------------------------------------------------------------
# Files — browse / read (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
@@ -261,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")
@@ -273,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."""
@@ -293,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):
@@ -411,6 +533,28 @@ class FileMoveResponse(BaseModel):
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
# ---------------------------------------------------------------------------
# Vaults & history (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
class VaultInfo(BaseModel):
"""Summary information about a configured vault."""
name: str = Field(description="Display name of the vault")
file_count: int = Field(description="Number of indexed files")
tag_count: int = Field(description="Number of unique tags")
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
class BookmarkToggleRequest(BaseModel):
"""Request to toggle a bookmark on a file."""
vault: str
path: str
title: str | None = None
# ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
@@ -502,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):
@@ -511,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")
@@ -522,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):
@@ -717,6 +863,7 @@ class VaultFileEntry(BaseModel):
modified_iso: str | None = None
extension: str = ""
rel_dir: str | None = None
tags: list[str] = Field(default_factory=list, description="Tags de l'index (#158)")
class VaultFilesResponse(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()
+1 -1
View File
@@ -31,7 +31,7 @@ DEFAULT_MAX_BACKUPS = 10
def _default_max_backups() -> int:
"""Read ``max_backups_per_file`` from app config (lazy, best-effort)."""
try:
from backend.main import _load_config
from backend.routers.config import _load_config # ROADMAP #85 T7 — déménagé depuis backend.main
return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS))
except Exception: # pragma: no cover - config unavailable
+215
View File
@@ -0,0 +1,215 @@
"""Duplicate detection & merge services (#166).
Single source of truth consumed by the REST routes
(``/api/duplicates``) and the AI tool layer (``find_duplicates``,
``merge_duplicate_notes``).
Method is deterministic stdlib-only: frontmatter stripped, token-set
Jaccard blended with a title similarity. No embedding dependency —
the semantic index (#70) stays an optional refinement, not a requirement.
Fusion never runs without an explicit confirmation: the tool layer
registers the merge as ``DANGEROUS`` (two-step propose/apply) and this
service takes an automatic backup before any destructive write.
"""
from __future__ import annotations
import logging
import re
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
from backend.services.backups import create_backup
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
from backend.services.vaults import get_vault_root
logger = logging.getLogger("obsigate.services.duplicates")
MAX_FILES_SCANNED = 500
MAX_FILE_BYTES = 200_000
MAX_CONTENT_CHARS = 50_000
_WORD_RE = re.compile(r"[\w]+", re.UNICODE)
_FRONTMATTER_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)
def _strip_frontmatter(text: str) -> str:
"""Remove a leading YAML frontmatter block, if present."""
return _FRONTMATTER_RE.sub("", text, count=1)
def _tokens(text: str) -> set[str]:
"""Lowercase word tokens (keeps accents), stop-words free but tiny tokens dropped."""
return {t for t in _WORD_RE.findall(text.lower()) if len(t) > 2}
def similarity_score(a: str, b: str) -> float:
"""Blend Jaccard (0.7) + title/first-line similarity (0.3) in [0, 1].
Pure function — unit-tested directly.
"""
ta, tb = _tokens(_strip_frontmatter(a)), _tokens(_strip_frontmatter(b))
if not ta or not tb:
return 0.0
jaccard = len(ta & tb) / len(ta | tb)
head_a = (a.strip().splitlines() or [""])[:1][0][:200].lower()
head_b = (b.strip().splitlines() or [""])[:1][0][:200].lower()
title_sim = SequenceMatcher(None, head_a, head_b).ratio() if head_a and head_b else 0.0
return round(0.7 * jaccard + 0.3 * title_sim, 4)
def _iter_markdown_files(root: Path, subdir: str = "") -> list[Path]:
base = resolve_safe_path(root, subdir) if subdir else root.resolve()
if not base.exists() or not base.is_dir():
raise ServiceError(
f"Directory not found: {subdir or '.'}",
code="not_found",
status=404,
details={"path": subdir},
)
files = sorted(
(p for p in base.rglob("*.md") if p.is_file() and not p.is_symlink()),
key=lambda p: str(p),
)
return files[:MAX_FILES_SCANNED]
def _read_capped(path: Path) -> str:
try:
if path.stat().st_size > MAX_FILE_BYTES:
return ""
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
return text[:MAX_CONTENT_CHARS]
def find_duplicate_pairs(
vault: str,
threshold: float = 0.75,
limit: int = 50,
subdir: str = "",
) -> dict[str, Any]:
"""Return candidate duplicate pairs ordered by descending score.
Args:
vault: Vault name.
threshold: Minimum blended score in [0.3, 1.0].
limit: Max pairs returned (1-200).
subdir: Optional vault-relative directory scope.
"""
if not 0.3 <= threshold <= 1.0:
raise ServiceError(
"threshold must be between 0.3 and 1.0",
code="invalid_arguments",
status=400,
)
limit = max(1, min(limit, 200))
root = get_vault_root(vault)
files = _iter_markdown_files(root, subdir)
contents: dict[str, str] = {}
token_sets: dict[str, set[str]] = {}
for path in files:
rel = str(path.relative_to(root)).replace("\\", "/")
text = _read_capped(path)
if not text.strip():
continue
contents[rel] = text
token_sets[rel] = _tokens(_strip_frontmatter(text))
rels = sorted(contents)
pairs: list[dict[str, Any]] = []
for i in range(len(rels)):
for j in range(i + 1, len(rels)):
a, b = rels[i], rels[j]
ta, tb = token_sets[a], token_sets[b]
if not ta or not tb:
continue
# Cheap pre-filter: Jaccard lower bound before the full score.
inter = len(ta & tb)
union = len(ta | tb)
if union == 0 or inter / union < threshold * 0.6:
continue
score = similarity_score(contents[a], contents[b])
if score >= threshold:
pairs.append({"file_a": a, "file_b": b, "score": score})
pairs.sort(key=lambda p: p["score"], reverse=True)
return {
"vault": vault,
"threshold": threshold,
"files_scanned": len(contents),
"truncated": len(files) >= MAX_FILES_SCANNED,
"pairs": pairs[:limit],
}
def merge_duplicates(
vault: str,
source_path: str,
target_path: str,
strategy: str = "append",
) -> dict[str, Any]:
"""Merge *source_path* into *target_path*, then delete the source.
Strategies:
``append`` — source content appended after target (separator + origin
marker), source deleted.
``prefer_target`` — source deleted, target untouched (dedupe only).
``prefer_source`` — target overwritten with source content, source deleted.
A backup of both files is taken first; the source deletion also goes
through the backup-aware mutation service.
"""
from backend.services import mutations as _mutations
if strategy not in ("append", "prefer_target", "prefer_source"):
raise ServiceError(
f"Unknown strategy: {strategy}",
code="invalid_arguments",
status=400,
)
if source_path == target_path:
raise ServiceError(
"source_path and target_path must differ",
code="invalid_arguments",
status=400,
)
root = get_vault_root(vault)
src = resolve_safe_path(root, source_path)
dst = resolve_safe_path(root, target_path)
if not src.is_file() or src.suffix.lower() != ".md":
raise ServiceError(
f"Source not found: {source_path}",
code="not_found",
status=404,
details={"path": source_path},
)
if not dst.is_file() or dst.suffix.lower() != ".md":
raise ServiceError(
f"Target not found: {target_path}",
code="not_found",
status=404,
details={"path": target_path},
)
# Backup préalable (jamais de fusion sans filet — critère #166).
create_backup(src, vault, source_path)
create_backup(dst, vault, target_path)
if strategy == "prefer_target":
result = _mutations.delete_file(vault, source_path)
return {"strategy": strategy, "target": target_path, "deleted": source_path, "delete": result}
if strategy == "prefer_source":
content = src.read_text(encoding="utf-8", errors="replace")
result = _mutations.edit_file(vault, target_path, content)
deleted = _mutations.delete_file(vault, source_path)
return {"strategy": strategy, "target": target_path, "edit": result, "deleted": source_path, "delete": deleted}
# append
target_text = dst.read_text(encoding="utf-8", errors="replace")
source_text = src.read_text(encoding="utf-8", errors="replace")
merged = target_text.rstrip() + f"\n\n---\n\n_Fusionné depuis `{source_path}` (#166)_\n\n" + source_text.lstrip()
result = _mutations.edit_file(vault, target_path, merged)
deleted = _mutations.delete_file(vault, source_path)
return {"strategy": strategy, "target": target_path, "edit": result, "deleted": source_path, "delete": deleted}
+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),
}
+24
View File
@@ -102,6 +102,29 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
return {"vault": vault_name, "path": path, "items": items}
def _indexed_tags(vault_name: str, rel_path: str) -> list[str]:
"""Tags of a file as stored in the search index (#158).
Empty list when the file is not indexed yet (binary formats, index still
building) — the listing itself comes from the filesystem, tags are a
decoration used by the navigation page facets/filters.
Args:
vault_name: Vault name.
rel_path: Path of the file relative to the vault root (``/`` separated).
Returns:
The file's tags, or an empty list when unknown.
"""
try:
from backend.search import get_inverted_index
info = get_inverted_index().doc_info.get(f"{vault_name}::{rel_path}")
except Exception:
return []
return list((info or {}).get("tags") or [])
def list_all_files(
vault_name: str,
dir: str = "",
@@ -189,6 +212,7 @@ def list_all_files(
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"extension": ext.lstrip(".") if ext else "",
"tags": _indexed_tags(vault_name, rel_path),
}
if rel_to_dir and rel_to_dir != ".":
file_entry["rel_dir"] = rel_to_dir
+48 -39
View File
@@ -10,6 +10,7 @@ No authentication required for public share views.
import json
import logging
import secrets
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -17,6 +18,10 @@ logger = logging.getLogger("obsigate.share")
SHARES_FILE = Path("data/shares.json")
# ROADMAP #85 T10a — verrou autour des read-modify-write (perte de mises à
# jour en cas de créations/accès/révocations concurrents).
_lock = threading.RLock()
def _read() -> dict:
if not SHARES_FILE.exists():
@@ -41,26 +46,27 @@ def create_share(
expires_in_hours: int | None = None,
) -> dict:
"""Create a new share token for a document."""
data = _read()
token = secrets.token_hex(32) # 64-char hex token
with _lock:
data = _read()
token = secrets.token_hex(32) # 64-char hex token
expires_at = None
if expires_in_hours:
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
expires_at = None
if expires_in_hours:
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
share = {
"id": token,
"token": token,
"vault": vault,
"path": path,
"created_by": created_by,
"created_at": datetime.now(timezone.utc).isoformat(),
"expires_at": expires_at,
"access_count": 0,
"last_accessed": None,
}
data["shares"][token] = share
_write(data)
share = {
"id": token,
"token": token,
"vault": vault,
"path": path,
"created_by": created_by,
"created_at": datetime.now(timezone.utc).isoformat(),
"expires_at": expires_at,
"access_count": 0,
"last_accessed": None,
}
data["shares"][token] = share
_write(data)
logger.info(f"Created share for {vault}/{path} by {created_by}")
return share
@@ -80,22 +86,24 @@ def get_share_by_token(token: str) -> dict | None:
def record_access(token: str):
"""Increment access counter for a share."""
data = _read()
share = data["shares"].get(token)
if share:
share["access_count"] = share.get("access_count", 0) + 1
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
_write(data)
with _lock:
data = _read()
share = data["shares"].get(token)
if share:
share["access_count"] = share.get("access_count", 0) + 1
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
_write(data)
def revoke_share(share_id: str) -> bool:
"""Revoke (delete) a share by its token."""
data = _read()
if share_id in data["shares"]:
del data["shares"][share_id]
_write(data)
logger.info(f"Revoked share {share_id}")
return True
with _lock:
data = _read()
if share_id in data["shares"]:
del data["shares"][share_id]
_write(data)
logger.info(f"Revoked share {share_id}")
return True
return False
@@ -112,12 +120,13 @@ def list_shares(vault_filter: str | None = None) -> list:
def update_shares_after_rename(vault: str, old_path: str, new_path: str):
"""Update all shares when a file is renamed."""
data = _read()
updated = False
for sid, s in data["shares"].items():
if s.get("vault") == vault and s.get("path") == old_path:
s["path"] = new_path
updated = True
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
if updated:
_write(data)
with _lock:
data = _read()
updated = False
for sid, s in data["shares"].items():
if s.get("vault") == vault and s.get("path") == old_path:
s["path"] = new_path
updated = True
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
if updated:
_write(data)
+4
View File
@@ -12,7 +12,11 @@ which ``.gitignore`` excludes via ``_*.py``), hence this explicit facade.
from backend.tools import connected as _connected # noqa: F401 (registers connected-source tools)
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 duplicates as _duplicates # noqa: F401 (registers duplicate tools #166)
from backend.tools import notify as _notify_tools # noqa: F401 (registers notify tool #168)
from backend.tools import scheduled as _scheduled # noqa: F401 (registers scheduler tools #170)
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,
+7 -3
View File
@@ -18,8 +18,10 @@ import csv as csv_lib
import io
import logging
import re
from typing import Any
from xml.sax import saxutils
from typing import Any, cast
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
from xml.sax import saxutils # nosec B406
from backend.services.errors import ServiceError
from backend.services.mutations import save_raw_file
@@ -171,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
+59
View File
@@ -0,0 +1,59 @@
"""Duplicate detection & merge tools (#166).
* ``find_duplicates`` — READ, vault-scoped: candidate pairs with scores.
* ``merge_duplicate_notes`` — DANGEROUS: confirmed fusion with automatic
backup (service layer), never without an explicit approval.
"""
from __future__ import annotations
from typing import Any
from backend.services import duplicates as _duplicates
from backend.services.errors import ServiceError
from backend.tools.context import ToolContext, ToolError, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import FindDuplicatesInput, MergeDuplicatesInput
@tool(
name="find_duplicates",
description="Find candidate duplicate markdown notes in a vault (similarity scores).",
input_model=FindDuplicatesInput,
risk=ToolRisk.READ,
requires_vault=True,
)
def find_duplicates(ctx: ToolContext, params: FindDuplicatesInput) -> dict[str, Any]:
"""List duplicate candidates ordered by descending score."""
try:
return _duplicates.find_duplicate_pairs(
params.vault,
threshold=params.threshold,
limit=params.limit,
subdir=params.subdir,
)
except ServiceError as e:
raise ToolError(e.message, code=e.code, details=e.details) from e
@tool(
name="merge_duplicate_notes",
description=(
"Merge one note into another and delete the source (backup first). "
"Destructive: requires confirmation."
),
input_model=MergeDuplicatesInput,
risk=ToolRisk.DANGEROUS,
requires_vault=True,
)
def merge_duplicate_notes(ctx: ToolContext, params: MergeDuplicatesInput) -> dict[str, Any]:
"""Fuse *source_path* into *target_path* using the chosen strategy."""
try:
return _duplicates.merge_duplicates(
params.vault,
params.source_path,
params.target_path,
strategy=params.strategy,
)
except ServiceError as e:
raise ToolError(e.message, code=e.code, details=e.details) from e
+14
View File
@@ -52,9 +52,23 @@ _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"),
"find_duplicates": ("duplicates", "vault"),
"merge_duplicate_notes": ("duplicates_merge", "source_path"),
"notify_external": ("notify", "title"),
"create_scheduled_task": ("schedule_create", "name"),
"list_scheduled_tasks": ("schedule_list", None),
"delete_scheduled_task": ("schedule_delete", "task_id"),
"run_scheduled_task_now": ("schedule_run", "task_id"),
}
GENERIC_KEY = "generic"
+42
View File
@@ -0,0 +1,42 @@
"""External notification tool (#168) — Discord, Telegram, SMTP, webhook.
``notify_external`` is WRITE (external side effect → confirmation card in the
UI, propose/apply over MCP). Delivery itself lives in :mod:`backend.notify`.
"""
from __future__ import annotations
from typing import Any
from backend import notify as _notify
from backend.tools.context import ToolContext, ToolError, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import NotifyExternalInput
@tool(
name="notify_external",
description="Send a notification through external channels (Discord, Telegram, SMTP, webhook).",
input_model=NotifyExternalInput,
risk=ToolRisk.WRITE,
)
def notify_external(ctx: ToolContext, params: NotifyExternalInput) -> dict[str, Any]:
"""Broadcast to the trigger scope, or target a single channel id."""
try:
if params.channel_id:
channel = next(
(c for c in _notify._read_channels() if c.get("id") == params.channel_id),
None,
)
if channel is None:
raise ToolError(f"Unknown channel: {params.channel_id}", code="not_found")
if not channel.get("enabled", True):
raise ToolError(f"Channel disabled: {params.channel_id}", code="invalid_arguments")
_notify.send_via_channel(channel, params.title, params.message, params.trigger)
return {"ok": True, "channel_id": params.channel_id}
results = _notify.broadcast(params.trigger, params.title, params.message)
return {"ok": True, "deliveries": results}
except ToolError:
raise
except Exception as e:
raise ToolError(f"Notification failed: {e}", code="notify_failed") from e
+15 -2
View File
@@ -117,9 +117,22 @@ def list_tools(*, scope: ToolScope | None = None) -> list[ToolSpec]:
return specs
# ponytail: tool schemas are static after import (registration is decorator
# only); keying the cache on len(_REGISTRY) invalidates it if a tool is ever
# registered at runtime. Rebuilding 50 pydantic JSON schemas cost ~27 ms per
# agent/MCP request.
_SCHEMAS_CACHE: dict[Any, list[dict[str, Any]]] = {}
def get_tool_schemas(*, scope: ToolScope | None = None) -> list[dict[str, Any]]:
"""Return OpenAI-compatible schemas for registered tools."""
return [spec.openai_schema() for spec in list_tools(scope=scope)]
"""Return OpenAI-compatible schemas for registered tools (cached)."""
key = (scope, len(_REGISTRY))
cached = _SCHEMAS_CACHE.get(key)
if cached is None:
cached = [spec.openai_schema() for spec in list_tools(scope=scope)]
_SCHEMAS_CACHE.clear()
_SCHEMAS_CACHE[key] = cached
return [dict(s) for s in cached]
def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: bool, error: str | None = None) -> None:
+78
View File
@@ -0,0 +1,78 @@
"""Scheduled-task tools (#170) — the agent programs its own cron.
* ``create_scheduled_task`` — WRITE (a future write, confirmed once now).
* ``list_scheduled_tasks`` — READ.
* ``delete_scheduled_task`` — WRITE (removes a future side effect).
* ``run_scheduled_task_now`` — WRITE (immediate side effect).
"""
from __future__ import annotations
from typing import Any
from backend import scheduler as _scheduler
from backend.tools.context import ToolContext, ToolError, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import (
CreateScheduledTaskInput,
DeleteScheduledTaskInput,
ListVaultsInput,
RunScheduledTaskInput,
)
@tool(
name="create_scheduled_task",
description="Program an automatic task (create_file, append_to_file, notify) on a cron-like schedule.",
input_model=CreateScheduledTaskInput,
risk=ToolRisk.WRITE,
)
def create_scheduled_task(ctx: ToolContext, params: CreateScheduledTaskInput) -> dict[str, Any]:
"""Create a task owned by the requesting user."""
try:
return _scheduler.create_task(
params.name,
params.action,
params.schedule,
created_by=ctx.username,
)
except ValueError as e:
raise ToolError(str(e), code="invalid_arguments") from e
@tool(
name="list_scheduled_tasks",
description="List automatic tasks programmed in ObsiGate.",
input_model=ListVaultsInput,
risk=ToolRisk.READ,
)
def list_scheduled_tasks(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]:
"""Return tasks newest first."""
return _scheduler.list_tasks()
@tool(
name="delete_scheduled_task",
description="Delete a programmed automatic task.",
input_model=DeleteScheduledTaskInput,
risk=ToolRisk.WRITE,
)
def delete_scheduled_task(ctx: ToolContext, params: DeleteScheduledTaskInput) -> dict[str, Any]:
"""Delete by id; unknown id is a not_found tool error."""
if not _scheduler.delete_task(params.task_id):
raise ToolError(f"Unknown task: {params.task_id}", code="not_found")
return {"ok": True, "task_id": params.task_id}
@tool(
name="run_scheduled_task_now",
description="Execute a programmed task immediately (manual run).",
input_model=RunScheduledTaskInput,
risk=ToolRisk.WRITE,
)
def run_scheduled_task_now(ctx: ToolContext, params: RunScheduledTaskInput) -> dict[str, Any]:
"""Run now and return the outcome (failures are recorded + notified)."""
try:
return _scheduler.run_task(params.task_id, manual=True)
except KeyError as e:
raise ToolError(f"Unknown task: {params.task_id}", code="not_found") from e
+151
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."""
@@ -337,6 +441,53 @@ class PdfInput(BaseModel):
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
class FindDuplicatesInput(BaseModel):
"""Find candidate duplicate notes in a vault (#166)."""
vault: str = Field(..., description="Vault name")
threshold: float = Field(0.75, ge=0.3, le=1.0, description="Minimum similarity score")
limit: int = Field(20, ge=1, le=200, description="Maximum number of pairs")
subdir: str = Field("", description="Vault-relative directory scope (empty = whole vault)")
class MergeDuplicatesInput(BaseModel):
"""Merge one note into another, then delete the source (#166, destructive)."""
vault: str = Field(..., description="Vault name")
source_path: str = Field(..., description="Vault-relative path of the note to absorb")
target_path: str = Field(..., description="Vault-relative path of the surviving note")
strategy: str = Field("append", description="'append', 'prefer_target' or 'prefer_source'")
class NotifyExternalInput(BaseModel):
"""Send a notification through external channels (#168)."""
title: str = Field(..., min_length=1, description="Notification title")
message: str = Field(..., min_length=1, description="Notification body")
trigger: str = Field("manual", description="Trigger scope: manual, schedule_failure, schedule_success")
channel_id: str = Field("", description="Single channel id (empty = broadcast to trigger)")
class CreateScheduledTaskInput(BaseModel):
"""Create an automatic task executed by the scheduler (#170)."""
name: str = Field(..., min_length=1, description="Task display name")
action: dict[str, Any] = Field(..., description="{kind, params} (create_file, append_to_file, notify)")
schedule: dict[str, Any] = Field(..., description="{kind, ...} (interval_hours, daily_time, once_at)")
class DeleteScheduledTaskInput(BaseModel):
"""Delete a scheduled task by id (#170)."""
task_id: str = Field(..., min_length=1, description="Task id")
class RunScheduledTaskInput(BaseModel):
"""Execute a scheduled task immediately (#170)."""
task_id: str = Field(..., min_length=1, description="Task id")
class ToolResult(BaseModel):
"""Uniform result returned by :func:`backend.tools.registry.call_tool`."""
+17 -11
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
logger = logging.getLogger("obsigate.tools.secrets")
@@ -34,6 +35,9 @@ TOOL_KEY_NAMES: tuple[str, ...] = (
_SECRET_MARKERS = ("API_KEY", "TOKEN")
# ROADMAP #85 T10a — verrou autour des read-modify-write du store de clés.
_lock = threading.RLock()
def _keys_file() -> Path:
base = os.environ.get("OBSIGATE_DATA_DIR", "data")
@@ -89,21 +93,23 @@ def set_tool_key(name: str, value: str) -> None:
if name not in TOOL_KEY_NAMES:
raise ValueError(f"Clé non prise en charge: {name}")
value = (value or "").strip()
keys = _read_keys()
if value:
keys[name] = value
else:
keys.pop(name, None)
_write_keys(keys)
with _lock:
keys = _read_keys()
if value:
keys[name] = value
else:
keys.pop(name, None)
_write_keys(keys)
def delete_tool_key(name: str) -> bool:
"""Remove one key from the store; return True when it existed."""
if name not in TOOL_KEY_NAMES:
raise ValueError(f"Clé non prise en charge: {name}")
keys = _read_keys()
if name in keys:
del keys[name]
_write_keys(keys)
return True
with _lock:
keys = _read_keys()
if name in keys:
del keys[name]
_write_keys(keys)
return True
return False
+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),
}
+3 -2
View File
@@ -22,7 +22,7 @@ Exemples :
from __future__ import annotations
import os
import subprocess
import subprocess # nosec B404
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent # racine du dépôt ObsiGate
@@ -34,7 +34,8 @@ _ENV_VAR = "OBSIGATE_VERSION"
def _run_git(args: list[str]) -> str:
"""Run a git command in the repo root; return stdout (stripped) or ''."""
try:
result = subprocess.run(
# argv fixe (git + args internes), sans shell : pas d'injection.
result = subprocess.run( # nosec B404 B603 B607
["git", *args],
cwd=str(_ROOT),
capture_output=True,
+2 -1
View File
@@ -280,7 +280,8 @@ class VaultWatcher:
for observer in self.observers.values():
try:
observer.join(timeout=5)
except Exception: # nosec B110 — best-effort shutdown, ignore failures
# best-effort shutdown, ignore failures (B110) :
except Exception: # nosec B110
pass
self.observers.clear()
logger.info("VaultWatcher stopped")
+28
View File
@@ -0,0 +1,28 @@
"""Shared VaultWatcher handle (ROADMAP #85, tranche 8).
Holder extrait de :mod:`backend.main` sans changement de comportement : le
lifespan de ``main`` y dépose l'instance (``set_watcher``) et l'y reprend à
l'extinction ; le router ``vaults`` la consulte via :func:`get_watcher`
(démarrage/arrêt de surveillance à l'ajout/retrait dynamique de vault,
état dans ``/api/vaults/status``).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backend.watcher import VaultWatcher
_watcher: VaultWatcher | None = None
def get_watcher() -> VaultWatcher | None:
"""Return the shared VaultWatcher instance (``None`` if disabled)."""
return _watcher
def set_watcher(watcher: VaultWatcher | None) -> None:
"""Store (or clear) the shared VaultWatcher instance."""
global _watcher
_watcher = watcher
+54 -43
View File
@@ -26,6 +26,7 @@ import json
import logging
import os
import socket
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -144,6 +145,12 @@ def _read_secrets() -> dict:
return {}
# ROADMAP #85 T10a — verrou autour des read-modify-write des deux stores
# (webhooks + secrets) : perte de mises à jour en cas de mutations
# concurrentes.
_lock = threading.RLock()
def _write_secrets(secrets: dict):
WEBHOOK_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = WEBHOOK_SECRETS_FILE.with_suffix(".tmp")
@@ -156,12 +163,13 @@ def _write_secrets(secrets: dict):
def _store_secret(wh_id: str, secret: str | None) -> None:
secrets = _read_secrets()
if secret:
secrets[wh_id] = secret
else:
secrets.pop(wh_id, None)
_write_secrets(secrets)
with _lock:
secrets = _read_secrets()
if secret:
secrets[wh_id] = secret
else:
secrets.pop(wh_id, None)
_write_secrets(secrets)
def _get_secret(wh: dict) -> str | None:
@@ -189,52 +197,55 @@ def get_webhooks() -> list:
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
validate_webhook_url(url)
webhooks = _read()
wh_id = str(uuid.uuid4())
wh = {
"id": wh_id,
"name": name,
"url": url,
"events": [e for e in events if e in VALID_EVENTS],
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_fired_at": None,
}
webhooks.append(wh)
_write(webhooks)
if secret:
_store_secret(wh_id, secret)
with _lock:
webhooks = _read()
wh_id = str(uuid.uuid4())
wh = {
"id": wh_id,
"name": name,
"url": url,
"events": [e for e in events if e in VALID_EVENTS],
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_fired_at": None,
}
webhooks.append(wh)
_write(webhooks)
if secret:
_store_secret(wh_id, secret)
logger.info(f"Created webhook '{name}' → {url}")
return _public_view(wh)
def update_webhook(wh_id: str, updates: dict) -> dict | None:
webhooks = _read()
for wh in webhooks:
if wh["id"] == wh_id:
if updates.get("url"):
validate_webhook_url(updates["url"])
if "secret" in updates:
_store_secret(wh_id, updates["secret"])
safe_updates = {
k: v for k, v in updates.items()
if k not in ("id", "secret")
}
wh.update(safe_updates)
_write(webhooks)
return _public_view(wh)
with _lock:
webhooks = _read()
for wh in webhooks:
if wh["id"] == wh_id:
if updates.get("url"):
validate_webhook_url(updates["url"])
if "secret" in updates:
_store_secret(wh_id, updates["secret"])
safe_updates = {
k: v for k, v in updates.items()
if k not in ("id", "secret")
}
wh.update(safe_updates)
_write(webhooks)
return _public_view(wh)
return None
def delete_webhook(wh_id: str) -> bool:
webhooks = _read()
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
if len(new_list) == len(webhooks):
return False
_write(new_list)
secrets = _read_secrets()
if secrets.pop(wh_id, None) is not None:
_write_secrets(secrets)
with _lock:
webhooks = _read()
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
if len(new_list) == len(webhooks):
return False
_write(new_list)
secrets = _read_secrets()
if secrets.pop(wh_id, None) is not None:
_write_secrets(secrets)
return True
+970 -15
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -115,7 +115,9 @@ $env:VERSION = $Version
Write-Info "Version : $Version"
# ----- Build the image -----
$BuildArgs = @("-f", $ComposeFile)
# NOTE : `--no-cache` est un flag de `docker compose build` (après `build`),
# pas un flag global (avant) — sinon `unknown flag: --no-cache`.
$BuildArgs = @("-f", $ComposeFile, "build")
if (-not $UseCache) {
$BuildArgs += "--no-cache"
Write-Info "Construction de l'image Docker (sans cache)..."
@@ -123,7 +125,7 @@ if (-not $UseCache) {
Write-Info "Construction de l'image Docker (avec cache)..."
}
docker compose @BuildArgs build
docker compose @BuildArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Échec de la construction de l'image Docker."
exit $LASTEXITCODE
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.27.9"
version = "2.53.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.27.9"
version = "2.53.2"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+2
View File
@@ -38,5 +38,7 @@ fn main() {
println!("cargo:rerun-if-changed=.git/refs/heads/main");
println!("cargo:rerun-if-changed=.git/refs/tags");
println!("cargo:rerun-if-changed=permissions");
tauri_build::build()
}
+1
View File
@@ -7,6 +7,7 @@
},
"permissions": [
"core:default",
"allow-app-commands",
"shell:allow-open",
"shell:allow-execute",
"dialog:default",
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","allow-app-commands","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
+6
View File
@@ -2210,6 +2210,12 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
"type": "string",
"const": "allow-app-commands",
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+6
View File
@@ -2210,6 +2210,12 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
"type": "string",
"const": "allow-app-commands",
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+36
View File
@@ -0,0 +1,36 @@
# Commandes applicatives ObsiGate — autorisées depuis l'origine distante.
#
# La fenêtre Tauri redirige vers http://127.0.0.1:<port> (page servie par le
# backend Python). Pour Tauri v2 c'est une origine *remote* : sans entrée ACL
# explicite, TOUTE commande de l'app (invoke_handler) est rejetée avec
# "Command ... not allowed by ACL" — le bouton « Choisir mon dossier » et le
# wizard ne faisaient alors rien (erreurs avalées par desktop.js:invoke()).
#
# Auto-généré en… non : édité à la main. Liste = tauri::generate_handler![…]
# dans src/main.rs — ajouter toute nouvelle commande ici.
[[permission]]
identifier = "allow-app-commands"
description = "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
commands.allow = [
"get_backend_url",
"get_version",
"get_config",
"save_vault_path",
"get_vault_path",
"get_wizard_state",
"complete_wizard",
"pick_vault_folder",
"pick_folder",
"get_system_theme",
"restart_backend",
"check_backend_health",
"save_window_state",
"get_window_state",
"add_vault",
"remove_vault",
"list_vaults",
"add_dir",
"remove_dir",
"list_dirs",
]
+19 -43
View File
@@ -10,48 +10,23 @@ use log::{info, warn};
#[cfg(target_os = "windows")]
mod imp {
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, HSTRING, PCWSTR};
use windows::Win32::Foundation::PROPERTYKEY;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
use windows::Win32::System::Variant::VT_LPWSTR;
use windows::Win32::UI::Shell::{
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
DestinationList, EnumerableObjectCollection, ShellLink,
};
use windows::Win32::UI::Shell::Common::IObjectCollection;
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, HSTRING, PCWSTR};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::Shell::{
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
DestinationList, EnumerableObjectCollection, ShellLink,
};
use windows::Win32::UI::Shell::Common::IObjectCollection;
const APP_ID: &str = "com.obsigate.desktop";
const APP_ID: &str = "com.obsigate.desktop";
// PKEY_Title (System.Title): {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, pid 2
const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
fmtid: windows::core::GUID::from_u128(0xF29F85E0_4FF9_1068_AB91_08002B27B3D9),
pid: 2,
};
fn wide_null_terminated(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
/// Set the jump-list display title via the shell link's property store.
fn set_link_title(link: &IShellLinkW, title: &str) -> windows::core::Result<()> {
let store: IPropertyStore = link.cast()?;
let mut wide = wide_null_terminated(title);
let mut pv: PROPVARIANT = unsafe { std::mem::zeroed() };
unsafe {
let inner = &mut *pv.Anonymous.Anonymous;
inner.vt = VT_LPWSTR;
inner.Anonymous.pwszVal = windows::core::PWSTR(wide.as_mut_ptr());
store.SetValue(&PKEY_TITLE, &pv)?;
store.Commit()?;
}
Ok(())
}
fn wide_null_terminated(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
/// Build one shell link for a vault. `arg` is the command-line argument the
/// app expects to open that vault (see file associations / single-instance).
@@ -70,9 +45,10 @@ mod imp {
}
link.SetDescription(&HSTRING::from(name))?;
}
// Title (display name) is best-effort — a missing title only means the
// jump-list entry falls back to the executable name.
let _ = set_link_title(&link, name);
// Title (display name): the raw PROPVARIANT setter that used to live
// here corrupted the process heap (STATUS_HEAP_CORRUPTION c0000374 —
// the shell property store freed our Rust-allocated VT_LPWSTR buffer).
// SetDescription below already gives jump-list entries their label.
Ok(link)
}
+202 -18
View File
@@ -48,6 +48,7 @@ fn pick_free_port() -> u16 {
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use tauri::Manager;
use tauri::webview::WebviewWindowBuilder;
// ── State ───────────────────────────────────────────────────────────────────
@@ -160,6 +161,39 @@ fn save_config(config: &AppConfig) {
}
}
// ── Premier lancement (#160) ───────────────────────────────────────────────
/// Répertoire de démarrage monté comme vault au premier lancement.
const STARTER_VAULT_NAME: &str = "ObsiGate";
/// Document d'accueil écrit (si absent) dans le répertoire de démarrage.
const WELCOME_DOC_NAME: &str = "Prise en main.md";
const WELCOME_DOC: &str = include_str!("prise_en_main.md");
/// Config par défaut au premier lancement — pure, sans I/O (testable).
/// `<home>/ObsiGate` est monté comme vault ET comme contexte initial ; le
/// dossier home reste une racine, nommée d'après son dernier segment
/// (fin du « bruno » codé en dur, #160).
fn default_first_run_config(home: &str) -> AppConfig {
let home_path = std::path::Path::new(home);
let vault_dir = home_path.join("ObsiGate");
let vault_path = vault_dir.to_string_lossy().to_string();
AppConfig {
vault_path: Some(vault_path.clone()),
vaults: vec![VaultConfig {
name: STARTER_VAULT_NAME.to_string(),
path: vault_path,
}],
dirs: vec![DirConfig {
name: home_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| home.to_string()),
path: home.to_string(),
}],
..AppConfig::default()
}
}
// ── Backend lifecycle ───────────────────────────────────────────────────────
fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
@@ -320,6 +354,18 @@ async fn pick_vault_folder(app: tauri::AppHandle) -> Result<String, String> {
}
}
/// Sélecteur de dossier générique, sans effet de bord (contrairement à
/// `pick_vault_folder` qui écrit `vault_path` + `wizard_done` pour le wizard).
/// Sert à l'ajout d'un dossier racine depuis la section Configuration (#159).
#[tauri::command]
async fn pick_folder(app: tauri::AppHandle) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
match app.dialog().file().blocking_pick_folder() {
Some(p) => Ok(p.to_string()),
None => Err("No folder selected".to_string()),
}
}
#[tauri::command]
fn get_wizard_state() -> bool {
load_config().wizard_done
@@ -343,6 +389,13 @@ fn get_system_theme(app: tauri::AppHandle) -> String {
}
}
/// La jump list des raccourcis suit les vaults : à rafraîchir après tout
/// ajout/retrait de vault (#159). Best-effort — `update_jumplist` logge et n'échoue jamais.
fn refresh_jumplist() {
let vaults = load_config().vaults;
jumplist::update_jumplist(&jumplist::build_vault_args(&vaults));
}
#[tauri::command]
fn add_vault(name: String, path: String) -> Result<(), String> {
let mut config = load_config();
@@ -350,6 +403,7 @@ fn add_vault(name: String, path: String) -> Result<(), String> {
config.vaults.retain(|v| v.name != name);
config.vaults.push(VaultConfig { name: name.clone(), path });
save_config(&config);
refresh_jumplist();
info!("Vault added: {} — restart backend to apply", name);
Ok(())
}
@@ -359,6 +413,7 @@ fn remove_vault(name: String) -> Result<(), String> {
let mut config = load_config();
config.vaults.retain(|v| v.name != name);
save_config(&config);
refresh_jumplist();
info!("Vault removed: {} — restart backend to apply", name);
Ok(())
}
@@ -513,7 +568,7 @@ fn main() {
// Load config for window state
let mut config = load_config();
// First run: create sensible defaults per platform
// First run: create sensible defaults per platform (#160)
if config.vaults.is_empty() {
info!("First run — creating default vault configuration");
#[cfg(target_os = "windows")]
@@ -521,25 +576,28 @@ fn main() {
#[cfg(not(target_os = "windows"))]
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/bruno".to_string());
let vault_dir = std::path::PathBuf::from(&home).join("voute_obsidian");
// Create the vault directory if it doesn't exist
if !vault_dir.exists() {
if let Err(e) = fs::create_dir_all(&vault_dir) {
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
} else {
info!("Created default vault dir: {}", vault_dir.display());
// Montage du répertoire de démarrage (vault + contexte initial) sans
// toucher aux autres champs (taille/position de fenêtre) de la config.
let defaults = default_first_run_config(&home);
config.dirs = defaults.dirs;
config.vaults = defaults.vaults;
config.vault_path = defaults.vault_path;
let vault_dir = std::path::PathBuf::from(config.vaults[0].path.clone());
if let Err(e) = fs::create_dir_all(&vault_dir) {
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
} else {
info!("Created default vault dir: {}", vault_dir.display());
}
// Document d'accueil : écrit une seule fois, jamais écrasé (#160).
let welcome = vault_dir.join(WELCOME_DOC_NAME);
if !welcome.exists() {
match fs::write(&welcome, WELCOME_DOC) {
Ok(()) => info!("Welcome document written: {}", welcome.display()),
Err(e) => error!("Failed to write {}: {}", welcome.display(), e),
}
}
config.dirs = vec![
DirConfig { name: "bruno".to_string(), path: home.clone() },
];
config.vaults = vec![
VaultConfig {
name: "Obsidian".to_string(),
path: vault_dir.to_string_lossy().to_string(),
},
];
config.vault_path = Some(home);
save_config(&config);
info!("Default vault config saved");
}
@@ -589,6 +647,7 @@ fn main() {
get_wizard_state,
complete_wizard,
pick_vault_folder,
pick_folder,
get_system_theme,
restart_backend,
check_backend_health,
@@ -602,6 +661,19 @@ fn main() {
list_dirs,
])
.setup(move |app| {
// BUG-107 / #89 — HTML5 drag & drop des fichiers : Tauri installe
// son propre IDropTarget par-dessus celui du WebView2 (wry :
// « find the WebView2 window and override! ») et aucun événement
// natif n'est écouté ici → les glisser-déposer depuis
// l'Explorateur n'atteignaient jamais la page. La fenêtre est donc
// créée manuellement (`create: false` dans tauri.conf.json) avec
// le handler désactivé : la page reçoit les drops comme sur le web.
let window_config = app.config().app.windows[0].clone();
WebviewWindowBuilder::from_config(app, &window_config)
.expect("fenêtre principale (tauri.conf.json)")
.disable_drag_drop_handler()
.build()?;
// Build native menu bar (File / Edit / Help)
let _ = build_native_menu(app);
@@ -1122,4 +1194,116 @@ mod tests {
let dbg = format!("{:?}", d);
assert!(dbg.contains("x"));
}
/// Guardrail ACL : toute commande Tauri invoquée par le frontend doit
/// figurer dans desktop/permissions/commands.toml. La page servie par le
/// backend (http://127.0.0.1) est une origine *remote* pour Tauri v2 :
/// sans cette entrée, la commande est rejetée « not allowed by ACL » et
/// desktop.js:invoke() avale l'erreur → bouton silencieusement mort
/// (bouton « Choisir mon dossier » du wizard, BUG-104).
#[test]
fn test_frontend_invokes_are_acl_allowed() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let perms = fs::read_to_string(dir.join("permissions/commands.toml"))
.expect("desktop/permissions/commands.toml manquant");
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
// Commandes déclarées dans le manifeste de permissions.
let allow_list = perms
.split("commands.allow")
.nth(1)
.and_then(|s| s.split(']').next())
.unwrap_or("");
let allowed: Vec<String> = allow_list
.split('"')
.skip(1)
.step_by(2)
.map(|s| s.to_string())
.collect();
assert!(!allowed.is_empty(), "commands.allow vide dans commands.toml");
// Chaque permission doit correspondre à une commande réellement
// enregistrée (faute de frappe → permission morte).
for cmd in &allowed {
assert!(
main_src.contains(&format!("fn {cmd}(")),
"permission allow pour la commande inconnue {cmd}"
);
}
// Toute commande invoke('…') du frontend doit être autorisée.
let js_dir = dir.join("../frontend/js");
let mut seen = 0usize;
for entry in fs::read_dir(&js_dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("js") { continue; }
let src = fs::read_to_string(&path).unwrap();
let mut rest = src.as_str();
while let Some(pos) = rest.find("invoke('") {
rest = &rest[pos + "invoke('".len()..];
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if !name.is_empty() {
seen += 1;
assert!(
allowed.contains(&name),
"commande {name} invoquée par {} absente de permissions/commands.toml",
path.file_name().unwrap().to_string_lossy()
);
}
rest = &rest[rest.find('\'').map(|i| i + 1).unwrap_or(rest.len())..];
}
}
assert!(seen > 0, "aucun invoke('…') trouvé dans frontend/js");
}
/// BUG-107 : la fenêtre est créée en code (`create: false`) avec le
/// handler de drag & drop de Tauri désactivé. Si l'un des deux manque,
/// wry réinstalle son IDropTarget par-dessus celui du WebView2 et les
/// glisser-déposer de fichiers depuis l'Explorateur n'atteignent plus la
/// page (#89 fonctionnait uniquement sur le web).
#[test]
fn test_window_created_without_tauri_drag_drop_handler() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let conf = fs::read_to_string(dir.join("tauri.conf.json")).unwrap();
assert!(
conf.contains("\"create\": false"),
"tauri.conf.json : la fenêtre doit être créée en code (create: false)"
);
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
assert!(
main_src.contains("disable_drag_drop_handler()"),
"main.rs : disable_drag_drop_handler() manquant — drag & drop bloqué"
);
}
/// #160 — premier lancement : `<home>/ObsiGate` monté comme vault et
/// comme contexte initial, racine home nommée d'après son chemin.
#[test]
fn test_default_first_run_config() {
#[cfg(target_os = "windows")]
let home = r"C:\Users\alice";
#[cfg(not(target_os = "windows"))]
let home = "/home/alice";
let c = default_first_run_config(home);
assert_eq!(c.vaults.len(), 1);
assert_eq!(c.vaults[0].name, STARTER_VAULT_NAME);
assert!(c.vaults[0].path.ends_with("ObsiGate"), "{}", c.vaults[0].path);
assert_eq!(c.vault_path.as_deref(), Some(c.vaults[0].path.as_str()));
assert_eq!(c.dirs.len(), 1);
assert_eq!(c.dirs[0].name, "alice", "nom de racine dérivé du chemin");
assert_eq!(c.dirs[0].path, home);
assert!(!c.wizard_done);
}
/// #160 — le document d'accueil est embarqué dans le binaire : non vide,
/// Markdown, et nom de fichier stable (écrit une seule fois sur le disque).
#[test]
fn test_welcome_doc_embedded() {
assert!(WELCOME_DOC.len() > 500, "document d'accueil trop court");
assert!(WELCOME_DOC.starts_with('#'), "attendu : titre Markdown");
assert!(WELCOME_DOC.contains("ObsiGate"));
assert!(WELCOME_DOC.contains("Ctrl+Espace"));
assert_eq!(WELCOME_DOC_NAME, "Prise en main.md");
}
}
+41
View File
@@ -0,0 +1,41 @@
# 🟢 Bienvenue sur ObsiGate — Prise en main
Ce dossier `%USERPROFILE%\ObsiGate` est votre **espace de départ** : il est
monté automatiquement comme vault **ObsiGate** au premier lancement. Vous
pouvez y écrire librement, puis brancher vos vrais vaults Obsidian quand vous
êtes prêt.
## ObsiGate en 30 secondes
ObsiGate est une porte d'entrée locale vers vos notes : index en mémoire,
recherche instantanée, lecture Markdown avec liens `[[wikilinks]]`, images,
PDF, tableurs Excel, Excalidraw, Mermaid et assistant IA — sans base de
données, sans modification de vos fichiers, tout reste sur votre machine.
## Premiers pas (application desktop)
1. **Ce document** s'affiche dans la visionneuse : c'est votre première note,
modifiez-la ou supprimez-la quand vous n'en avez plus besoin.
2. **Ajouter vos vaults** : Configuration → « 🖥️ Vaults & dossiers (Desktop) »
→ *Ajouter un vault* (sélecteur de dossier natif). Depuis la sidebar,
clic droit sur une racine → « Retirer de l'application » la désiste
(aucun fichier n'est supprimé).
3. **Naviguer** : la sidebar liste vos racines et leur arbre ; l'onglet
Navigation parcourt un dossier sous forme de vues répertoire.
4. **Rechercher** : `Ctrl+Espace` (palette de fichiers),
`Ctrl+Alt+Espace` (palette de commandes), `Ctrl+F` dans un document.
5. **Onglets** : `Ctrl+W` ferme l'onglet, `Ctrl+Tab` bascule ; le mode split
divise l'écran en panneaux.
6. **Guide complet** : bouton d'aide de l'en-tête — sommaire, raccourcis,
architecture, et téléchargement du guide en Markdown/PDF.
## Le saviez-vous ?
- Le **glisser-déposer** de fichiers depuis l'Explorateur est pris en charge :
déposez-les sur la fenêtre pour les importer.
- Le **clic droit** est partout : menus contextuels de fichier, dossier et
vault (avec renommage, création, suppression, vue graphique).
- Le **watcher** surveille vos vaults : une note modifiée dans Obsidian
apparaît immédiatement ici.
— *Ce fichier vous appartient.*
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.27.9",
"version": "2.53.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
@@ -13,6 +13,7 @@
"withGlobalTauri": true,
"windows": [
{
"create": false,
"title": "ObsiGate",
"width": 1200,
"height": 800,
+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
View File
@@ -235,6 +235,9 @@ Gestion :
| `/api/conflicts` · `/api/conflicts/resolve` | Conflits Syncthing | GET/POST |
| `/api/plugins` | Installer / activer / désactiver | GET/POST/DELETE |
| `/api/push/*` | Abonnement Web Push (VAPID) | GET/POST/DELETE |
| `/api/duplicates` · `/api/duplicates/merge` | Doublons : paires candidates, fusion (`confirm: true`) | GET/POST |
| `/api/notify/channels` · `/api/notify/test` | Notifications Discord/Telegram/SMTP/webhook (CRUD admin + test) | GET/POST/PATCH/DELETE |
| `/api/scheduler/tasks` · `/api/scheduler/tasks/{id}/run` | Tâches planifiées (CRUD + exécution manuelle) | GET/POST/PATCH/DELETE |
---
+13
View File
@@ -182,10 +182,23 @@ partagée par l'assistant in-app et le serveur MCP.
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
| Web / sources connectées | `web_search`, `fetch_url`, sources Gitea/GitHub… |
| Doublons (#166) | `find_duplicates` (lecture), `merge_duplicate_notes` (destructif) |
| Notifications (#168) | `notify_external` (Discord, Telegram, SMTP, webhook) |
| Tâches planifiées (#170) | `create/list/delete/run_scheduled_task*` |
Les mutations suivent un flux **two-step** : `propose_<tool>` renvoie un aperçu
et un **jeton signé à usage unique**, puis `apply_<tool>` exécute.
Exemples de demandes à l'assistant (mode agent) :
- « Trouve les notes en double dans ce vault » → `find_duplicates`, puis
« fusionne `brouillon.md` dans `rapport.md` » → `merge_duplicate_notes`
(backup automatique, approbation requise).
- « Préviens-moi sur Discord quand la tâche échoue » → `notify_external`
(canaux configurés via `POST /api/notify/channels`, déclencheurs au choix).
- « Crée une note de veille chaque matin à 8h » → `create_scheduled_task`
(`daily_time 08:00`, action `create_file` ou `append_to_file`).
---
## 7. Sécurité
+26
View File
@@ -23,6 +23,32 @@ commande**.
| Icône de barre des tâches (tray) | ❌ | ✅ |
| Auto-update | ❌ | ✅ (vérifie les releases Gitea) |
| Mode hors-ligne | Limité | Complet (backend local) |
| Gestion vaults & dossiers | Fichiers de config serveur | ✅ UI dédiée + menu contextuel |
### Gestion des vaults et dossiers (#159)
Deux surfaces, réservées à l'application desktop :
- **Menu contextuel** : clic droit sur un vault ou un dossier racine dans la
sidebar → « Retirer de l'application » (confirmation, déregistration de la
configuration locale — **aucun fichier n'est supprimé**), puis
redémarrage automatique du backend.
- **Configuration** : section « 🖥️ Vaults & dossiers (Desktop) » listant les
vaults et dossiers chargés, avec boutons « Ajouter un vault » (sélecteur de
dossier natif) et « Ajouter un dossier ».
### Premier lancement (#160)
Au tout premier démarrage (aucun vault configuré), l'application :
1. crée et monte **`%USERPROFILE%\ObsiGate`** comme vault de démarrage
(« ObsiGate ») et ouvre l'interface dessus ;
2. y écrit le document **`Prise en main.md`** — présentation, premières
étapes, raccourcis (`Ctrl+Espace`, `Ctrl+Alt+Espace`, `Ctrl+F`,
`Ctrl+W`/`Ctrl+Tab`) et accès au guide complet (bouton d'aide de l'en-tête).
Le fichier n'est jamais réécrit : modifiez-le ou supprimez-le librement ;
3. ajoute votre dossier personnel comme seconde racine (nommée d'après son
chemin).
---
+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 |
+239 -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,240 @@ 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. Chaque entrée porte une **icône** ; le menu se referme dès qu'il perd
le focus (clic ailleurs, `Échap`) et se parcourt au clavier (`↑`/`↓`).
**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 +381,7 @@ Fiche technique : [`features/excalidraw.md`](../features/excalidraw.md).
---
## 7. Autres contenus riches
## 8. Autres contenus riches
### Mermaid
@@ -182,7 +416,7 @@ curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes"
---
## 8. Dépannage
## 9. Dépannage
| Symptôme | Piste |
|---|---|
+58 -3
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,13 +188,38 @@ 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`) | 🟢 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` |
| *BUG-100* | Retour à l'accueil incomplet : le clic sur le titre perd la section « Raccourcis & Astuces » et laisse la recherche globale ainsi que le filtre de la sidebar actifs | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`showWelcome`), `frontend/js/legacy.js` (`goHome`) | Ouvrir un fichier (le dashboard est détruit), puis cliquer le titre ObsiGate | `viewer.js` : `#quick-help` capturé au chargement du module et réinjecté dans le template reconstruit de `showWelcome()` ; `legacy.js::goHome` clique `#search-clear-btn` (chips + barre de résultats) et `#sidebar-filter-clear-btn` avant d'afficher l'accueil | **Cause racine :** `showWelcome()` reconstruit `#dashboard-home` depuis un template qui n'embarque pas le bloc `#quick-help` présent dans `index.html` — il ne disparaissait qu'après la première reconstruction (ouverture de fichier, puis retour). Livré avec #158 A3 |
| *BUG-101* | Raccourcis d'onglets déclarés deux fois : un seul `Ctrl+W` fermait deux onglets (le second fermait celui ré-activé par la fermeture précédente) et `Ctrl+Tab` sautait un onglet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (bloc « Keyboard shortcuts for tabs » dupliqué en fin de fichier) | Ouvrir deux onglets puis `Ctrl+W` une fois | Suppression du second bloc `document.addEventListener("keydown", …)` dupliqué (fin de `ui.js`) — un seul listener reste | Exposé par #158 : le second onglet n'était plus jamais vide auparavant (la 2ᵉ fermeture tombait sur `_activeTabId === null` et devenait sans effet). Vérifié : repro Playwright (`closes: ["fichier", "nav"]` avant → `["fichier"]` après) + suite E2E |
| *BUG-102* | Menu contextuel qui se referme ~10 ms après son ouverture : la reflow des icônes (remplacement `<i>` → `<svg>` par lucide) déclenche un événement `scroll` capturé par `ContextMenuManager` qui referme le menu | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/ui.js` (`ContextMenuManager.init/show`) | Clic droit sur un fichier de la page de navigation (ou de l'arbre) juste après un rendu d'icônes | `show()` mémorise `_shownAt` ; le listener `scroll` (capture) ignore un déclenchement dans les 250 ms suivant l'ouverture | Exposé par #158 A5 (test E2E `nav-tab.spec.js` : menu résolu « hidden » alors que 6 items étaient construits). Vérifié : instrumentation Playwright (display block conservé après le scroll) + suite E2E |
| *BUG-103* | Mobile : le bas du sidebar de navigation est masqué par la barre d'outils du bas (`z-index` 200 < 900) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` (règle `.sidebar` du bloc `@media (max-width: 768px)`) | Vue mobile (≤768 px), sidebar ouvert depuis « Explorateur » | Passé à `z-index: 950` (au-dessus de `.mobile-toolbar`, 900) | Exposé après #158 (retour utilisateur). Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`) |
| *BUG-104* | Desktop : toutes les commandes Tauri invoquées depuis la page backend (`http://127.0.0.1`) rejetées « not allowed by ACL » — le bouton « Choisir mon dossier » du wizard ne fait rien | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | Installeur 2.49.2 : clic sur « Choisir mon dossier » → aucune fenêtre ; console `[desktop] invoke(pick_vault_folder) failed: Command … not allowed by ACL` (capturée via CDP, WebView2 `--remote-debugging-port`) | Manifeste de permissions applicatives créé (`allow-app-commands`, 19 commandes) et référencé par la capability : Tauri v2 ACL gate toute commande invoquée depuis une origine *remote*, y compris celles de l'`invoke_handler` | `cargo test` 25 passed (nouveau garde-fou `test_frontend_invokes_are_acl_allowed`) + vérif live CDP : `get_version`→2.49.2, clic → fenêtre native « Select Folder », parcours complet ajout de vault terminé |
| *BUG-105* | Desktop : crash 2-15 s après l'ouverture de la fenêtre (`APPCRASH c0000374`, heap corruption détectée dans ntdll) — absent de v2.0.0, apparu en 2.49.2 | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/src/jumplist.rs` (`set_link_title`) | Installeur 2.49.2 : fenêtre visible quelques secondes puis fermeture (3/3), Event Log Windows `APPCRASH obsigate-desktop.exe … c0000374`, rien dans stderr | `set_link_title` (#77) construisait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust et le confiait au property store du shell, qui le libère avec l'allocateur Windows → corruption du tas ; fonction supprimée (`SetDescription` fournit déjà le libellé) | Bisect 3 états (jumplist off = stable, titre off = stable, d'origine = crash) ; run 9 min sans crash + sortie propre `EXIT=0`, zéro événement WER (avant : 3 crashes ≤20 s) |
| *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 |
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler`) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
| | | | | | | | | | |
### 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)* | | | | | | | | | |
---
@@ -207,6 +232,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) |
@@ -273,6 +306,26 @@ 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) |
| 2026-10-02 | #158 (A1-A3), BUG-100 | Fonctionnalité + correction | `frontend/js/navfacets.js` (nouveau), `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `frontend/js/legacy.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/services/vaults.py`, `backend/schemas.py`, `tests/test_nav_files.py` (nouveau), `tests/frontend/navfacets.test.mjs` (nouveau), `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **A1** : `TabManager.openNav(vault, dir)` — un onglet de navigation par vault, ouvert à chaque clic répertoire de l'arbre (mode focus et mode All) sans fermer les onglets de fichiers ; la ligne vault reste un expandeur. **A2** : la vue liste les sous-répertoires cliquables et embarque le panneau de facettes **Vaults · Tags · Extensions** (mêmes classes que la page de recherche), les tris **Pertinence / Date** et le bouton **Sauver** ; `GET /api/vault/{v}/files` expose désormais les `tags` indexés (facettes calculées côté client). **A3 / BUG-100** : `showWelcome()` réinjecte `#quick-help` et `goHome()` efface recherche globale + filtre sidebar. Tests : `test_nav_files.py` 3 passed, `navfacets.test.mjs` 10/10, `unit.test.mjs` 12/12, `validate-imports` 41 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | #158 (A4-A6) | Fonctionnalité | `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/legacy.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/e2e/nav-tab.spec.js` (nouveau), `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/navigation-tab-158.md` | **A4** : `TabManager.openHome()` — le clic sur le titre ouvre la page d'accueil dans un **onglet Accueil** (icône Maison, `common.home` FR/EN) remonté en position 0 à chaque fois ; `deactivate()` conserve cet onglet actif, `pane-manager.js` a son propre `openHome` pour le split. **A5** : menus contextuels répertoires **et** fichiers de la page de navigation via `ContextMenuManager.show()` (avec `stopPropagation()`, le handler global referme sinon le menu) — listes vérifiées identiques à la sidebar ; la ligne d'un vault rouvre la page de navigation. **A6** : icône des répertoires à `var(--accent)` (couleur de l'arbre). **Correctif annexé** : l'effacement du filtre sidebar au retour à l'accueil n'est déclenché que s'il était actif (sinon `restoreSidebarTree()` repliait l'arbre). Vérifié : Playwright jetable 16/16, `nav-tab.spec.js` 4 tests, validate-imports/unit/i18n parity verts. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-101 | Correction | `frontend/js/ui.js` | **BUG-101 — le bloc « Keyboard shortcuts for tabs » existait en double dans `ui.js`** (fin de fichier) : un seul `Ctrl+W` déclenchait **deux** `close()`, le second refermant l'onglet ré-activé par le premier — observé avec #158 quand un onglet navigation survit à la fermeture du fichier ; `Ctrl+Tab` sautait également un onglet. Bloc dupliqué supprimé (un seul listener). Vérifié : repro Playwright (`close()` appelé 2× puis 1×) et suite E2E. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-102 | Correction | `frontend/js/ui.js` | **BUG-102 — menu contextuel refermé par sa propre ouverture** : la reflow des icônes lucide à l'affichage déclenche un `scroll` capturé qui refermait le menu ~10 ms après son apparition (constaté sur les fichiers de la page de navigation, #158 A5). `show()` mémorise `_shownAt` et le listener `scroll` ignore un déclenchement dans les 250 ms. Vérifié : instrumentation Playwright + suite E2E (`nav-tab.spec.js`). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-103 | Correction | `frontend/style.css` | **BUG-103 — sidebar mobile sous la barre d'outils du bas** : en mobile le `.sidebar` (`z-index: 200`) passait sous `.mobile-toolbar` (`z-index: 900`, 64 px de haut), masquant les dernières entrées de l'arbre ; passé à `950`. Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`). | 🟢 corrigé |
| 2026-10-02 | BUG-104 | Correction | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | **BUG-104 — commandes Tauri rejetées par l'ACL depuis la page backend** : la fenêtre redirige vers `http://127.0.0.1:<port>`, origine *remote* pour Tauri v2, et aucune commande applicative n'était déclarée → `Command … not allowed by ACL` sur `pick_vault_folder`, `get_wizard_state`, etc. ; les erreurs sont avalées par `desktop.js:invoke()` → bouton du wizard silencieusement inopérant depuis #77. Correctif : manifeste `desktop/permissions/commands.toml` (`allow-app-commands`, 19 commandes) référencé par la capability + `rerun-if-changed=permissions` dans `build.rs`. Vérifié : `cargo test` 25 passed (nouveau garde-fou ACL), CDP live : `get_version` OK, clic → fenêtre native « Select Folder », ajout de vault complété de bout en bout. | 🟢 corrigé |
| 2026-10-02 | BUG-105 | Correction | `desktop/src/jumplist.rs` | **BUG-105 — crash heap (c0000374) à l'ouverture du desktop** : `set_link_title` (#77) passait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust au property store du shell, qui le libère avec l'allocateur Windows → `STATUS_HEAP_CORRUPTION` détectée dans ntdll ≤20 s après le lancement (3/3, Event Log WER, même empreinte de pile). Fonction supprimée : le libellé des entrées vient de `SetDescription`. Vérifié : bisect sur 3 états de build, run 9 min `EXIT=0`, zéro événement WER. | 🟢 corrigé |
| 2026-10-02 | BUG-106 | Correction | `backend/main.py`, `tests/test_csp_nonce.py` | **BUG-106 — page blanche sur /docs** : la CSP `#87 T5c` (`script-src` sans `unsafe-inline`, nonce frais par réponse) s'applique aussi aux pages générées par FastAPI (`/docs`, `/redoc`) dont le balisage n'est jamais passé par `inject_csp_nonce` → le navigateur refusait le script inline `SwaggerUIBundle(…)`, la div restait vide. `SecurityHeadersMiddleware` injecte désormais le nonce dans ce HTML avec le helper existant ; le corps est décompressé/recompressé car `SSESafeGZipMiddleware` est plus proche de la route (les réponses arrivent gunzippées). `/redoc` n'a aucun script inline (bundle jsdelivr seul, couvert par `script-src`) — vérifié tel quel. Vérifié : `test_docs_inline_script_nonced`, `test_redoc_served_with_csp`, `test_docs_external_bundle_not_nonced` + 45 passed sur les 3 suites sécurité, ruff/mypy 0. | 🟢 corrigé |
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
---
@@ -283,7 +336,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) |
---
+427 -21
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.27.9 | **Dernière mise à jour :** 2026-09-26
> **Version :** 2.53.2 | **Dernière mise à jour :** 2026-10-07
> **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)**
@@ -40,6 +40,255 @@
- [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
### 159. Gestion desktop des vaults & dossiers — retrait par menu contextuel + ajout en configuration
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** commandes Rust existantes (`add/remove/list_vaults|dirs`) + `ContextMenuManager`
- **Statut :** ✅ livré le 2026-10-02 (v2.50.0) — ouvert le 2026-10-02 (prérequis : BUG-104/BUG-105 livrés)
- **Reste à faire :**
- [x] Retrait d'un vault **ou dossier racine** par menu contextuel (entrée « Retirer de l'application », desktop uniquement, confirmation, déregistration seule)
- [x] Section Configuration : liste des vaults/dossiers desktop + ajout (bouton vault existant `pickAndAddVault` + nouveau sélecteur `pick_folder` sans effet de bord pour les dossiers)
- [x] Rafraîchissement de la jump list après chaque ajout/retrait
- [x] i18n FR/EN, tests (garde-fou ACL + unit), E2E, fiche features, release
### 160. Premier lancement professionnel + section Configuration harmonisée
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** config Rust + CSS classes existantes
- **Statut :** ✅ livré le 2026-10-02 (v2.51.0) — ouvert le 2026-10-02
- **Reste à faire :**
- [x] Premier lancement : monter `%USERPROFILE%\ObsiGate` comme vault par défaut (remplace `voute_obsidian`), `vault_path` dessus, nom de dossier racine home dérivé du chemin (fin du hardcode « bruno »)
- [x] Document `Prise en main.md` (contenu embarqué `include_str!`) écrit dans ce dossier si absent — jamais d'écrasement
- [x] Refonte visuelle de la section « 🖥️ Vaults & dossiers (Desktop) » : classes CSS (motif `webauthn-key-item`), boutons `.config-btn-sm`, zéro style inline, override mobile 44px
- [x] Tests Rust (config par défaut + doc embarqué), suites frontend, E2E, docs, release
---
## 🔵 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
---
## 🔵 En cours — Polish menus tableur
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
- **Effort :** 1-2 jours | **Impact :** 🟡
- **Statut :** 🔵 en cours depuis 2026-10-04
- **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md)
- **Description :** la grille (#155), les menus Structure/Mise en forme/Export
(#156-A8/A11/A14) et les boutons du ruban (#154) sont textuels et restent
ouverts quand le focus quitte le menu. Ajouter des icônes Lucide
représentatives, fermer tout menu à la perte de focus (clic extérieur,
`focusout`, `Échap`, défilement), naviguer au clavier dans le menu grille, et
garantir des cibles tactiles ≥ 44 px + menus dans le viewport en mobile.
- **Sous-tâches :**
- [x] **A1** Icônes des entrées du menu contextuel de la grille (+ `item.icon`)
- [x] **A2** Icônes des menus Structure / Mise en forme / Export + boutons
Enregistrer / « + » onglet
- [x] **A3** Fermeture à la perte de focus (helper partageable : extérieur,
`focusout`, `Échap`, scroll/resize) + navigation clavier du menu grille
- [x] **A4** Polish mobile (44 px tactile, menus bornés au viewport) + tests
JSDOM + E2E `xlsx-viewer.spec.js`
---
## ✅ Terminé — Assistant IA
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
- **Effort :** 0,5 jour | **Impact :** 🟢
- **Statut :** ✅ **livré le 2026-10-06** — ouvert le 2026-10-06
- **Description :** le bouton « mode agent » du panneau (toggle persisté en
localStorage, défaut OFF) n'était plus protecteur : Deep Research et les
quick actions `agent: true` basculaient déjà le mode en silence. Retiré au
profit d'un agent toujours actif : toute requête texte part sur
`/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les mutations
gardent la confirmation two-step) ; les images seules restent sur
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant textuelle).
- **Sous-tâches :**
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
- [x] **A2** Optimisations requêtes AI : `/agent` résout le provider comme
`/chat` (avant : `req.provider` brut transmis au provider adapter →
moteur réel ≠ étiquette SSE) ; schémas d'outils mis en cache (~27 ms de
pydantic économisés par requête agent/MCP)
- [x] **A3** Tests : `ai.test.mjs` adapté (100/100) ; non-régression pytest
(`test_bookslm.py` provider résolu, `test_tools.py` cache sûr) ;
ruff/mypy 0 erreur ; build + instance Docker locale à jour
---
## ✅ Terminé — Navigation
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
- **Effort :** 1-2 jours | **Impact :** 🟡
- **Statut :** ✅ **livré** — 2026-10-02 (A1 onglet, A2 contenu/filtres/tris, A3 retour Home +
**BUG-100**)
- **Détail de conception :** [features/navigation-tab-158.md](./features/navigation-tab-158.md)
- **Description :** la vue « chemin + Récents + fichiers du répertoire » (vault home) n'apparaît
qu'en mode focus vault, disparaît dès qu'un fichier est ouvert, n'expose ni sous-répertoires ni
filtres, et le clic sur le titre ne redonne pas la vraie page d'accueil (section
**Raccourcis & Astuces** manquante, recherche/filtres non effacés).
- **Sous-tâches :**
- [x] **A1 — onglet de navigation.** Tout clic sur un **répertoire** de l'arbre (en mode focus
vault **et** en mode All) ouvre/actualise un **onglet de navigation** dédié, qui
coexiste avec les onglets de fichiers ouverts. La ligne d'un vault dans l'arbre garde son
rôle d'expansion ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette
Vaults) ou le sélecteur de vault.
- [x] **A2 — contenu de la vue.** Liste des **sous-répertoires cliquables**, panneau de
**facettes Vaults · Tags · Extensions** (même mécanisme/visuel que la page de recherche),
boutons de tri **Pertinence / Date** et bouton **Sauver**.
- [x] **A3 — retour Home complet.** Clic sur le titre = page d'accueil d'un refresh
(section **Raccourcis & Astuces** restaurée) + effacement de la recherche globale et de
la barre de filtre de la sidebar gauche.
- [x] **A4 — onglet Accueil.** Le clic sur le titre affiche la page d'accueil dans un onglet
(icône Maison) épinglé **en tête de barre**, réactivable à tout moment.
- [x] **A5 — menus contextuels de la page de navigation.** Clic droit identique à la sidebar
sur les répertoires **et** les fichiers de la vue ; la ligne d'un vault dans l'arbre
ouvre la page de navigation (racine du vault).
- [x] **A6 — icône des répertoires** de la section Répertoires à la couleur de l'arbre
(`var(--accent)`).
---
## ✅ 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)
@@ -48,6 +297,12 @@
- **Effort :** 6-8 jours | **Impact :** 🟢
- **Décision 2026-09-26 : reporté (P4)** — axe prioritaire = dette & sécurité (#85/#87) ; #73 hors chemin critique. Si réactivé : partir d'un MVP export/hash/LWW adossé à #59 (PWA offline) + #62 (collab Yjs/CRDT) plutôt qu'un protocole parallèle.
- **Périmètre élargi le 2026-10-04 (liste v1.2 Ph.3) :** la **synchronisation des favoris**
(répertoires compris, cf. #161) et la **synchronisation des préférences utilisateur** (thèmes
#65/#174, langue, historique de recherche et de modifications, recherches sauvegardées #162) sont
**fusionnées dans cet item** plutôt que portées par des items séparés — même API de sync, même
résolution de conflits. Store local actuel : JSON par utilisateur dans `data/` (pas de table
`user_preferences` distincte ; un addendum de schéma suffirait si #73 est réactivé).
- **Description :** Synchronisation des vaults entre plusieurs instances d'ObsiGate via un protocole de synchronisation décentralisé ou compatible Obsidian Sync. Alternative self-hosted à Obsidian Sync.
- **Sous-tâches :**
- [ ] Protocole : évaluation CRDT vs OT vs diff/patch pour fichiers markdown
@@ -58,37 +313,156 @@
- [ ] Conflits : UI de résolution manuelle (diff côte à côte entre version locale et distante)
- [ ] Chiffrement : optionnel, chiffrement AES-256-GCM avant transmission
- [ ] Pairing : échange de clé publique + code QR pour appairage des appareils
- [ ] Périmètre v1.2 : sync des **favoris** (#161) et des **préférences** (thème, langue,
historique, recherches sauvegardées) avec résolution de conflits (priorité à la dernière
modification) + indicateur « préférences synchronisées »
---
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
### 85. Refonte architecturale — découpage du monolithe & persistance d'état (phase 2)
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`). **T6c livrée (v2.27.9) :** media/pdf/export/guide (file/pdf, exports ×3, guide, pdf/stream|info, image, media+thumb, attachments ×2, vault settings ×3, vault files → `backend/routers/files_media.py`, Range helper → `helpers.py`).
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
- **Sous-tâches :**
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`), `files-media` ✅ (T6c, `backend/routers/files_media.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
- [ ] Extraire le service de partage public (expiration, révocation, quotas)
### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4)
- **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)
- [ ] 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`)
- [ ] 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)
- [ ] 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) ; **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
---
## ⚪ Backlog — Améliorations produit & évolutions agent IA (liste v1.2) — P1/P2
> **Ouvert le 2026-10-04** à partir de la liste « Roadmap ObsiGate – Développement des
> Fonctionnalités » v1.2 (phases 1-3 + suggestions agent IA numérotées 161-180 dans la liste
> source). Évaluation croisée avec le code réel (2026-10-04) : **7 propositions sont déjà
> livrées** et n'ouvrent donc **pas** d'ID ; seul le périmètre manquant reçoit un ID ObsiGate
> stable `#161` → `#178`. Les numéros de la liste source ne sont **pas** repris tels quels
> (règle du dépôt : un ID n'est jamais attribué à du déjà-fait ni réutilisé).
> Chaque item doit passer par [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md) (DoD) à son ouverture.
### Table de correspondance (liste v1.2 → ObsiGate)
| Proposition v1.2 | Verdict 2026-10-04 (vérifié dans le code) | ID ObsiGate |
|---|---|---|
| Ph.1 — Sauvegarde des recherches fréquentes | Sauvegarde **déjà livrée** (`backend/saved_searches.py`, store `data/{user}_saved_searches.json`, bouton « Sauver » #158) ; fréquence & rappels manquants | ⚪ #162 |
| Ph.1 — Liens de répertoires aux favoris | Marque-page existant sur **fichiers** uniquement (dashboard) ; répertoires absents | ⚪ #161 |
| Ph.1 — Recherche dans la page | **Déjà livrée** en vue lecture (`FindInPageManager`, Ctrl+F + navigation + regex, tableur via #153-A13) ; reste l'intégration **Forge** | ⚪ #163 |
| Ph.2 — Auto-complétion intelligente | **Déjà livrée** (`frontend/js/autocomplete.js` : Mermaid, code fence, frontmatter, wikilinks, table — partagé Forge/CodeMirror) | ✅ |
| Ph.2 — Suggestions contextuelles (historique & préférences) | Non livré | ⚪ #164 |
| Ph.3 — Synchronisation favoris + préférences multiplateforme | Fusionné dans le périmètre de **#73** (reporté P4, décision 2026-09-26) | ⚪ #73 |
| 161 — Synchronisation Git automatique des vaults | Non livré (les outils `git_*` #92 ciblent Gitea/GitHub pour l'IA, pas la sync de vault) | ⚪ #165 |
| 162 — Génération de résumés automatiques | **Déjà livrée** (`POST /api/ai/summarize`, actions instantanées #106, skills `backend/skills.py`) | ✅ |
| 163 — Détection & fusion de doublons | Non livré (brique embeddings/TF-IDF #70 disponible) | ⚪ #166 |
| 164 — Traduction automatique | **Déjà livrée** (toolbar IA « Traduire », #106) | ✅ |
| 165 — Analyse de ton/style | **Déjà livrée** (toolbar IA « Ton », #106) | ✅ |
| 166 — APIs tierces (Google Drive, Notion) | Partiel : sources connectées **Gitea/GitHub** livrées (#103, `backend/tools/connected.py`) ; Drive/Notion manquants | ⚪ #167 |
| 167 — Notifications externes (Slack, Discord, Email) | Partiel : webhooks #9 + Push API #67 livrés ; canaux Slack/Discord/SMTP et choix des déclencheurs à câbler | ⚪ #168 |
| 168 — Édition collaborative temps réel | **Déjà livrée** (#62 — Yjs/CRDT, WebSocket, awareness, v2.3.0) | ✅ |
| 169 — Scripts personnalisés sandboxés | **Déjà livrée** (#61 — plugins en sandbox Web Worker, hooks, 9 endpoints, v2.2.0) | ✅ |
| 170 — Planification de tâches automatiques (cron) | Non livré | ⚪ #170 |
| 171 — Audit des accès | Partiel : `backend/audit.py` + `/api/admin/audit` + dashboard admin #71 existent ; historique **par fichier**, filtres avancés et export CSV manquants | ⚪ #171 |
| 172 — Chiffrement/déchiffrement de fichiers | Non livré (AES-GCM interne réservé aux tokens) | ⚪ #172 |
| 173 — Vues personnalisées (Kanban, Calendrier) | Non livré | ⚪ #173 |
| 174 — Optimisation mobile | Base **largement livrée** (#69 éditeur mobile, #83 ruban mobile, #114 config responsive 44 px) ; reste un audit du solde | ⚪ #178 |
| 175 — Chat intégré | Non livré (transport WebSocket/rooms de #62 réutilisable) | ⚪ #169 |
| 176 — Thèmes dynamiques (règles heure/date) | Base livrée (#65 thèmes + import/export) ; règles automatiques manquantes | ⚪ #174 |
| 177 — Export formats propriétaires (OneNote, Evernote) | Base livrée (#66 HTML/MD bundle/ePub + PDF #74) ; formats tiers manquants | ⚪ #175 |
| 178 — Webhooks pour événements | **Déjà livrée** (#9 — `backend/webhooks.py`, événements + secrets + validation d'URL SSRF-safe) | ✅ |
| 179 — Gestion fine des permissions | Non livré (ACLs par vault/dossier racine seulement) | ⚪ #176 |
| 180 — Historique des révisions visuel | Partiel : gestion des backups **avec diff** livrée (#40-46, outil IA `diff_backup`) ; UI côte à côte manquante | ⚪ #177 |
---
### 161. Favoris — répertoires et liens de vault (menu contextuel)
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
- **Description :** le marque-page actuel ne couvre que les fichiers ; ajouter les **répertoires**
aux favoris (proposition « liens de répertoires », liste v1.2 Ph.1).
- **Sous-tâches :**
- [ ] Étendre le modèle de favoris aux répertoires (vérification d'accès backend via
`_resolve_safe_path()`, permissions)
- [ ] Entrée « Ajouter aux favoris » dans le menu contextuel de l'arbre **et** de la page de
navigation (#158-A5)
- [ ] Section « Favoris » dans la sidebar (répertoires + fichiers)
- [ ] i18n FR/EN, tests (unit + E2E si UI), fiche `docs/features/`
### 162. Recherches fréquentes — fréquence d'utilisation & rappels
- **Effort :** 1-2 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
- **Cadrage :** la **sauvegarde** des recherches existe déjà (`backend/saved_searches.py`, bouton
« Sauver » de #158). Cet item n'ouvre que l'incrément manquant de la liste v1.2 Ph.1.
- **Sous-tâches :**
- [ ] Compteur d'utilisation par recherche sauvegardée (horodatage + fréquence)
- [ ] Section dédiée « Recherches fréquentes » (page de résultats + sidebar) et épinglage
- [ ] Rappel optionnel (« Vous avez souvent cherché X ») via notifications #67, interruptible
en Configuration
- [ ] i18n FR/EN + tests
### 163. Recherche dans la page — éditeur Forge
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
- **Cadrage :** `FindInPageManager` (Ctrl+F, surbrillance, navigation ↑/↓, casse / mot entier /
regex) est livré pour la vue lecture ; le tableur a sa propre recherche (#153-A13). Reste à
raccorder la recherche à l'éditeur **Forge** et à l'exposer en commande `/`.
- **Sous-tâches :**
- [ ] Raccorder `FindInPageManager` à Forge (textarea **et** CodeMirror) : surbrillance dans le
contenu édité, navigation avec boucle, conservation du curseur
- [ ] Commande `/recherche-dans-page` (mécanisme de commandes #81)
- [ ] Non-régression des raccourcis existants (Ctrl+F vs recherche globale, Échap) ; i18n ;
tests JSDOM + E2E
### 164. Suggestions contextuelles dans l'éditeur (historique & préférences)
- **Effort :** 3-5 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
- **Cadrage :** l'auto-complétion contextuelle (Mermaid, code, Markdown, wikilinks, frontmatter)
est **déjà livrée** (`frontend/js/autocomplete.js`) — la proposition v1.2 Ph.2
« Auto-complétion intelligente » est couverte. Cet item n'ouvre que les suggestions apprises
du contexte utilisateur.
- **Sous-tâches :**
- [ ] Mesure locale des snippets les plus utilisés par l'utilisateur (fréquence, sans cloud)
- [ ] Suggestions basées sur l'historique de modification (termes récurrents, patterns du type
« souvent inséré après … ») — option locale, IA légère si disponible
- [ ] Bascule on/off en Configuration ; intégration à la complétion existante
- [ ] i18n FR/EN + tests
---
### ⚪ Évolutions agent IA (liste v1.2 « Phase 4 », IDs ObsiGate #165 → #178)
| ID | Fonctionnalité | Effort | Impact | Dépendances / socle à étendre |
|---|---|---|---|---|
| 165 | Synchronisation automatique des vaults avec Git (add/commit/push/pull, résolution de conflits, auth SSH/token, option planification X heures) | 4-6 j | 🟡 | subprocess/`gitpython` ; UI dans Configuration (#160) ; distinct des outils `git_*` #92 (IA, Gitea/GitHub) |
| 166 | Détection & fusion de doublons (score de similarité, liste des paires potentielles, outil de fusion) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md)) | 3-4 j | 🟢 | embeddings/TF-IDF #70 ; **jamais de fusion sans confirmation explicite** (+ backup préalable) |
| 167 | Connexions tierces — Google Drive & Notion (import/export, OAuth2, respect des quotas) | 5-7 j | 🟡 | page « sources connectées » #103 ; rate-limit existant |
| 168 | Notifications externes — canaux Slack/Discord (webhooks) et Email (SMTP) + choix des déclencheurs — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; périmètre : Discord, Telegram, SMTP, webhook générique — Slack via webhook générique) | 3-4 j | 🟢 | `backend/webhooks.py` #9 ; Push API #67 |
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) | 3-4 j | 🟢 | transport WebSocket/rooms de la collab #62 |
| 170 | Planification de tâches automatiques (type cron ; actions = outils existants `create_file`/`append_to_file` ; notifications d'échec) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; tick asyncio 60 s, API + outils, sans UI dédiée) | 3-4 j | 🟢 | scheduler backend (APScheduler ou asyncio) ; UI de création de tâches |
| 171 | Audit des accès — extension : historique lecture/édition par fichier, filtres (utilisateur/fichier/date), export CSV | 2-3 j | 🟡 | `backend/audit.py` + `/api/admin/audit` (#71) |
| 172 | Chiffrement/déchiffrement de fichiers sensibles (AES-256-GCM, gestion de clés, confirmation) | 3-4 j | 🟡 | **backup automatique préalable** ; clés hors dépôt (jamais de secret committé) |
| 173 | Vues personnalisées Kanban & Calendrier (glisser-déposer, colonnes/date par tags & métadonnées) | 5-6 j | 🟢 | facettes/tags #157-#158 ; store JSON par utilisateur (pattern `data/*.json`) |
| 174 | Thèmes dynamiques (règles heure/date, ex. mode sombre 18h-8h, règles personnalisables) | 2-3 j | 🟢 | thèmes #65 ; préférence persistée (candidate à la sync #73) |
| 175 | Export vers formats propriétaires (OneNote, Evernote) avec conservation des métadonnées (tags, liens, images) | 3-4 j | 🟢 | `export.py` #66 (HTML/MD bundle/ePub) + PDF #74 |
| 176 | Permissions fines par fichier/dossier (utilisateur/groupe + actions, héritage, journal des changements) | 4-5 j | 🟡 | ACLs vaults/dossiers existantes ; passerelle obligatoire par `_resolve_safe_path()` |
| 177 | Historique des révisions visuel (diff côte à côte, surlignage ajouts/suppressions, navigation Précédent/Suivant) | 3-4 j | 🟢 | backups + diff #40-46 (`diff_backup`) ; `difflib` backend |
| 178 | Optimisation mobile — solde (audit cibles tactiles ≥ 44 px, lazy-loading images/médias, raccourcis clavier virtuel) | 4-5 j | 🟡 | base livrée #69/#83/#114 ; lazy-loading déjà partiel (#153-A9) |
- **Critères d'acceptation transverses (liste v1.2 harmonisée avec la DoD du dépôt) :** test
desktop **et** mobile ; documentation utilisateur FR/EN dans `docs/GUIDES/` (l'appel
« Guide_MCP_ObsiGate.md » de la liste source est ramené à la cartographie documentaire du
dépôt) ; **backup automatique avant toute modification destructive** ; i18n FR/EN systématique ;
passage de l'ID à « en cours » avant codage (AGENTS.md, « Avant de commencer »).
---
## ✅ Complété — index
> Détail complet dans [docs/archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) et
@@ -98,6 +472,11 @@
| # | 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) |
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.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) |
@@ -164,6 +543,12 @@
| BUG-078 | Fichiers de code — coloration syntaxique restaurée (feuilles highlight.js basculées sur le mode de thème et non la clé) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 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) |
| 158 | Navigation — clic répertoire → **onglet de navigation** (sous-répertoires, facettes Vaults/Tags/Extensions, tri Pertinence/Date, Sauver), **onglet Accueil**, menus contextuels + retour Home complet (**BUG-100** → **BUG-102**) | 2.48.0→2.49.0 | [features/navigation-tab-158.md](./features/navigation-tab-158.md) |
| 166 | Assistant IA — détection & fusion de doublons (score déterministe, paires candidates, fusion backup + confirmation) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| 168 | Assistant IA — notifications externes Discord / Telegram / SMTP / webhook (déclencheurs, secrets hors config, SSRF-safe) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| 170 | Assistant IA — tâches planifiées type cron (create/append/notify, interval/daily/once, tick 60 s, échec notifié) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
---
@@ -171,17 +556,38 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–115, #117, #92 | ~133 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–86, #88–93, #94–100, #102–115, #117, #92 | ~141 jours réalisés |
| 🔵 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 | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
| **Total chemin critique** | **#77 fin + #85 + #87** | **~12-18 jours** |
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique ; périmètre élargi 2026-10-04 (favoris #161 + préférences) | 6-8 jours si réactivé |
| ⚪ P1/P2 nouvelles (liste v1.2) | #161 favoris répertoires · #162 recherches fréquentes · #163 recherche Forge · #164 suggestions contextuelles | ~5-9 jours |
| ⚪ Évolutions agent IA (#165 → #178) | Git sync, doublons, Drive/Notion, notifications, chat, cron, audit, chiffrement, Kanban, thèmes dynamiques, OneNote/Evernote, permissions, diff visuel, mobile | ~45-63 jours si tout activé (à prioriser par tranches) |
| ⚪ 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** |
---
## 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).
- **Ajout 2026-10-04 :** intégration de la liste « Roadmap – Développement des Fonctionnalités »
v1.2 (phases 1-3 + suggestions agent IA). Évaluation croisée avec le code : **7 propositions déjà
livrées** (auto-complétion `autocomplete.js`, résumés/traduction/ton #106, édition collaborative
#62, scripts/plugins #61, webhooks #9, recherche dans la page en vue lecture) → pas d'ID ;
**périmètre manquant** ouvert en backlog **#161 → #178** avec **table de correspondance** en tête
de la nouvelle section ; sync favoris/préférences **fusionnée dans #73** (reporté P4). Les numéros
161-180 de la liste source ne sont pas repris tels quels (règle dépôt : ID jamais attribué au
déjà-fait) ; la correspondance figure item par item.
- **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.
- Les items marqués 🟢 (nice-to-have) sont de bons candidats pour des contributions externes.
+87
View File
@@ -0,0 +1,87 @@
// ObsiGate AI Agent — flux de traitement d'une demande (b89af91)
digraph ObsiGateAgent {
rankdir=TB;
splines=polyline;
nodesep=0.35; ranksep=0.45;
graph [fontname="DejaVu Sans", fontsize=13, bgcolor="white", pad=0.3];
node [fontname="DejaVu Sans", fontsize=12, style=filled, shape=box, color="#333333", margin="0.16,0.09"];
edge [fontname="DejaVu Sans", fontsize=11, color="#555555", arrowsize=0.8];
// ── Entrée ──
user [label="Demande utilisateur\n(frontend bookslm.js)", fillcolor="#dbeafe"];
deep [label="Chip « Deep Research »\n= agent mode + prompt injecté :\n« décomposer les étapes, chercher\ndans le vault, croiser,\nsynthétiser avec sources »",
shape=note, fillcolor="#fef3c7"];
api [label="POST /api/bookslm/agent\n→ réponse SSE (thought · tool ·\nconfirmation · message · done)", fillcolor="#e0f2fe"];
prompt [label="Construction du system prompt\nmode directory / documents / general\ncontexte vault pré-chargé (BooksLM :\n≤200 fichiers, ≤200k chars, cache 5 min)\nprotocole agent = function calling natif", fillcolor="#f1f5f9"];
user -> api; deep -> user [style=dashed, label="active", fontsize=10];
api -> prompt;
// ── Boucle agent ──
subgraph cluster_loop {
label="BOUCLE AGENT — backend/agent/loop.py · run_agent()";
style=rounded; color="#e11d48"; penwidth=2; fontsize=13; fontcolor="#e11d48";
llm [label="Appel LLM — chat_completion()\nfournisseur OpenAI-compatible / Gemini\ntemp=0.3 · max_tokens 8192\nschemas des 50 outils exposés\n(si modèle sans tools → retombe chat simple)", fillcolor="#fee2e2"];
decide [label="Le LLM demande des\noutils (tool_calls) ?", shape=diamond, fillcolor="#fff7ed"];
thought [label="note de raisonnement\nintermédiaire → step visible « pensée »", fillcolor="#fef9c3"];
tools [label="Exécution de chaque tool call\nvia call_tool() (voir pipeline)", fillcolor="#dcfce7"];
back [label="résultat (JSON, tronqué ≤100k chars)\najouté à la conversation →\nitération suivante", shape=plaintext];
llm -> decide;
decide -> thought [label="oui", color="#e11d48"];
thought -> tools;
tools -> llm [label="itération ≤ 10\nquota tools ≤ 25\n(BOOKSLM_MAX_TOOL_CALLS)", color="#e11d48"];
}
prompt -> llm;
// ── Pipeline call_tool ──
subgraph cluster_pipe {
label="PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là";
style=rounded; color="#7c3aed"; fontsize=13; fontcolor="#7c3aed";
p1 [label="1 · Schéma Pydantic\n+ validation arguments", fillcolor="#ede9fe"];
p2 [label="2 · Rate limit\npar identité + outil", fillcolor="#ede9fe"];
p3 [label="3 · Permission vault\n(+ déstructif si risque\nDANGEROUS)", fillcolor="#ede9fe"];
p4 [label="4 · Risque READ ?\n→ exécution directe", shape=diamond, fillcolor="#f5f3ff"];
p5 [label="PAUSE CONFIRMATION\noutil WRITE/DANGEROUS → event SSE\n« confirmation » avec le plan complet\nbatché (BUG-075) : une seule\napprobation applique toutes les mutations\n(resume via confirm / confirm_all)", fillcolor="#fde68a"];
p6 [label="5 · Handler exécuté\n6 · Audit JSON-lines\n7 · Redaction secrets (redact_payload)", fillcolor="#dcfce7"];
p1 -> p2 -> p3 -> p4;
p4 -> p6 [label="read"];
p4 -> p5 [label="mutation", color="#d97706"];
p5 -> p6 [label="approuvé", style=dashed];
}
tools -> p1;
p6 -> back [label="tool result", style=invis];
p6 -> tools [style=invis];
back -> p1 [style=invis];
// ── Sorties ──
done [label="Réponse finale\nréponse sans tool_calls\n→ SSE « message »\n(Markdown + sources citées)", fillcolor="#bbf7d0"];
final [label="Budget épuisé (10 itérations\nou 25 appels) → _finalize_answer :\ndernier appel SANS outils pour forcer\nune synthèse, sinon liste des sources\ndéterministe (jamais de réponse vide)", fillcolor="#fed7aa"];
decide -> done [label="non", color="#059669", penwidth=2];
decide -> final [label="budget atteint", color="#ea580c"];
answer [label="Affichage UI : bloc\n« N étapes » Notion-style\n(pensées + outils dépliables)", fillcolor="#dbeafe"];
done -> answer; final -> answer;
// ── Boîte à outils ──
subgraph cluster_tools {
label="50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS";
style=rounded; color="#0369a1"; fontsize=13; fontcolor="#0369a1";
node [fillcolor="#f0f9ff", fontsize=10];
fam_vault [label="VAULT · service.py (22)\nlecture : list_vaults, list_directory,\nlist_all_files, read_file, read_file_raw,\nsearch_fulltext, search_advanced,\nsearch_paths, get_graph, get_backlinks,\nlist_tags, suggest_tags, list_recent,\nlist_backups, diff_backup\nécriture : create_file, create_directory,\nappend_to_file, edit_file, restore_backup\nDANGEROUS : delete_file, delete_directory,\nmove_path, rename_file, rename_directory,\nreplace_in_files"];
fam_web [label="WEB · web.py + crawler.py\nweb_search · fetch_url\ncrawl_site (≤20 pages, même hôte)"];
fam_doc [label="DOCUMENTS · documents.py\ncreate_pdf · create_docx\ncreate_xlsx · create_csv"];
fam_xlsx [label="TABLEURS · spreadsheets.py\nlist_xlsx_sheets · xlsx_to_markdown\nsearch_workbook · analyze_range\nupdate_xlsx_cells · append_xlsx_rows\nedit_xlsx_structure"];
fam_dup [label="DOUBLONS · duplicates.py\nfind_duplicates\nmerge_duplicate_notes (DANG.)"];
fam_git [label="GIT CONNECTÉ · connected.py\ngit_list_repos · git_get_file\ngit_search_issues (Gitea/GitHub)"];
fam_sched [label="SCHEDULER · scheduled.py\nlist/create/delete/run_scheduled_task"];
fam_notify [label="NOTIFICATIONS · notify.py\nnotify_external (Discord,\nTelegram, SMTP, webhook)"];
}
p6 -> cluster_tools [style=invis];
fam_vault -> fam_web [style=invis]; fam_web -> fam_doc [style=invis];
fam_doc -> fam_xlsx [style=invis]; fam_xlsx -> fam_dup [style=invis];
fam_dup -> fam_git [style=invis]; fam_git -> fam_sched [style=invis];
fam_sched -> fam_notify [style=invis];
}
+394
View File
@@ -0,0 +1,394 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.42.4 (0)
-->
<!-- Title: ObsiGateAgent Pages: 1 -->
<svg width="2130pt" height="1034pt"
viewBox="0.00 0.00 2130.20 1033.70" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(21.6 1012.1)">
<title>ObsiGateAgent</title>
<polygon fill="white" stroke="transparent" points="-21.6,21.6 -21.6,-1012.1 2108.6,-1012.1 2108.6,21.6 -21.6,21.6"/>
<g id="clust1" class="cluster">
<title>cluster_loop</title>
<path fill="white" stroke="#e11d48" stroke-width="2" d="M493,-11.5C493,-11.5 1052,-11.5 1052,-11.5 1058,-11.5 1064,-17.5 1064,-23.5 1064,-23.5 1064,-471 1064,-471 1064,-477 1058,-483 1052,-483 1052,-483 493,-483 493,-483 487,-483 481,-477 481,-471 481,-471 481,-23.5 481,-23.5 481,-17.5 487,-11.5 493,-11.5"/>
<text text-anchor="middle" x="772.5" y="-468.6" font-family="DejaVu Sans" font-size="13.00" fill="#e11d48">BOUCLE AGENT — backend/agent/loop.py · run_agent()</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_pipe</title>
<path fill="white" stroke="#7c3aed" d="M1084,-102C1084,-102 1580,-102 1580,-102 1586,-102 1592,-108 1592,-114 1592,-114 1592,-761.5 1592,-761.5 1592,-767.5 1586,-773.5 1580,-773.5 1580,-773.5 1084,-773.5 1084,-773.5 1078,-773.5 1072,-767.5 1072,-761.5 1072,-761.5 1072,-114 1072,-114 1072,-108 1078,-102 1084,-102"/>
<text text-anchor="middle" x="1332" y="-759.1" font-family="DejaVu Sans" font-size="13.00" fill="#7c3aed">PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_tools</title>
<path fill="white" stroke="#0369a1" d="M1612,-8C1612,-8 2067,-8 2067,-8 2073,-8 2079,-14 2079,-20 2079,-20 2079,-970.5 2079,-970.5 2079,-976.5 2073,-982.5 2067,-982.5 2067,-982.5 1612,-982.5 1612,-982.5 1606,-982.5 1600,-976.5 1600,-970.5 1600,-970.5 1600,-20 1600,-20 1600,-14 1606,-8 1612,-8"/>
<text text-anchor="middle" x="1839.5" y="-968.1" font-family="DejaVu Sans" font-size="13.00" fill="#0369a1">50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS</text>
</g>
<!-- user -->
<g id="node1" class="node">
<title>user</title>
<polygon fill="#dbeafe" stroke="#333333" points="712.5,-743.5 561.5,-743.5 561.5,-705.5 712.5,-705.5 712.5,-743.5"/>
<text text-anchor="middle" x="637" y="-727.9" font-family="DejaVu Sans" font-size="12.00">Demande utilisateur</text>
<text text-anchor="middle" x="637" y="-714.9" font-family="DejaVu Sans" font-size="12.00">(frontend bookslm.js)</text>
</g>
<!-- api -->
<g id="node3" class="node">
<title>api</title>
<polygon fill="#e0f2fe" stroke="#333333" points="742.5,-669 531.5,-669 531.5,-618 742.5,-618 742.5,-669"/>
<text text-anchor="middle" x="637" y="-653.4" font-family="DejaVu Sans" font-size="12.00">POST /api/bookslm/agent</text>
<text text-anchor="middle" x="637" y="-640.4" font-family="DejaVu Sans" font-size="12.00">→ réponse SSE (thought · tool ·</text>
<text text-anchor="middle" x="637" y="-627.4" font-family="DejaVu Sans" font-size="12.00">confirmation · message · done)</text>
</g>
<!-- user&#45;&gt;api -->
<g id="edge1" class="edge">
<title>user&#45;&gt;api</title>
<path fill="none" stroke="#555555" d="M637,-705.18C637,-696.86 637,-686.76 637,-677.2"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-677.01 637,-669.01 634.2,-677.01 639.8,-677.01"/>
</g>
<!-- deep -->
<g id="node2" class="node">
<title>deep</title>
<polygon fill="#fef3c7" stroke="#333333" points="749,-919 519,-919 519,-842 755,-842 755,-913 749,-919"/>
<polyline fill="none" stroke="#333333" points="749,-919 749,-913 "/>
<polyline fill="none" stroke="#333333" points="755,-913 749,-913 "/>
<text text-anchor="middle" x="637" y="-903.4" font-family="DejaVu Sans" font-size="12.00">Chip « Deep Research »</text>
<text text-anchor="middle" x="637" y="-890.4" font-family="DejaVu Sans" font-size="12.00">= agent mode + prompt injecté :</text>
<text text-anchor="middle" x="637" y="-877.4" font-family="DejaVu Sans" font-size="12.00">« décomposer les étapes, chercher</text>
<text text-anchor="middle" x="637" y="-864.4" font-family="DejaVu Sans" font-size="12.00">dans le vault, croiser,</text>
<text text-anchor="middle" x="637" y="-851.4" font-family="DejaVu Sans" font-size="12.00">synthétiser avec sources »</text>
</g>
<!-- deep&#45;&gt;user -->
<g id="edge2" class="edge">
<title>deep&#45;&gt;user</title>
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M637,-841.83C637,-813.95 637,-776.67 637,-751.91"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-751.71 637,-743.71 634.2,-751.71 639.8,-751.71"/>
<text text-anchor="middle" x="652.5" y="-784.5" font-family="DejaVu Sans" font-size="10.00">active</text>
</g>
<!-- prompt -->
<g id="node4" class="node">
<title>prompt</title>
<polygon fill="#f1f5f9" stroke="#333333" points="773.5,-585 500.5,-585 500.5,-508 773.5,-508 773.5,-585"/>
<text text-anchor="middle" x="637" y="-569.4" font-family="DejaVu Sans" font-size="12.00">Construction du system prompt</text>
<text text-anchor="middle" x="637" y="-556.4" font-family="DejaVu Sans" font-size="12.00">mode directory / documents / general</text>
<text text-anchor="middle" x="637" y="-543.4" font-family="DejaVu Sans" font-size="12.00">contexte vault pré&#45;chargé (BooksLM :</text>
<text text-anchor="middle" x="637" y="-530.4" font-family="DejaVu Sans" font-size="12.00">≤200 fichiers, ≤200k chars, cache 5 min)</text>
<text text-anchor="middle" x="637" y="-517.4" font-family="DejaVu Sans" font-size="12.00">protocole agent = function calling natif</text>
</g>
<!-- api&#45;&gt;prompt -->
<g id="edge3" class="edge">
<title>api&#45;&gt;prompt</title>
<path fill="none" stroke="#555555" d="M637,-617.54C637,-610.12 637,-601.74 637,-593.43"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-593.17 637,-585.17 634.2,-593.17 639.8,-593.17"/>
</g>
<!-- llm -->
<g id="node5" class="node">
<title>llm</title>
<polygon fill="#fee2e2" stroke="#333333" points="784.5,-453 489.5,-453 489.5,-376 784.5,-376 784.5,-453"/>
<text text-anchor="middle" x="637" y="-437.4" font-family="DejaVu Sans" font-size="12.00">Appel LLM — chat_completion()</text>
<text text-anchor="middle" x="637" y="-424.4" font-family="DejaVu Sans" font-size="12.00">fournisseur OpenAI&#45;compatible / Gemini</text>
<text text-anchor="middle" x="637" y="-411.4" font-family="DejaVu Sans" font-size="12.00">temp=0.3 · max_tokens 8192</text>
<text text-anchor="middle" x="637" y="-398.4" font-family="DejaVu Sans" font-size="12.00">schemas des 50 outils exposés</text>
<text text-anchor="middle" x="637" y="-385.4" font-family="DejaVu Sans" font-size="12.00">(si modèle sans tools → retombe chat simple)</text>
</g>
<!-- prompt&#45;&gt;llm -->
<g id="edge8" class="edge">
<title>prompt&#45;&gt;llm</title>
<path fill="none" stroke="#555555" d="M637,-507.9C637,-493.35 637,-476.55 637,-461.3"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-461.02 637,-453.02 634.2,-461.02 639.8,-461.02"/>
</g>
<!-- decide -->
<g id="node6" class="node">
<title>decide</title>
<polygon fill="#fff7ed" stroke="#333333" points="641,-325 489,-287 641,-249 793,-287 641,-325"/>
<text text-anchor="middle" x="641" y="-290.4" font-family="DejaVu Sans" font-size="12.00">Le LLM demande des</text>
<text text-anchor="middle" x="641" y="-277.4" font-family="DejaVu Sans" font-size="12.00">outils (tool_calls) ?</text>
</g>
<!-- llm&#45;&gt;decide -->
<g id="edge4" class="edge">
<title>llm&#45;&gt;decide</title>
<path fill="none" stroke="#555555" d="M638.2,-375.85C638.63,-362.33 639.12,-346.95 639.57,-332.87"/>
<polygon fill="#555555" stroke="#555555" points="642.37,-332.85 639.83,-324.76 636.77,-332.67 642.37,-332.85"/>
</g>
<!-- thought -->
<g id="node7" class="node">
<title>thought</title>
<polygon fill="#fef9c3" stroke="#333333" points="786.5,-154.5 529.5,-154.5 529.5,-116.5 786.5,-116.5 786.5,-154.5"/>
<text text-anchor="middle" x="658" y="-138.9" font-family="DejaVu Sans" font-size="12.00">note de raisonnement</text>
<text text-anchor="middle" x="658" y="-125.9" font-family="DejaVu Sans" font-size="12.00">intermédiaire → step visible « pensée »</text>
</g>
<!-- decide&#45;&gt;thought -->
<g id="edge5" class="edge">
<title>decide&#45;&gt;thought</title>
<path fill="none" stroke="#e11d48" d="M645.12,-249.81C648.17,-222.91 652.28,-186.83 655.02,-162.68"/>
<polygon fill="#e11d48" stroke="#e11d48" points="657.81,-162.94 655.93,-154.67 652.25,-162.31 657.81,-162.94"/>
<text text-anchor="middle" x="659.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">oui</text>
</g>
<!-- done -->
<g id="node16" class="node">
<title>done</title>
<polygon fill="#bbf7d0" stroke="#333333" points="473,-167.5 275,-167.5 275,-103.5 473,-103.5 473,-167.5"/>
<text text-anchor="middle" x="374" y="-151.9" font-family="DejaVu Sans" font-size="12.00">Réponse finale</text>
<text text-anchor="middle" x="374" y="-138.9" font-family="DejaVu Sans" font-size="12.00">réponse sans tool_calls</text>
<text text-anchor="middle" x="374" y="-125.9" font-family="DejaVu Sans" font-size="12.00">→ SSE « message »</text>
<text text-anchor="middle" x="374" y="-112.9" font-family="DejaVu Sans" font-size="12.00">(Markdown + sources citées)</text>
</g>
<!-- decide&#45;&gt;done -->
<g id="edge19" class="edge">
<title>decide&#45;&gt;done</title>
<path fill="none" stroke="#059669" stroke-width="2" d="M595.1,-260.3C551.33,-235.79 485.07,-198.69 436.49,-171.49"/>
<polygon fill="#059669" stroke="#059669" stroke-width="2" points="437.81,-169.02 429.46,-167.55 435.07,-173.9 437.81,-169.02"/>
<text text-anchor="middle" x="541.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">non</text>
</g>
<!-- final -->
<g id="node17" class="node">
<title>final</title>
<polygon fill="#fed7aa" stroke="#333333" points="250,-174 0,-174 0,-97 250,-97 250,-174"/>
<text text-anchor="middle" x="125" y="-158.4" font-family="DejaVu Sans" font-size="12.00">Budget épuisé (10 itérations</text>
<text text-anchor="middle" x="125" y="-145.4" font-family="DejaVu Sans" font-size="12.00">ou 25 appels) → _finalize_answer :</text>
<text text-anchor="middle" x="125" y="-132.4" font-family="DejaVu Sans" font-size="12.00">dernier appel SANS outils pour forcer</text>
<text text-anchor="middle" x="125" y="-119.4" font-family="DejaVu Sans" font-size="12.00">une synthèse, sinon liste des sources</text>
<text text-anchor="middle" x="125" y="-106.4" font-family="DejaVu Sans" font-size="12.00">déterministe (jamais de réponse vide)</text>
</g>
<!-- decide&#45;&gt;final -->
<g id="edge20" class="edge">
<title>decide&#45;&gt;final</title>
<path fill="none" stroke="#ea580c" d="M565.01,-267.84C491.13,-250.18 390,-226 390,-226 390,-226 314.36,-200.46 243.91,-176.66"/>
<polygon fill="#ea580c" stroke="#ea580c" points="244.71,-173.97 236.23,-174.07 242.92,-179.28 244.71,-173.97"/>
<text text-anchor="middle" x="429.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">budget atteint</text>
</g>
<!-- tools -->
<g id="node8" class="node">
<title>tools</title>
<polygon fill="#dcfce7" stroke="#333333" points="987,-57.5 787,-57.5 787,-19.5 987,-19.5 987,-57.5"/>
<text text-anchor="middle" x="887" y="-41.9" font-family="DejaVu Sans" font-size="12.00">Exécution de chaque tool call</text>
<text text-anchor="middle" x="887" y="-28.9" font-family="DejaVu Sans" font-size="12.00">via call_tool() (voir pipeline)</text>
</g>
<!-- thought&#45;&gt;tools -->
<g id="edge6" class="edge">
<title>thought&#45;&gt;tools</title>
<path fill="none" stroke="#555555" d="M701.61,-116.41C739.89,-100.53 795.6,-77.42 835.95,-60.68"/>
<polygon fill="#555555" stroke="#555555" points="837.23,-63.18 843.55,-57.53 835.09,-58 837.23,-63.18"/>
</g>
<!-- tools&#45;&gt;llm -->
<g id="edge7" class="edge">
<title>tools&#45;&gt;llm</title>
<path fill="none" stroke="#e11d48" d="M881.93,-57.8C864.2,-121.83 806,-332 806,-332 806,-332 764.31,-352.1 722.35,-372.34"/>
<polygon fill="#e11d48" stroke="#e11d48" points="721.06,-369.85 715.07,-375.85 723.49,-374.9 721.06,-369.85"/>
<text text-anchor="middle" x="930" y="-217.2" font-family="DejaVu Sans" font-size="11.00">itération ≤ 10</text>
<text text-anchor="middle" x="930" y="-205.2" font-family="DejaVu Sans" font-size="11.00">quota tools ≤ 25</text>
<text text-anchor="middle" x="930" y="-193.2" font-family="DejaVu Sans" font-size="11.00">(BOOKSLM_MAX_TOOL_CALLS)</text>
</g>
<!-- p1 -->
<g id="node10" class="node">
<title>p1</title>
<polygon fill="#ede9fe" stroke="#333333" points="1244,-743.5 1080,-743.5 1080,-705.5 1244,-705.5 1244,-743.5"/>
<text text-anchor="middle" x="1162" y="-727.9" font-family="DejaVu Sans" font-size="12.00">1 · Schéma Pydantic</text>
<text text-anchor="middle" x="1162" y="-714.9" font-family="DejaVu Sans" font-size="12.00">+ validation arguments</text>
</g>
<!-- tools&#45;&gt;p1 -->
<g id="edge15" class="edge">
<title>tools&#45;&gt;p1</title>
<path fill="none" stroke="#555555" d="M927.18,-57.59C988.4,-85.16 1098,-134.5 1098,-134.5 1098,-644.5 1098,-644.5 1098,-644.5 1098,-644.5 1123.27,-675.7 1142.06,-698.88"/>
<polygon fill="#555555" stroke="#555555" points="1139.94,-700.72 1147.16,-705.18 1144.3,-697.2 1139.94,-700.72"/>
</g>
<!-- back -->
<g id="node9" class="node">
<title>back</title>
<polygon fill="#333333" stroke="transparent" points="1056,-440 810,-440 810,-389 1056,-389 1056,-440"/>
<text text-anchor="middle" x="933" y="-424.4" font-family="DejaVu Sans" font-size="12.00">résultat (JSON, tronqué ≤100k chars)</text>
<text text-anchor="middle" x="933" y="-411.4" font-family="DejaVu Sans" font-size="12.00">ajouté à la conversation →</text>
<text text-anchor="middle" x="933" y="-398.4" font-family="DejaVu Sans" font-size="12.00">itération suivante</text>
</g>
<!-- back&#45;&gt;p1 -->
<!-- p2 -->
<g id="node11" class="node">
<title>p2</title>
<polygon fill="#ede9fe" stroke="#333333" points="1282,-662.5 1146,-662.5 1146,-624.5 1282,-624.5 1282,-662.5"/>
<text text-anchor="middle" x="1214" y="-646.9" font-family="DejaVu Sans" font-size="12.00">2 · Rate limit</text>
<text text-anchor="middle" x="1214" y="-633.9" font-family="DejaVu Sans" font-size="12.00">par identité + outil</text>
</g>
<!-- p1&#45;&gt;p2 -->
<g id="edge9" class="edge">
<title>p1&#45;&gt;p2</title>
<path fill="none" stroke="#555555" d="M1174.06,-705.18C1181.02,-694.61 1189.86,-681.17 1197.46,-669.62"/>
<polygon fill="#555555" stroke="#555555" points="1199.95,-670.95 1202,-662.72 1195.27,-667.87 1199.95,-670.95"/>
</g>
<!-- p3 -->
<g id="node12" class="node">
<title>p3</title>
<polygon fill="#ede9fe" stroke="#333333" points="1321.5,-572 1168.5,-572 1168.5,-521 1321.5,-521 1321.5,-572"/>
<text text-anchor="middle" x="1245" y="-556.4" font-family="DejaVu Sans" font-size="12.00">3 · Permission vault</text>
<text text-anchor="middle" x="1245" y="-543.4" font-family="DejaVu Sans" font-size="12.00">(+ déstructif si risque</text>
<text text-anchor="middle" x="1245" y="-530.4" font-family="DejaVu Sans" font-size="12.00">DANGEROUS)</text>
</g>
<!-- p2&#45;&gt;p3 -->
<g id="edge10" class="edge">
<title>p2&#45;&gt;p3</title>
<path fill="none" stroke="#555555" d="M1219.98,-624.18C1224.04,-611.72 1229.54,-594.88 1234.35,-580.15"/>
<polygon fill="#555555" stroke="#555555" points="1237.11,-580.7 1236.93,-572.23 1231.79,-578.96 1237.11,-580.7"/>
</g>
<!-- p4 -->
<g id="node13" class="node">
<title>p4</title>
<polygon fill="#f5f3ff" stroke="#333333" points="1278,-452.5 1136,-414.5 1278,-376.5 1420,-414.5 1278,-452.5"/>
<text text-anchor="middle" x="1278" y="-417.9" font-family="DejaVu Sans" font-size="12.00">4 · Risque READ ?</text>
<text text-anchor="middle" x="1278" y="-404.9" font-family="DejaVu Sans" font-size="12.00">→ exécution directe</text>
</g>
<!-- p3&#45;&gt;p4 -->
<g id="edge11" class="edge">
<title>p3&#45;&gt;p4</title>
<path fill="none" stroke="#555555" d="M1251.28,-520.74C1255.74,-503.18 1261.84,-479.16 1267.08,-458.5"/>
<polygon fill="#555555" stroke="#555555" points="1269.87,-458.92 1269.12,-450.48 1264.44,-457.54 1269.87,-458.92"/>
</g>
<!-- p5 -->
<g id="node14" class="node">
<title>p5</title>
<polygon fill="#fde68a" stroke="#333333" points="1413.5,-332 1136.5,-332 1136.5,-242 1413.5,-242 1413.5,-332"/>
<text text-anchor="middle" x="1275" y="-316.4" font-family="DejaVu Sans" font-size="12.00">PAUSE CONFIRMATION</text>
<text text-anchor="middle" x="1275" y="-303.4" font-family="DejaVu Sans" font-size="12.00">outil WRITE/DANGEROUS → event SSE</text>
<text text-anchor="middle" x="1275" y="-290.4" font-family="DejaVu Sans" font-size="12.00">« confirmation » avec le plan complet</text>
<text text-anchor="middle" x="1275" y="-277.4" font-family="DejaVu Sans" font-size="12.00">batché (BUG&#45;075) : une seule</text>
<text text-anchor="middle" x="1275" y="-264.4" font-family="DejaVu Sans" font-size="12.00">approbation applique toutes les mutations</text>
<text text-anchor="middle" x="1275" y="-251.4" font-family="DejaVu Sans" font-size="12.00">(resume via confirm / confirm_all)</text>
</g>
<!-- p4&#45;&gt;p5 -->
<g id="edge13" class="edge">
<title>p4&#45;&gt;p5</title>
<path fill="none" stroke="#d97706" d="M1277.12,-376.52C1276.85,-365.25 1276.54,-352.62 1276.26,-340.52"/>
<polygon fill="#d97706" stroke="#d97706" points="1279.05,-340.18 1276.06,-332.25 1273.45,-340.31 1279.05,-340.18"/>
<text text-anchor="middle" x="1301" y="-351.2" font-family="DejaVu Sans" font-size="11.00">mutation</text>
</g>
<!-- p6 -->
<g id="node15" class="node">
<title>p6</title>
<polygon fill="#dcfce7" stroke="#333333" points="1395.5,-161 1142.5,-161 1142.5,-110 1395.5,-110 1395.5,-161"/>
<text text-anchor="middle" x="1269" y="-145.4" font-family="DejaVu Sans" font-size="12.00">5 · Handler exécuté</text>
<text text-anchor="middle" x="1269" y="-132.4" font-family="DejaVu Sans" font-size="12.00">6 · Audit JSON&#45;lines</text>
<text text-anchor="middle" x="1269" y="-119.4" font-family="DejaVu Sans" font-size="12.00">7 · Redaction secrets (redact_payload)</text>
</g>
<!-- p4&#45;&gt;p6 -->
<g id="edge12" class="edge">
<title>p4&#45;&gt;p6</title>
<path fill="none" stroke="#555555" d="M1323.61,-388.38C1367.1,-364.44 1426,-332 1426,-332 1426,-332 1426,-242 1426,-242 1426,-242 1358.97,-196.96 1312.51,-165.73"/>
<polygon fill="#555555" stroke="#555555" points="1314.01,-163.37 1305.81,-161.24 1310.89,-168.02 1314.01,-163.37"/>
<text text-anchor="middle" x="1438.5" y="-284.2" font-family="DejaVu Sans" font-size="11.00">read</text>
</g>
<!-- p5&#45;&gt;p6 -->
<g id="edge14" class="edge">
<title>p5&#45;&gt;p6</title>
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M1273.23,-241.95C1272.3,-218.69 1271.17,-190.62 1270.32,-169.29"/>
<polygon fill="#555555" stroke="#555555" points="1273.11,-169.06 1269.99,-161.18 1267.51,-169.28 1273.11,-169.06"/>
<text text-anchor="middle" x="1298" y="-205.2" font-family="DejaVu Sans" font-size="11.00">approuvé</text>
</g>
<!-- p6&#45;&gt;tools -->
<!-- p6&#45;&gt;back -->
<!-- cluster_tools -->
<g id="node27" class="node">
<title>cluster_tools</title>
<polygon fill="#333333" stroke="#333333" points="1319,-56.5 1219,-56.5 1219,-20.5 1319,-20.5 1319,-56.5"/>
<text text-anchor="middle" x="1269" y="-35.4" font-family="DejaVu Sans" font-size="12.00">cluster_tools</text>
</g>
<!-- p6&#45;&gt;cluster_tools -->
<!-- answer -->
<g id="node18" class="node">
<title>answer</title>
<polygon fill="#dbeafe" stroke="#333333" points="347,-64 151,-64 151,-13 347,-13 347,-64"/>
<text text-anchor="middle" x="249" y="-48.4" font-family="DejaVu Sans" font-size="12.00">Affichage UI : bloc</text>
<text text-anchor="middle" x="249" y="-35.4" font-family="DejaVu Sans" font-size="12.00">« N étapes » Notion&#45;style</text>
<text text-anchor="middle" x="249" y="-22.4" font-family="DejaVu Sans" font-size="12.00">(pensées + outils dépliables)</text>
</g>
<!-- done&#45;&gt;answer -->
<g id="edge21" class="edge">
<title>done&#45;&gt;answer</title>
<path fill="none" stroke="#555555" d="M333.07,-103.39C318.5,-92.32 302.14,-79.89 287.76,-68.96"/>
<polygon fill="#555555" stroke="#555555" points="289.37,-66.66 281.3,-64.05 285.98,-71.12 289.37,-66.66"/>
</g>
<!-- final&#45;&gt;answer -->
<g id="edge22" class="edge">
<title>final&#45;&gt;answer</title>
<path fill="none" stroke="#555555" d="M174.01,-96.95C186.01,-87.76 198.68,-78.05 210.13,-69.28"/>
<polygon fill="#555555" stroke="#555555" points="212.07,-71.32 216.71,-64.24 208.66,-66.88 212.07,-71.32"/>
</g>
<!-- fam_vault -->
<g id="node19" class="node">
<title>fam_vault</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1958,-952.5 1720,-952.5 1720,-808.5 1958,-808.5 1958,-952.5"/>
<text text-anchor="middle" x="1839" y="-938.5" font-family="DejaVu Sans" font-size="10.00">VAULT · service.py (22)</text>
<text text-anchor="middle" x="1839" y="-927.5" font-family="DejaVu Sans" font-size="10.00">lecture : list_vaults, list_directory,</text>
<text text-anchor="middle" x="1839" y="-916.5" font-family="DejaVu Sans" font-size="10.00">list_all_files, read_file, read_file_raw,</text>
<text text-anchor="middle" x="1839" y="-905.5" font-family="DejaVu Sans" font-size="10.00">search_fulltext, search_advanced,</text>
<text text-anchor="middle" x="1839" y="-894.5" font-family="DejaVu Sans" font-size="10.00">search_paths, get_graph, get_backlinks,</text>
<text text-anchor="middle" x="1839" y="-883.5" font-family="DejaVu Sans" font-size="10.00">list_tags, suggest_tags, list_recent,</text>
<text text-anchor="middle" x="1839" y="-872.5" font-family="DejaVu Sans" font-size="10.00">list_backups, diff_backup</text>
<text text-anchor="middle" x="1839" y="-861.5" font-family="DejaVu Sans" font-size="10.00">écriture : create_file, create_directory,</text>
<text text-anchor="middle" x="1839" y="-850.5" font-family="DejaVu Sans" font-size="10.00">append_to_file, edit_file, restore_backup</text>
<text text-anchor="middle" x="1839" y="-839.5" font-family="DejaVu Sans" font-size="10.00">DANGEROUS : delete_file, delete_directory,</text>
<text text-anchor="middle" x="1839" y="-828.5" font-family="DejaVu Sans" font-size="10.00">move_path, rename_file, rename_directory,</text>
<text text-anchor="middle" x="1839" y="-817.5" font-family="DejaVu Sans" font-size="10.00">replace_in_files</text>
</g>
<!-- fam_web -->
<g id="node20" class="node">
<title>fam_web</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1938.5,-747 1739.5,-747 1739.5,-702 1938.5,-702 1938.5,-747"/>
<text text-anchor="middle" x="1839" y="-733" font-family="DejaVu Sans" font-size="10.00">WEB · web.py + crawler.py</text>
<text text-anchor="middle" x="1839" y="-722" font-family="DejaVu Sans" font-size="10.00">web_search · fetch_url</text>
<text text-anchor="middle" x="1839" y="-711" font-family="DejaVu Sans" font-size="10.00">crawl_site (≤20 pages, même hôte)</text>
</g>
<!-- fam_vault&#45;&gt;fam_web -->
<!-- fam_doc -->
<g id="node21" class="node">
<title>fam_doc</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1922,-666 1756,-666 1756,-621 1922,-621 1922,-666"/>
<text text-anchor="middle" x="1839" y="-652" font-family="DejaVu Sans" font-size="10.00">DOCUMENTS · documents.py</text>
<text text-anchor="middle" x="1839" y="-641" font-family="DejaVu Sans" font-size="10.00">create_pdf · create_docx</text>
<text text-anchor="middle" x="1839" y="-630" font-family="DejaVu Sans" font-size="10.00">create_xlsx · create_csv</text>
</g>
<!-- fam_web&#45;&gt;fam_doc -->
<!-- fam_xlsx -->
<g id="node22" class="node">
<title>fam_xlsx</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1944.5,-580 1733.5,-580 1733.5,-513 1944.5,-513 1944.5,-580"/>
<text text-anchor="middle" x="1839" y="-566" font-family="DejaVu Sans" font-size="10.00">TABLEURS · spreadsheets.py</text>
<text text-anchor="middle" x="1839" y="-555" font-family="DejaVu Sans" font-size="10.00">list_xlsx_sheets · xlsx_to_markdown</text>
<text text-anchor="middle" x="1839" y="-544" font-family="DejaVu Sans" font-size="10.00">search_workbook · analyze_range</text>
<text text-anchor="middle" x="1839" y="-533" font-family="DejaVu Sans" font-size="10.00">update_xlsx_cells · append_xlsx_rows</text>
<text text-anchor="middle" x="1839" y="-522" font-family="DejaVu Sans" font-size="10.00">edit_xlsx_structure</text>
</g>
<!-- fam_doc&#45;&gt;fam_xlsx -->
<!-- fam_dup -->
<g id="node23" class="node">
<title>fam_dup</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1929.5,-437 1748.5,-437 1748.5,-392 1929.5,-392 1929.5,-437"/>
<text text-anchor="middle" x="1839" y="-423" font-family="DejaVu Sans" font-size="10.00">DOUBLONS · duplicates.py</text>
<text text-anchor="middle" x="1839" y="-412" font-family="DejaVu Sans" font-size="10.00">find_duplicates</text>
<text text-anchor="middle" x="1839" y="-401" font-family="DejaVu Sans" font-size="10.00">merge_duplicate_notes (DANG.)</text>
</g>
<!-- fam_xlsx&#45;&gt;fam_dup -->
<!-- fam_git -->
<g id="node24" class="node">
<title>fam_git</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1931,-309.5 1747,-309.5 1747,-264.5 1931,-264.5 1931,-309.5"/>
<text text-anchor="middle" x="1839" y="-295.5" font-family="DejaVu Sans" font-size="10.00">GIT CONNECTÉ · connected.py</text>
<text text-anchor="middle" x="1839" y="-284.5" font-family="DejaVu Sans" font-size="10.00">git_list_repos · git_get_file</text>
<text text-anchor="middle" x="1839" y="-273.5" font-family="DejaVu Sans" font-size="10.00">git_search_issues (Gitea/GitHub)</text>
</g>
<!-- fam_dup&#45;&gt;fam_git -->
<!-- fam_sched -->
<g id="node25" class="node">
<title>fam_sched</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1942.5,-153.5 1735.5,-153.5 1735.5,-117.5 1942.5,-117.5 1942.5,-153.5"/>
<text text-anchor="middle" x="1839" y="-138.5" font-family="DejaVu Sans" font-size="10.00">SCHEDULER · scheduled.py</text>
<text text-anchor="middle" x="1839" y="-127.5" font-family="DejaVu Sans" font-size="10.00">list/create/delete/run_scheduled_task</text>
</g>
<!-- fam_git&#45;&gt;fam_sched -->
<!-- fam_notify -->
<g id="node26" class="node">
<title>fam_notify</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1917.5,-61 1760.5,-61 1760.5,-16 1917.5,-16 1917.5,-61"/>
<text text-anchor="middle" x="1839" y="-47" font-family="DejaVu Sans" font-size="10.00">NOTIFICATIONS · notify.py</text>
<text text-anchor="middle" x="1839" y="-36" font-family="DejaVu Sans" font-size="10.00">notify_external (Discord,</text>
<text text-anchor="middle" x="1839" y="-25" font-family="DejaVu Sans" font-size="10.00">Telegram, SMTP, webhook)</text>
</g>
<!-- fam_sched&#45;&gt;fam_notify -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 26 KiB

+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 |
+98
View File
@@ -0,0 +1,98 @@
# #166 · #168 · #170 — Agent IA phase 4 : doublons, notifications externes, tâches planifiées
> **Statut :** ✅ livré — **Effort :** ~5 jours | **Impacts :** 🟢
> **Références :** [Roadmap](../ROADMAP.md#--évolutions-agent-ia-liste-v12--phase-4--ids-obsigate-165--178) · [Changelog](../../CHANGELOG.md)
> **Guides :** [Assistant IA & Forge](../GUIDES/ASSISTANT_IA_FORGE.md) · [API REST](../GUIDES/API_REST.md) · [MCP](../GUIDES/MCP.md)
## 1. Périmètre
Trois items de la liste v1.2 « Phase 4 » livrés ensemble car ils partagent le
même socle (registre `@tool`, stores JSON verrouillés, notifications) :
| ID | Fonctionnalité | Entrées |
|---|---|---|
| #166 | Détection & fusion de doublons | `backend/services/duplicates.py`, `backend/tools/duplicates.py`, `backend/routers/duplicates.py` |
| #168 | Notifications externes Discord / Telegram / SMTP / webhook | `backend/notify.py`, `backend/tools/notify.py`, `backend/routers/notify.py` |
| #170 | Tâches planifiées type cron | `backend/scheduler.py`, `backend/tools/scheduled.py`, `backend/routers/scheduler.py` |
Règle transverse respectée : **tout nouvel outil = `@tool` + libellé
`labels.py` + clés i18n `ai.step.*` FR/EN + tests** (cf. `ai-tools-roadmap.md` §2).
## 2. #166 — Doublons
- **Score déterministe stdlib** (`similarity_score`) : Jaccard sur tokens
(frontmatter exclu, accents conservés) à 70 % + similarité du titre/first-line
(`difflib`) à 30 %. Pas de dépendance embeddings — l'index sémantique #70
reste un raffinement optionnel, pas un prérequis.
- **Scan borné** : 500 fichiers `.md` max, 200 Ko/fichier, pré-filtre Jaccard
avant le score complet, `truncated` exposé quand le plafond est atteint.
- **Fusion jamais sans filet** : backup des 2 fichiers avant écriture,
outil `merge_duplicate_notes` en `DANGEROUS` (carte « Tout approuver »),
route `POST /api/duplicates/merge` exige `{confirm: true}`, stratégies
`append` (défaut, avec marqueur d'origine) / `prefer_target` / `prefer_source`.
- **Outils** : `find_duplicates` (READ), `merge_duplicate_notes` (DANGEROUS).
## 3. #168 — Notifications externes
Canaux `discord` (webhook `discord.com`), `telegram` (Bot API + `chat_id`),
`smtp` (stdlib, STARTTLS + login) et `webhook` générique (JSON
`{event, title, message, timestamp, source}`).
- **Secrets** : jamais dans `notify_channels.json` — store `notify_secrets.json`
(0600) ou `OBSIGATE_NOTIFY_SECRET_<ID>` (même motif que #9 / BUG-026) ;
l'API n'expose que `***` + `has_secret`. Le token Telegram peut aussi venir
de `OBSIGATE_TELEGRAM_BOT_TOKEN`.
- **SSRF** : `validate_webhook_url` / `is_safe_target` réutilisés pour les
webhooks génériques et Telegram ; Discord valide son préfixe d'URL.
- **Déclencheurs** : `manual`, `schedule_failure`, `schedule_success`,
`duplicate_found` — choisis par canal. `broadcast()` n'échoue jamais en bloc
(résultat par canal, `last_error` persisté).
- **CRUD admin** (`/api/notify/channels`), test d'envoi authentifié
(`POST /api/notify/test`), outil `notify_external` (WRITE → confirmation).
## 4. #170 — Tâches planifiées
Store `data/scheduled_tasks.json` (RLock, écriture atomique tmp+replace).
Actions = outils existants, aucun nouveau chemin d'écriture :
- `create_file` / `append_to_file` → `backend.services.mutations` ;
- `notify` → `backend.notify.broadcast`.
Planifications `interval_hours` (≥ 0,25), `daily_time` (`HH:MM`) et `once_at`
(ISO-8601, one-shot désactivé après exécution). `tick()` exécute les tâches
dues, enregistre `last_status`/`last_error`/`run_count`/`next_run_at` et émet
`schedule_failure` via #168 (sauf quand l'action elle-même est `notify` —
anti-récursion). Boucle de fond dans le lifespan de `main.py` (tick 60 s via
`asyncio.to_thread`, désactivable par `OBSIGATE_SCHEDULER=0`).
Outils : `create_scheduled_task` / `list_scheduled_tasks` /
`delete_scheduled_task` / `run_scheduled_task_now` (WRITE sauf list).
La création vérifie l'accès au vault **et** son existence (404 sinon).
## 5. API REST (toutes avec `response_model`)
| Route | Rôle |
|---|---|
| `GET /api/duplicates?vault&threshold&limit&subdir` | paires candidates |
| `POST /api/duplicates/merge` | fusion (`confirm: true` obligatoire) |
| `GET/POST /api/notify/channels`, `PATCH/DELETE /api/notify/channels/{id}` | CRUD admin |
| `POST /api/notify/test` | test broadcast ou canal ciblé |
| `GET/POST /api/scheduler/tasks`, `PATCH/DELETE /api/scheduler/tasks/{id}`, `POST …/run` | CRUD + exécution manuelle |
## 6. Tests
`tests/test_duplicates.py` (16), `tests/test_notify_channels.py` (14),
`tests/test_scheduler.py` (18) : services, routes (fixture `client`,
auth désactivée), outils (confirmation `DANGEROUS` vérifiée), stores isolés
en tmp, réseau mocké (jamais d'Internet en CI). Labels couverts par le
garde-fou `test_tool_labels.py` (tout outil IN_APP doit avoir son libellé).
## 7. Limites assumées (V1)
- Similarité lexicale (pas d'embeddings) — seuils réglables par l'agent.
- Pas d'UI dédiée (API + agent uniquement) ; les clés i18n des étapes
existent déjà pour la section « N étapes ».
- Scheduler in-process (pas de persistance distribuée, tick 60 s) ;
Redis/APScheduler resteraient l'option multi-workers.
- SMTP sans OAuth2 (login STARTTLS) ; Slack natif non ciblé (webhook
générique compatible `incoming-webhook` utilisable tel quel).
+77
View File
@@ -0,0 +1,77 @@
# #85 — Refonte architecturale : découpage du monolithe & persistance d'état (phase 2)
> **Statut :** livré (T1→T10) — `backend/main.py` 4 827 → ~750 lignes, 14 routers,
> persistance partielle (stores verrouillés + rate-limit SQLite optionnel).
> Méthode : tranches à impact minimal, comportement inchangé, un domaine par
> commit, suite complète verte à chaque commit (1320 passed / 6 skipped).
## 1. Découpage du monolithe (T1→T9, comportement inchangé)
Chaque tranche déplace un domaine vers `backend/routers/` (handlers verbatim,
mêmes chemins/modèles/auth/tags OpenAPI), les modèles vers `backend/schemas.py`,
et ne committe que sur suite verte + `test_version` vert.
| Tranche | Domaine | Nouveau module | Version |
|---|---|---|---|
| T1 | health (`/api/health*`) | `routers/health.py` (+ `HealthResponse` → schemas) | 2.27.2 |
| T2 | webhooks CRUD | `routers/webhooks.py` | 2.27.3 |
| T3 | sharing (`/api/share*`, `/s/*`) | `routers/sharing.py` | 2.27.4 |
| T4 | backups (9 routes) | `routers/backups.py` (+ `Diff/Restore*` → schemas, `backend/sse.py`) | 2.27.5 |
| T5 | search (11 routes) | `routers/search.py` (+ modèles → schemas, `backend/search_executor.py`) | 2.27.6 |
| T6a | lecture fichiers | `routers/files_read.py` (+ modèles, `routers/helpers.py`) | 2.27.7 |
| T6b | mutations fichiers/dossiers | `routers/files_write.py` (+ 15 modèles → schemas) | 2.27.8 |
| T6c | media/pdf/export/guide | `routers/files_media.py` (Range helper → `helpers.py`) | 2.27.9 |
| T7 | config (12 routes) | `routers/config.py` (`_FALLBACK_MODELS` déplacé) | 2.27.10 |
| T8 | vaults + history + conflicts (13 routes) | `routers/vaults.py`, `history.py`, `conflicts.py` (+ `backend/watcher_state.py`) | 2.27.11 |
| T9 | realtime + render | `routers/realtime.py` (SSE + collab WS), `backend/render.py` | 2.27.12 |
`main.py` ne contient plus que l'assemblage : lifespan, middlewares, montage
des routers, racine `/api`, statique/SPA, 4 cales de compatibilité testées
(`_resolve_safe_path`, `_backup_file`, `_check_vault_writable`, `_get_backup_dir`).
Correctifs au passage : décorateur orphelin `/s/{token}` (double-enregistrement
de `/api/conflicts`), tag OpenAPI `media` inexistant (assignation par chemin
conservée), tests statiques frontend réalignés (`image-viewer`, `media-viewer`),
tests repointés vers les modules canoniques (`test_ai_models`, `test_api_main`).
## 2. Persistance d'état (T10)
| État | Avant | Après |
|---|---|---|
| JTI révoqués (`revoked_tokens.json`) | persisté, **sans verrou** | `RLock` (load/save/revoke/check) |
| `shares.json` | persisté, **sans verrou** | `RLock` (4 mutateurs) |
| `webhooks.json` + secrets | persistés, **sans verrou** | `RLock` (create/update/delete/secrets) |
| `api_keys.json` (tool-secrets) | persisté, **sans verrou** | `RLock` (set/delete) |
| Rate-limit auth | mémoire, mono-process | **inchangé par défaut** + option `OBSIGATE_RATELIMIT_DB` (SQLite WAL : mêmes fenêtres/budgets, partagé multi-workers, survit au redémarrage) |
| Index de recherche | mémoire, rebuild au démarrage | **conservé** (voir §3) |
| `users.json`, `api_tokens.json`, `vault_settings.json` | déjà verrouillés (BUG-029, #107) | inchangé |
Tests : `tests/test_store_locks.py` (4 — concurrence threads, pertes prouvées
sans verrou : 25/200 partages), `tests/test_ratelimit_store.py` (7 —
sémantique SQLite identique, persistance, concurrence 200/200).
Déjà existants et vérifiés (pas de code) : verrous `threading` + `asyncio`
de l'indexeur (`_index_lock`, `_async_index_lock`), contrat central des
outils IA — `backend/tools/registry.py` couvre déjà permissions
(`requires_vault`, `require_destructive_allowed`), quotas
(`check_and_record` par outil) et redaction (`redact_payload`) pour les
35 outils enregistrés via `@tool(`.
## 3. Décisions assumées (non fait, et pourquoi)
- **Index non persisté sur disque.** Le rebuild différentiel (#86 : réutilise
les entrées inchangées `size` + `mtime`) rend le démarrage rapide ; un
snapshot introduirait des risques de staleness/drift de format sans gain
mesuré. Réévaluer si le démarrage devient lent (vaults 50k+ fichiers).
- **Redis exclu.** SQLite WAL couvre le multi-workers mono-hôte sans nouvelle
infra ; Redis reste l'option multi-nœuds documentée (cf. `ratelimit.py`).
- **`.gitignore` (`_*.py` ignore les `__init__.py`).** Contourné par
`git add -f` comme les packages existants ; assainir la règle à part.
- Noms en `_` conservés (`backend/render.py`, stores) : déplacement verbatim,
zéro churn d'appels.
## 4. Reste connu (hors #85)
- CSP `unsafe-inline` (migration nonce, BUG-034 partiel) et `Secure` cookies → #87.
- `main.py` (~750 lignes) : lifespan, middlewares, statique/SPA — cible
d'extraction ultérieure si besoin, non bloquant.
+37
View File
@@ -86,3 +86,40 @@
- Python 3.11 embed — téléchargé depuis python.org
- NSIS (Windows) — pour le générateur d'installateur `.exe`
- AppImageKit (Linux) — pour le packaging portable
## G. Gestion des vaults & dossiers — #159 — ✅ livré (2026-10-02, v2.50.0)
- [x] Retrait vault/dossier racine par menu contextuel (`ContextMenuManager`,
branche `vault`, gate `isTauriEnv()`) → `removeRoot()` dans `desktop.js` :
résolution vault/dossier via `list_vaults`/`list_dirs`, confirmation i18n,
`remove_vault`/`remove_dir` + `restart_backend` + reload — déregistration
seule, zéro suppression disque.
- [x] Section Configuration `cfg-desktop-roots` (+ entrée TOC i18n FR/EN) :
liste des roots injectés, retrait par ligne, ajout vault
(`pickAndAddVault`, existant) et ajout dossier (nouvelle commande Rust
`pick_folder` — sélecteur sans effet de bord, contrairement à
`pick_vault_folder` réservé au wizard). Section masquée hors desktop.
- [x] Jump list rafraîchie après chaque ajout/retrait de vault
(`refresh_jumplist()`).
- [x] i18n FR/EN (7 clés `config.*`/`desktop.*`), garde-fou ACL automatique
(`test_frontend_invokes_are_acl_allowed` : toute commande invoquée par le
frontend doit figurer dans `permissions/commands.toml`).
- [x] Tests : `tests/frontend/desktop-roots.test.mjs` (4 — helpers purs +
gating hors desktop), inscrit au CI ; suites frontend/JSDOM vertes,
`cargo test` 25 passed.
## H. Premier lancement & section Configuration harmonisée — #160 — ✅ livré (2026-10-02, v2.51.0)
- [x] `default_first_run_config(home)` (pure, testée) : `<home>/ObsiGate`
monté comme vault « ObsiGate » ET comme `vault_path`, racine home nommée
d'après son dernier segment — remplace `voute_obsidian` + le « bruno »
codé en dur ; les champs fenêtre de la config existante sont préservés.
- [x] `Prise en main.md` : contenu embarqué (`include_str!("prise_en_main.md")`),
écrit au premier lancement **si absent** (jamais d'écrasement).
- [x] Section `cfg-desktop-roots` refondue : markup à classes (zéro style
inline), bloc CSS `#160` sur variables (`desktop-roots-*`, motif
`webauthn-key-item`), boutons `.config-btn-sm` (primaire/secondaire),
override mobile 44px dans le bloc `#config-modal`.
- [x] Tests : `test_default_first_run_config`, `test_welcome_doc_embedded`
— `cargo test` 28 passed ; suites frontend vertes (validate-imports,
unit, config-mobile, desktop-roots, settings-order) + E2E locale.
+72
View File
@@ -0,0 +1,72 @@
# #158 — Navigation : vue répertoire en onglet, filtres & tris, retour Home complet
> **Statut :** livré le 2026-10-02 · **ID :** `#158` (correctif associé : `BUG-100`)
> **Fichiers :** `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`,
> `frontend/js/navfacets.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/legacy.js`,
> `backend/services/vaults.py`, `backend/schemas.py`, `frontend/style.css`,
> `frontend/locales/{fr,en}.json`
## Problème
La vue « chemin + Récents + fichiers du répertoire » (vault home) ne s'affichait qu'en mode
focus vault, disparaissait dès qu'un fichier était ouvert (aucun onglet), ne listait pas les
sous-répertoires, n'avait ni filtre ni tri, et le clic sur le titre ne rendait pas la vraie
page d'accueil.
## Conception
**Un onglet de navigation par vault.** `TabManager.openNav(vault, dir)` (`frontend/js/ui.js`)
crée/rétarget l'onglet `nav::<vault>` (icône dossier) ; `TabManager.activate()` branche sur
`cache.nav` et rend `showVaultHome(vault, dir)` au lieu de fetcher un fichier. Les onglets de
fichiers ne sont jamais fermés. Le dashboard appelle `TabManager.deactivate()` : aucun onglet
ne reste « actif », donc un clic suivant ré-éffectue réellement l'onglet (avant : early-return
et vue figée).
Points d'entrée des clics (`frontend/js/sidebar.js`) : clic **répertoire** dans l'arbre (2 sites,
mode focus et mode All), clic sur la **racine d'un vault** (2 sites, expansion + navigation),
changement de contexte vault (sélecteur), facettes/filtres et fil d'Ariane de la vue
(`vaulthome.js` route tout par `openNav`, plus aucune délégation sur l'arbre).
**Menus contextuels** : les répertoires et les fichiers de la vue appellent le même
`ContextMenuManager.show()` que la sidebar, avec `stopPropagation()` — le handler global de
`ContextMenuManager` referme le menu sur tout clic droit hors `.tree-item`.
**Onglet Accueil** (`ui.js::openHome` + `pane-manager.js::openHome`) : le clic sur le titre
crée (ou remonte en position 0) l'onglet `home` — icône Maison, libellé i18n `common.home` —
et l'active ; `activate()` rend la page d'accueil via `showWelcome()` et `deactivate()`
conserve l'onglet Accueil actif (il *est* la page d'accueil). En mode split, l'onglet vit dans
le panneau actif (délégation `getActiveTabManager()`, libellé transmis par l'appelant pour ne
pas d'importer `t` dans `pane-manager.js`, qui déclare déjà des variables locales `t`).
**Contenu de la vue** (`vaulthome.js`) : fil d'Ariane · barre **Pertinence / Date** + **Sauver**
· panneau de facettes · **Répertoires** cliquables (`GET /api/browse`) · Récents (racine) ·
fichiers (`GET /api/vault/{v}/files?recursive=false`). Les facettes réutilisent les classes de
la page de recherche (`search-facets`, `search-sort`, `search-save-btn`) — même visuel, même
repli `obsigate_facets_collapsed`, filtrage **local** (pas de nouvelle requête).
**Facettes** (`frontend/js/navfacets.js`, pur, sans DOM) : `buildNavFacets`,
`filterNavFiles`, `sortNavFiles`. Le listing embarque les tags indexés :
`GET /api/vault/{v}/files` renvoie `tags` (`_indexed_tags()` dans
`backend/services/vaults.py`, lookup `vault::path` dans l'index inversé, `[]` si non indexé).
## Décisions / limites connues
- **Un seul onglet de navigation par vault** : cliquer un autre répertoire re-cible le même
onglet (pas d'empilement d'onglets). En mode split, la vue s'affiche dans la zone principale.
- **« Pertinence » = tri alphabétique** (`ponytail:` commenté) : un listing de répertoire n'a
aucun signal de pertinence tant que la vue n'a pas sa propre requête — voir
`navfacets.js::sortNavFiles`.
- Facettes calculées sur la liste courante (répertoire non récursif, 200 fichiers max) : les
sous-répertoires non listés n'apportent aucune compte.
## Tests
- `tests/test_nav_files.py` (3) : `tags` exposés + survivent au `response_model`, tags en
sous-répertoire, miss d'index → `[]`.
- `tests/frontend/navfacets.test.mjs` (10) : comptages, filtres tag/ext (insensible au point),
combinaison ET, tris, non-mutation.
- `tests/frontend/unit.test.mjs` : `navfacets.js` ajouté à la liste des modules purs.
- `tests/e2e/nav-tab.spec.js` (4) : racine vault → page de navigation, icône + menus
contextuels identiques à la sidebar, onglet Accueil en tête de barre (avec `#quick-help`).
- Vérification manuelle Playwright (jetable) : 22/22 puis 16/16 checks sur les lots A1-A3 et
A4-A6.
+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 |
+72
View File
@@ -0,0 +1,72 @@
# #179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile
> **Statut :** 🔵 en cours (2026-10-04) | **Effort :** 1-2 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) · [#156](./xlsx-editor-completeness.md)
## 1. Constat
La grille (#155), les menus Structure / Mise en forme / Export (#156-A8/A11/A14)
et les boutons du ruban (#154) étaient textuels, et les menus Structure / Mise
en forme / Export **restaient ouverts** quand le focus les quittait (seul le
menu grille se fermait, au clic extérieur / `Échap` uniquement).
## 2. Livré (A1-A4)
### A1 — Icônes du menu contextuel de la grille
Chaque entrée de `buildContextItems` (`viewer.js`) porte une icône Lucide
(`scissors`, `copy`, `clipboard-paste`, `rows`/`columns`, `trash-2` danger,
`arrow-up`/`arrow-down`, `eraser`). Rendu dans `xlsx/context-menu.js`
(`<i data-lucide>` + `<span class="xlsx-menu-label">`, label en `textContent`
— jamais d'HTML injecté), hydraté par `safeCreateIcons()` (jamais
`lucide.createIcons()` direct).
### A2 — Icônes Structure / Mise en forme / Export + boutons
- Structure : `file-plus`, `pencil`, `copy`, `trash-2` (danger), `rows`,
`columns` ; suppressions marquées `.xlsx-context-danger`.
- Mise en forme : `bold`/`italic`/`underline`, `remove-formatting`,
`align-left/center/right`, `palette`/`paint-bucket` (sélecteurs natifs),
`type`/`hash`/`percent`/`euro`/`calendar`/`pilcrow` (formats),
`combine`/`ungroup`, `snowflake`/`sun` (figer/libérer),
`move-horizontal`/`move-vertical` (largeur/hauteur).
- Export : `file-text` (MD), `code` (HTML), `printer` (impression).
- Ruban : bouton Enregistrer (`save` + libellé), onglet « + » (`plus`),
`aria-haspopup="menu"` / `aria-expanded` sur les 3 boutons à menu.
### A3 — Fermeture à la perte de focus + clavier
- `trackDismissable(menu, { toggle, onClose })` dans `context-menu.js` :
clic extérieur (le toggle garde ses propres clics), `Échap`, `focusout`
(vers menu **et** toggle exemptés), scroll (capture), resize ; `dismiss()`
idempotent qui débranche tout (pas de fuite entre re-rendus).
- Menu grille : `focusout` + scroll/resize en plus de l'existant ;
`enableArrowNav()` (`↓`/`↑`/`Home`/`End`, désactivés sautés), autofocus de
la 1ʳᵉ entrée à l'ouverture (`preventScroll`), `:focus` toujours visible.
- Menus Structure / Mise en forme / Export branchés sur le helper.
### A4 — Polish mobile
- `@media (pointer: coarse)` : entrées ≥ 44 px, police 0,9 rem.
- Menus bornés au viewport (grille : `min(320px, 86vw)` + `max-height: 80vh`
scrollable ; structure : `min(320px, 92vw)` ; export : `86vw`).
- Le ruban s'enroule déjà (`flex-wrap`), l'inspecteur s'empile ≤ 900 px :
vérifié sans débordement horizontal à 393 px (E2E projet mobile).
## 3. Tests
- `tests/frontend/xlsx-menus.test.mjs` (11) : rendu icônes + XSS, autofocus,
navigation clavier, `Échap` / clic / `focusout` / scroll / resize,
`trackDismissable`, icônes du ruban.
- `tests/frontend/xlsx-viewer.test.mjs` (+6, 113 total) : icônes et fermetures
au niveau viewer réel.
- `tests/e2e/xlsx-viewer.spec.js` (+5) : icônes par entrée, fermeture clic/`Échap`,
Structure au focus perdu, Export au clic extérieur, mobile (ruban, 44 px,
viewport) — ce dernier `skip` hors viewport ≤ 768 px.
## 4. Limites assumées
- Noms d'icônes de la gamme Lucide 0.344.0 (CDN `unpkg`) : si le CDN est
injoignable, les libellés restent (dégradation texte, jamais de crash).
- Pas de nouveaux libellés i18n (icônes + `aria-expanded` uniquement).
+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é** |
+51 -38
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">
@@ -1576,17 +1575,6 @@
class="help-search-clear"
id="config-search-clear"
title="Effacer"
onclick="
var s =
document.getElementById(
'config-nav-search',
);
if (s) {
s.value = '';
s.dispatchEvent(new Event('input'));
s.focus();
}
"
>
X
</button>
@@ -1599,6 +1587,7 @@
<li><a href="#cfg-recent" class="help-nav-link" data-i18n="config.section_recent"></a></li>
<li><a href="#cfg-tags" class="help-nav-link" data-i18n="config.section_tags"></a></li>
<li><a href="#cfg-hidden-files" class="help-nav-link" data-i18n="config.section_hidden"></a></li>
<li><a href="#cfg-desktop-roots" class="help-nav-link" data-i18n="config.section_desktop"></a></li>
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
<li><a href="#cfg-diags" class="help-nav-link" data-i18n="settings.diagnostics"></a></li>
@@ -1698,7 +1687,7 @@
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
</div>
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout">Déconnexion</button>
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
</div>
</section>
@@ -1962,6 +1951,45 @@
</div>
</section>
<!-- Desktop vaults & root directories (#159, #160) -->
<section
id="cfg-desktop-roots"
class="config-section help-section"
>
<h2 data-i18n="config.section_desktop">🖥️ Vaults &amp; dossiers (Desktop)</h2>
<p
class="config-description"
data-i18n="config.desktop_roots_desc"
>
Ajoutez ou retirez les vaults et dossiers
racine chargés par l'application desktop.
</p>
<div
id="desktop-roots-list"
class="desktop-roots-list"
>
<!-- Racines injectées par renderDesktopRoots() -->
</div>
<div class="config-actions-row desktop-roots-actions">
<button
class="config-btn-primary config-btn-sm"
id="cfg-desktop-add-vault"
data-i18n="desktop.add_vault"
>
📁 Ajouter un vault
</button>
<button
class="config-btn-secondary config-btn-sm"
id="cfg-desktop-add-dir"
data-i18n="desktop.add_dir"
>
📂 Ajouter un dossier
</button>
</div>
</section>
<!-- Watcher / Synchronisation -->
<section
class="config-section help-section"
@@ -3005,14 +3033,6 @@
id="help-hamburger"
title="Sommaire"
aria-label="Afficher le sommaire"
onclick="
var n = document.getElementById('help-nav');
if (n) {
var d = n.style.display;
n.style.display =
d === 'none' || d === '' ? 'flex' : 'none';
}
"
>
<i
data-lucide="menu"
@@ -3077,17 +3097,6 @@
id="help-search-clear"
title="Effacer la recherche"
aria-label="Effacer"
onclick="
var s =
document.getElementById(
'help-nav-search',
);
if (s) {
s.value = '';
s.dispatchEvent(new Event('input'));
s.focus();
}
"
>
✕
</button>
@@ -3725,6 +3734,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>
@@ -4661,9 +4674,9 @@
conversation.
</li>
<li data-i18n="help.assistant_agent">
Le bouton « mode agent » active les outils (lire, lister,
chercher) ; les actions de modification demandent une
confirmation avec aperçu des changements.
L'assistant utilise toujours ses outils (lire, lister,
chercher, modifier) ; les actions de modification demandent
une confirmation avec aperçu des changements.
</li>
<li data-i18n="help.assistant_agent_run">
Les actions s'affichent dans le fil : l'assistant regroupe les
+2 -2
View File
@@ -46,8 +46,8 @@ export const ACTION_CATALOG = Object.freeze([
{ id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' },
{ id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' },
{ id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' },
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true },
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true },
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt' },
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt' },
{ id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' },
{ id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' },
// ── Code & Scripts ───────────────────────────────────────────────────
+24 -3
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();
}
@@ -1305,8 +1316,7 @@ async function _startMfaSetup() {
// secret when the backend has no QR generator available.
const qrImg = data.qr_data_url
? `<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code-img"
src="${data.qr_data_url}"
onerror="this.style.display='none';document.getElementById('mfa-qr-fallback').style.display='block';">`
src="${data.qr_data_url}">`
: "";
const fallbackStyle = data.qr_data_url ? "display:none" : "";
flowArea.innerHTML = `
@@ -1335,6 +1345,17 @@ async function _startMfaSetup() {
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
});
// QR fallback (#87, ex-onerror inline) : si l'image ne charge pas,
// afficher la saisie manuelle du secret.
const qrImgEl = document.getElementById("mfa-qr-img");
if (qrImgEl) {
qrImgEl.addEventListener("error", () => {
qrImgEl.style.display = "none";
const fallback = document.getElementById("mfa-qr-fallback");
if (fallback) fallback.style.display = "block";
});
}
document.getElementById("mfa-confirm-btn").addEventListener("click", async () => {
const code = codeInput.value.trim();
if (code.length !== 6) return;
+14 -43
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',
]);
/**
@@ -149,9 +151,8 @@ class BooksLM {
this._sessions = [];
this._currentSessionId = null;
this._pendingNewSession = false;
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
// call read/search tools and propose mutations (confirmation cards).
this._agentMode = this._readAgentMode();
// #187: the assistant is always in agent mode (tools on /agent endpoint);
// the former header toggle was removed — images still use the /chat path.
// Ad-hoc context added with `@` (files/directories) and images attached
// by paste or by mentioning an image file.
this._adhocFiles = [];
@@ -186,33 +187,6 @@ class BooksLM {
this._extensions = null;
}
_readAgentMode() {
try {
return localStorage.getItem('obsigate-bookslm-agent') === 'true';
} catch {
return false;
}
}
_toggleAgentMode() {
this._agentMode = !this._agentMode;
try {
localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false');
} catch { /* private mode */ }
this._updateAgentToggle();
}
_updateAgentToggle() {
if (!this._panel) return;
const btn = this._panel.querySelector('.bookslm-btn-agent');
if (!btn) return;
btn.classList.toggle('active', this._agentMode);
const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off');
btn.title = label;
btn.setAttribute('aria-label', label);
btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false');
}
// ── Public API ──────────────────────────────────────────────────────
/**
@@ -964,7 +938,6 @@ class BooksLM {
</div>
<span class="bookslm-qa-badge hidden" data-qa-context=""></span>
<div class="bookslm-header-actions">
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
@@ -1009,7 +982,6 @@ class BooksLM {
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode());
panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => {
e.stopPropagation();
this._toggleHistoryMenu();
@@ -1550,10 +1522,9 @@ class BooksLM {
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
/** #97/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */
/** #97/#99 — Deep Research: a skill-like chip (the agent tools are always on). */
_startDeepResearch() {
this._closeExtMenu();
if (!this._agentMode) this._toggleAgentMode();
this._activeDeepResearch = true;
this._renderAttachments();
showToast(t('bookslm.deep_research_started'), 'info');
@@ -2199,15 +2170,14 @@ class BooksLM {
}
/** Immediate-send the action prompt through the composer (same path as a
* typed message: skills, agent mode and images all keep working). Actions
* flagged `agent` (they mutate the document) transparently switch the
* assistant to agent mode first — same pattern as Deep Research. */
* typed message: skills and images all keep working). #187: the assistant
* is always agent mode, so the former `agent: true` quick actions need no
* mode switch anymore. */
_runQuickAction(action) {
if (!this._panel || !action) return;
const { prompt } = actionTexts(action);
if (!prompt) return;
this._closeActionDrawer();
if (action.agent && !this._agentMode) this._toggleAgentMode();
const textarea = this._panel.querySelector('textarea');
if (!textarea) return;
textarea.value = prompt;
@@ -3251,14 +3221,15 @@ class BooksLM {
this._saveHistory();
}
/** POST to /chat or /agent depending on the agent-mode toggle. */
/** POST to /agent (always) or /chat when images are attached. */
_postChat(payload) {
// Images require the plain multimodal chat endpoint (the agent loop is
// #187: the assistant is always agent mode — images are the only case
// that still needs the plain multimodal chat endpoint (the agent loop is
// text/tool oriented).
const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
const endpoint = (this._agentMode && !hasImages)
? '/api/ai/bookslm/agent'
: '/api/ai/bookslm/chat';
const endpoint = hasImages
? '/api/ai/bookslm/chat'
: '/api/ai/bookslm/agent';
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch(endpoint, {
method: 'POST',
+88 -4
View File
@@ -7,6 +7,7 @@ import { escapeHtml, safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, setLocale, getLocale } from './i18n.js';
import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
import { isTauriEnv, invoke, pickAndAddVault, pickAndAddDir, removeRoot } from './desktop.js';
let _recentTimestampTimer = null;
let _recentFilesCache = [];
@@ -363,6 +364,27 @@ function initHelpModal() {
}
});
// Help TOC hamburger + search clear (#87, ex-onclick inline in index.html).
var helpHamburger = document.getElementById("help-hamburger");
if (helpHamburger) {
helpHamburger.addEventListener("click", function() {
var n = document.getElementById("help-nav");
if (n) {
var d = n.style.display;
n.style.display = d === "none" || d === "" ? "flex" : "none";
}
});
}
var helpSearch = document.getElementById("help-nav-search");
var helpSearchClear = document.getElementById("help-search-clear");
if (helpSearchClear && helpSearch) {
helpSearchClear.addEventListener("click", function() {
helpSearch.value = "";
helpSearch.dispatchEvent(new Event("input"));
helpSearch.focus();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
closeHelpModal();
@@ -755,6 +777,52 @@ function closeHelpModal() {
if (modal) modal.classList.remove("active");
}
// ── Vaults & root directories — desktop only (#159) ──────────────────────
// Lists the roots the Tauri shell injects into the backend (vaults +
// directories) and offers add/remove. The whole section — including its TOC
// entry — is hidden outside the desktop app.
async function renderDesktopRoots() {
const section = document.getElementById("cfg-desktop-roots");
const navLink = document.querySelector('#config-nav a[href="#cfg-desktop-roots"]');
if (!section) return;
if (!isTauriEnv()) {
section.style.display = "none";
if (navLink && navLink.parentElement) navLink.parentElement.style.display = "none";
return;
}
section.style.display = "";
if (navLink && navLink.parentElement) navLink.parentElement.style.display = "";
const vaults = (await invoke("list_vaults")) || [];
const dirs = (await invoke("list_dirs")) || [];
const list = document.getElementById("desktop-roots-list");
if (!list) return;
const row = (name, path, kind) =>
'<div class="desktop-roots-item">' +
`<i data-lucide="${kind === "vault" ? "vault" : "folder"}" class="desktop-roots-icon"></i>` +
'<div class="desktop-roots-text">' +
`<span class="desktop-roots-name">${escapeHtml(name)}</span>` +
`<span class="desktop-roots-path" title="${escapeHtml(path)}">${escapeHtml(path)}</span>` +
"</div>" +
`<button class="config-btn-secondary config-btn-sm" data-root="${escapeHtml(name)}">` +
`<i data-lucide="folder-minus" class="desktop-roots-btn-icon"></i>${escapeHtml(t("desktop.remove_root"))}</button>` +
"</div>";
if (!vaults.length && !dirs.length) {
list.innerHTML = `<div class="desktop-roots-empty">${escapeHtml(t("desktop.roots_empty"))}</div>`;
} else {
list.innerHTML = vaults.map((v) => row(v.name, v.path, "vault")).join("") +
dirs.map((d) => row(d.name, d.path, "dir")).join("");
for (const btn of list.querySelectorAll("button[data-root]")) {
// onclick (not addEventListener): the modal re-renders on every open.
btn.onclick = () => removeRoot(btn.dataset.root, t("desktop.remove_root_confirm", { name: btn.dataset.root }));
}
}
const addVaultBtn = document.getElementById("cfg-desktop-add-vault");
const addDirBtn = document.getElementById("cfg-desktop-add-dir");
if (addVaultBtn) addVaultBtn.onclick = () => pickAndAddVault();
if (addDirBtn) addDirBtn.onclick = () => pickAndAddDir();
safeCreateIcons();
}
function initConfigModal() {
const openBtn = document.getElementById("config-open-btn");
const closeBtn = document.getElementById("config-close");
@@ -785,6 +853,7 @@ function initConfigModal() {
loadTokensUI();
loadSharesUI();
loadToolKeys();
renderDesktopRoots();
safeCreateIcons();
});
@@ -883,7 +952,7 @@ function initConfigModal() {
});
}
// Logout button — handled inline in index.html (onclick)
// Logout button — wired here with addEventListener (#87, no inline onclick)
// Config nav search
var cfgSearch = document.getElementById("config-nav-search");
@@ -901,6 +970,16 @@ function initConfigModal() {
});
}
// Config search clear button (#87, ex-onclick inline).
var cfgSearchClear = document.getElementById("config-search-clear");
if (cfgSearchClear && cfgSearch) {
cfgSearchClear.addEventListener("click", function() {
cfgSearch.value = "";
cfgSearch.dispatchEvent(new Event("input"));
cfgSearch.focus();
});
}
// BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav
// rule that hides it below 768px, but — unlike the help modal — the config
// modal had no toggle to reveal it, leaving mobile users with no way to
@@ -1191,7 +1270,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"],
],
},
{
@@ -1575,13 +1654,15 @@ export async function openShareDialog(vault, path) {
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
${expiresInfo}
<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p>
<input type="text" class="share-url-input" value="${url}" readonly onclick="this.select()">
<input type="text" class="share-url-input" value="${url}" readonly>
<div class="share-dialog-actions">
<button class="share-copy-btn">📋 Copier le lien</button>
<button class="share-revoke-btn">🗑 Révoquer</button>
<button class="share-close-btn">Fermer</button>
</div>
</div>`;
const shareUrlInput = div.querySelector(".share-url-input");
if (shareUrlInput) shareUrlInput.addEventListener("click", function() { shareUrlInput.select(); });
div.querySelector(".share-copy-btn").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(url);
@@ -2525,7 +2606,10 @@ function initProfile() {
} catch(e) {}
});
// Logout button — handled inline in index.html (onclick)
// Logout button (#87, ex-onclick inline in index.html).
if (logoutBtn && window.handleLogout) {
logoutBtn.addEventListener('click', function() { window.handleLogout(); });
}
// ── Avatar (#113) ────────────────────────────────────────────────
var avatarField = document.getElementById('profile-avatar-field');
+52 -2
View File
@@ -163,14 +163,64 @@ export async function pickAndAddVault() {
if (!path) return undefined;
// Derive a display name from the final path segment.
const segments = path.replace(/[\\/]+$/, '').split(/[\\/]/);
const name = segments[segments.length - 1] || 'Vault';
const name = lastPathSegment(path) || 'Vault';
await invoke('add_vault', { name: name, path: path });
await invoke('restart_backend');
return path;
}
// ── Vault & root-directory management (#159) ────────────────────────────
// The Tauri shell injects vaults (VAULT_N_*) and root directories (DIR_N_*)
// into the backend as environment variables. These helpers unregister them
// from the desktop config, restart the backend and reload the page.
// Pure: classify a root name against the desktop config lists.
// Returns 'vault' | 'dir' | null.
export function resolveRootKind(vaults, dirs, name) {
if ((vaults || []).some((v) => v.name === name)) return 'vault';
if ((dirs || []).some((d) => d.name === name)) return 'dir';
return null;
}
// Pure: last path segment, Windows or POSIX separators, trailing
// separators ignored ('' for root-ish inputs).
export function lastPathSegment(path) {
const segments = String(path).replace(/[\\/]+$/, '').split(/[\\/]/);
return segments[segments.length - 1] || '';
}
// Remove a vault or a root directory from the desktop config. `confirmMsg`
// (already translated) gates the destructive step; nothing on disk is
// deleted. Returns 'vault' | 'dir', or undefined when not on desktop,
// cancelled, unknown root, or a command failed.
export async function removeRoot(name, confirmMsg) {
if (!isTauriEnv() || !name) return undefined;
if (confirmMsg && !window.confirm(confirmMsg)) return undefined;
const vaults = (await invoke('list_vaults')) || [];
const dirs = (await invoke('list_dirs')) || [];
const kind = resolveRootKind(vaults, dirs, name);
if (!kind) return undefined;
const res = await invoke(kind === 'vault' ? 'remove_vault' : 'remove_dir', { name: name });
if (res === undefined) return undefined; // command failed (see invoke wrapper)
await invoke('restart_backend');
window.location.reload();
return kind;
}
// Add a root directory: side-effect-free native folder picker, register it,
// restart the backend and reload. Mirrors pickAndAddVault.
export async function pickAndAddDir() {
if (!isTauriEnv()) return undefined;
const path = await invoke('pick_folder');
if (typeof path !== 'string' || !path) return undefined;
const name = lastPathSegment(path) || 'Dossier';
await invoke('add_dir', { name: name, path: path });
await invoke('restart_backend');
window.location.reload();
return path;
}
// ── First-run wizard (DOM) ─────────────────────────────────────────────────
// Shows a dismissible welcome banner on first desktop launch offering to pick
// the user's Obsidian vault folder. Non-blocking; the default vault created
+1 -1
View File
@@ -35,7 +35,7 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
// Build the iframe
const iframe = document.createElement('iframe');
iframe.id = editorId;
iframe.src = '/static/excalidraw-editor.html?v=' + Date.now();
iframe.src = '/excalidraw-editor.html?v=' + Date.now();
iframe.sandbox.add('allow-scripts');
iframe.sandbox.add('allow-same-origin');
// Let the editor's own Fullscreen button work (native Fullscreen API inside
+14 -1
View File
@@ -288,6 +288,14 @@ function initSearch() {
}
function goHome() {
// #158 — a refresh-equivalent home: wipe the global search (chips, result
// bar, filters) and the sidebar filter bar before showing the dashboard.
const searchClear = document.getElementById("search-clear-btn");
if (searchClear) searchClear.click();
const sideInput = document.getElementById("sidebar-filter-input");
const sideClear = document.getElementById("sidebar-filter-clear-btn");
if (sideClear && sideInput && sideInput.value) sideClear.click();
const searchInput = document.getElementById("search-input");
if (searchInput) searchInput.value = "";
@@ -299,7 +307,12 @@ function goHome() {
state.cachedRawSource = null;
closeMobileSidebar();
showWelcome();
// #158 — la page d'accueil s'ouvre dans un onglet épinglé en tête de barre
if (window.TabManager && window.TabManager.openHome) {
window.TabManager.openHome();
} else {
showWelcome();
}
}
// =========================================================================

Some files were not shown because too many files have changed in this diff Show More