Compare commits

...
51 Commits
Author SHA1 Message Date
bruno d6fdcef4d4 feat(ai): assistant AI multi-contexte + ergonomie sidebar et clavier
CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Contexte:
- Repertoire (mode historique BooksLM, via menu contextuel dossier)
- Documents (bouton flottant quand des documents sont ouverts)
- General (bouton flottant sans document: aide app + creation de fichiers)
- Le contexte est affiche dans le header; suggestions adaptees au mode
- Blocs obsigate-action rendus en carte avec bouton Appliquer (pas d'execution auto)

Backend:
- /api/ai/bookslm/context et /chat acceptent mode + context_files
- collect_files_context(), empty_context(), build_general_system_prompt()
- mode documents degrade vers general si aucun fichier lisible

Corrige:
- bouton flottant passait le chemin d'un fichier comme repertoire -> erreur
  'Aucun fichier markdown trouve dans ce dossier'
- panneau precedent masque (localStorage) ne se rouvrait plus
- selecteur fournisseur/modele deplace sur une ligne dediee (header actions visibles)
- Entree envoie, Ctrl+Entree insere un saut de ligne

Tests: test_bookslm.py (+13) et tests/frontend/ai.test.mjs (+8)
2026-09-11 09:11:00 -04:00
bruno 62023acd4d feat(api,ai): #72 OpenAPI 3.1 enrichie + fiabilisation de l'outil AI UI
CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
API (#72):
- backend/openapi_docs.py: 18 tags documentes, assignation auto par prefixe,
  securite bearerAuth/cookieAuth, erreurs 401/403/404/422/500, exemples,
  page /api autonome (liens /docs, /redoc, /openapi.json)
- backend/schemas.py: response_model Pydantic pour ~35 endpoints sans modele
- main.py: FastAPI enrichi + override app.openapi; routes /api et /api/
- ai_routes/bookslm_routes: response_model + doc SSE
- frontend: entree 'API' du menu (i18n FR/EN)
- tests/test_openapi.py (58 tests)

AI UI:
- BooksLM: requetes authentifiees (AuthManager), parsing SSE {token}/error,
  message utilisateur correct (plus le placeholder vide), barre de contexte
- AI Editor: Ctrl/Cmd+J lie une seule fois, libelles i18n, modale de
  reecriture accessible a la place de window.prompt()
- tests/frontend/ai.test.mjs (7 tests) + CI
2026-09-11 01:40:21 -04:00
bruno 75ef597ece ci(e2e): rendre le vault de test inscriptible (docker cp root -> UID 1000)
CI / lint (push) Successful in 54s
CI / security (push) Successful in 39s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 32s
CI / e2e (push) Successful in 10m32s
Les fixtures copiees via docker cp appartiennent a root ; l'app tourne en UID 1000 et os.access(W_OK) renvoyait False -> POST /api/file 403 'Vault is read-only' -> le test E2E de creation .excalidraw (C8) echouait. Ajout d'un chmod -R a+rwX apres le health check.
2026-09-10 23:07:33 -04:00
bruno 0cbbbf260a fix(search,excalidraw,e2e): recherche cassee + corruption .excalidraw.md + tests E2E #78
CI / lint (push) Successful in 54s
CI / security (push) Successful in 38s
CI / test (push) Successful in 1m7s
CI / build (push) Successful in 33s
CI / e2e (push) Failing after 10m52s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- frontend/js/search.js : onSearchFilter appelee avec (vault, query, results) — renvoyait undefined, cassait le rendu et basculait sur la recherche offline (0 resultat). Regression plugins depuis 30f2df3.
- frontend/excalidraw-editor.html + js/excalidraw-viewer.js : ignorer onChange au montage (plus d'auto-save spurieux qui reecrivait .excalidraw.md en JSON brut) + preservation du format Obsidian frontmatter + compressed-json a la sauvegarde.
- frontend/js/app.js : __OBSIGATE_BOOTED pose apres init() (splash + E2E attendent les handlers lies).
- tests/e2e : goHome/login attendent le boot ; excalidraw.spec.js reecrit avec les selecteurs reels (modal, context menu, canvas.first()).
- ROADMAP : C8 (creation via menu contextuel) marque fait.
- Verifie : 86/86 E2E chromium-desktop, 599 backend, ruff, validate-imports, JSDOM.
2026-09-10 22:51:08 -04:00
bruno 4f99d5ce61 docs(excalidraw,roadmap): #78 H1-H3 doc utilisateur + #61/#74 optionnels non retenus
CI / lint (push) Successful in 55s
CI / security (push) Successful in 39s
CI / test (push) Successful in 1m6s
CI / build (push) Successful in 34s
CI / e2e (push) Failing after 22m30s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- README FR/EN : .excalidraw dans les formats supportes + section Diagrammes Excalidraw
- Guide integre : nouvelle section Excalidraw (i18n FR/EN) + note compatibilite plugin Obsidian
- ROADMAP : #78 termine (B5/F3/H1-H3), C8/F2 non retenus ; #61 et #74 optionnels marques non retenus
- CHANGELOG : entree Documentation
2026-09-10 20:51:25 -04:00
bruno f96c4ccd64 fix(ui,help): #61 version display in About modal + help guide footer (footer_tagline i18n, about-version id)
CI / lint (push) Successful in 52s
CI / security (push) Successful in 35s
CI / test (push) Successful in 1m5s
CI / build (push) Successful in 32s
CI / e2e (push) Failing after 20m14s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-10 10:10:32 -04:00
bruno ffa07b7df4 fix(ui,help): #61 unified dynamic version display + full guide sections for AI editor & plugins
CI / lint (push) Successful in 54s
CI / security (push) Successful in 35s
CI / test (push) Successful in 1m13s
CI / build (push) Successful in 32s
CI / e2e (push) Failing after 22m0s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-10 09:44:41 -04:00
bruno e81dd9a7ec fix(ruff): #61 I001 import sorting + RUF012 ClassVar annotation
CI / lint (push) Successful in 51s
CI / security (push) Successful in 34s
CI / test (push) Successful in 1m9s
CI / build (push) Successful in 32s
CI / e2e (push) Failing after 21m30s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-10 09:05:30 -04:00
bruno 30f2df3004 feat: #61 Plugin system — backend API, sandboxed Web Worker, hooks wired to real app flow, user guide, 47+21 tests
CI / lint (push) Failing after 30s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 35s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-10 09:01:38 -04:00
bruno ac16fc1f0e feat: #78 Excalidraw finitions + #67 push + #68 health-detailed + #77 desktop jumplist
CI / lint (push) Successful in 52s
CI / security (push) Successful in 36s
CI / test (push) Successful in 1m6s
CI / build (push) Successful in 1m15s
CI / e2e (push) Failing after 12m40s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
#78 Excalidraw — finitions B5/C8/F2/F3
- backend/indexer.py: port Python pur de lz-string decompressFromBase64 (bitsPerChar=6, resetValue=32) validé contre 4 fixtures JS truth (texte accentué, edge cases) — remplace le stub base64 non-fonctionnel
- B5 indexation: .excalidraw.md (format plugin Obsidian) maintenant décompressé côté Python → texte indexé pour recherche TF-IDF
- 7 nouveaux tests B5 dans tests/test_excalidraw.py (13/13 total)
- F2: excalidraw-viewer.test.mjs enregistré dans CI (lint job)
- F3: tests/e2e/excalidraw.spec.js (9 specs — ouverture .excalidraw/.excalidraw.md, création via modale/menu contextuel, toolbar, thème, pop-out, recherche)
- Fixtures test_vault/diagram.excalidraw + diagram.excalidraw.md

#67 Push notifications (Web Push API + VAPID)
- backend/push.py: router + VAPID keys + send_push_notification (pywebpush>=2.3.0)
- frontend/js/push.js: subscription UI + service worker integration
- tests/test_push.py: 10 tests (subscribe/list/unsubscribe/vapid key)
- requirements.txt + sw.js + locales push strings

#68 Health check enrichi
- backend/main.py: GET /api/health/detailed (admin) — memory/cpu/disk/backups/index/SSE connections
- backend/indexer.py: _last_full_index_ts tracking
- tests/conftest.py: admin_client fixture
- tests/test_api_main.py: 3 nouveaux tests health detailed

#77 Desktop jumplist
- desktop/src/jumplist.rs: Windows jumplist integration
- Cargo.toml/lock + capabilities + main.rs
- frontend/js/desktop.js: Tauri bridge (isTauriEnv, invoke, getSystemTheme, syncSystemTheme, shouldShowWizard, crash banner)
- tests/frontend/desktop.test.mjs: 21 tests JSDOM (détection, invoke degradation, theme gating, wizard, crash banner)
- tests/frontend/unit.test.mjs: desktop.js whitelisted (standalone global reader)

# Frontend & CI
- frontend/sw.js: réécriture complète (precache + push event handlers + offline)
- frontend/index.html: section push dans settings + about repositionné
- frontend/js/app.js: initDesktopIntegration + initPush
- CI .gitea/workflows/ci.yml: excalidraw-viewer.test.mjs ajouté au lint job

All checks: ruff clean, 552 backend tests pass, 9 frontend unit, 5 excalidraw-viewer, 21 desktop, 9 pane-manager, validate-imports 33 modules.
2026-09-09 23:18:29 -04:00
bruno e4aca3b31e fix(pdf,excalidraw): BUG-001 + BUG-002
CI / lint (push) Successful in 47s
CI / security (push) Successful in 32s
CI / test (push) Successful in 1m0s
CI / build (push) Successful in 30s
CI / e2e (push) Successful in 9m22s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
BUG-001 (PDF stream 500) : le nom Unicode du fichier etait injecte brut
dans Content-Disposition -> en-tete HTTP invalide -> 500. Nouveau helper
_content_disposition (RFC 5987 : filename ASCII + filename*=UTF-8'').
Applique aux 2 branches /pdf/stream. Test: tests/test_pdf_stream.py.

BUG-002 (Excalidraw 'Loading...' infini) : deux causes.
1) L'import esm.sh avec ?alias=react:... etait propage a chaque dep
   transitive; esm.sh renvoie 408 sur les builds a gamme semver (jotai@>=2.9.2)
   -> import entier echoue. On retire l'alias et on utilise React 19 coherent
   (spec a gamme + target identique a celle d'Excalidraw) -> plus de 408.
2) L'editeur passait la prop legacy 'excalidrawRef', inoperante en 0.18
   (la prop reelle est 'excalidrawAPI') -> l'API n'etait jamais recue,
   le 'Loading' ne se masquait pas, save/export morts.

Verifie : 534 tests backend verts (dont 3 nouveaux PDF) + E2E navigateur
(loading masque + cycle requestSave->save OK).
2026-09-09 13:04:40 -04:00
bruno 212753f311 test(e2e): #75 I3 — matrice ouverture/fermeture split view (21 tests) + fix chemins Windows run-e2e-local.sh
CI / lint (push) Successful in 42s
CI / security (push) Successful in 27s
CI / test (push) Successful in 55s
CI / build (push) Successful in 23s
CI / e2e (push) Successful in 9m33s
2026-09-08 09:38:10 -04:00
bruno ccfcb58ed1 docs(roadmap): corrections cases #74/#75/#76/#78 — optionnels non retenus de-coches, reels coches, dedup I2/I3
CI / lint (push) Successful in 44s
CI / security (push) Successful in 26s
CI / test (push) Successful in 53s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m0s
2026-09-08 06:25:46 -04:00
bruno aa3df74fc1 fix(mfa): /mfa/webauthn/options enumeration-safe (200 totp fallback au lieu de 400) + test
CI / lint (push) Successful in 40s
CI / security (push) Successful in 27s
CI / test (push) Successful in 53s
CI / build (push) Successful in 23s
CI / e2e (push) Successful in 6m1s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-08 00:48:20 -04:00
bruno f9d2c0d2d4 docs(roadmap): menage — aligne le roadmap sur le code reel (#59,63-66,71,74-77,78), resume efforts recalcule + CHANGELOG 2.1.0
CI / lint (push) Successful in 41s
CI / security (push) Successful in 28s
CI / test (push) Successful in 52s
CI / build (push) Successful in 1m10s
CI / e2e (push) Successful in 6m1s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-08 00:12:28 -04:00
bruno c066b2c82a feat(desktop): #77 B5 port auto-increment si 17890 occupe (pick_free_port) + 3 tests Rust 2026-09-07 23:55:36 -04:00
bruno ab795ec9e0 feat(mfa): #64 WebAuthn complet — enregistrement/clé/verify login + gestion clés dans Sécurité (backend+frontend+i18n+tests) 2026-09-07 23:55:35 -04:00
bruno 7042307955 feat(pdf): #74 au complet — fix auth 500 stream + endpoint pdf/info + HTTP Range 206 + indexation incrementale PDF + config taille/timeout 2026-09-07 23:24:13 -04:00
bruno f37d5fda3c fix(version): ordre des imports (ruff I001)
CI / lint (push) Successful in 39s
CI / security (push) Successful in 28s
CI / test (push) Successful in 50s
CI / build (push) Successful in 23s
CI / e2e (push) Successful in 5m51s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-07 22:15:39 -04:00
bruno c5b92eabe1 feat(version): version x.y.z propre et cohérente partout + bump script
CI / lint (push) Failing after 21s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Corrige l'incoherence d'affichage (page principale 0.0.0-dev vs a propos
v2.0.0-dev) et unifie la gestion de version sur une seule source de verite.

Backend:
- version.py: get_version() retourne toujours le tag x.y.z propre du dernier
  tag (plus de 0.0.0-dev / -N-gHASH dans l'UI). Ajout get_git_describe() et
  get_git_commit() pour le detail.
- main.py: /api/health expose git_describe + git_commit (nouveaux champs).

Frontend:
- modale A propos (populateAbout): version + commit lus depuis /api/health,
  suppression du hardcode v2.0.0-dev dans index.html.
- section config A propos (loadAbout): lit health.version, plus de state.
  APP_VERSION obsolete.
- badge header: fetch /api/health (fallback neutre '...').
- state.js: suppression de APP_VERSION (1.5.0) devenu mort; imports nettoyes.

Script:
- scripts/bump_version.sh: incrementation SemVer selon les commits
  (breaking->major, feat->minor, sinon patch), creer/pousser le tag vX.Y.Z.

Tests: 507 passed, frontend validators OK.
2026-09-07 22:13:54 -04:00
bruno 01453bce5f feat(admin): navigation par sections sticky + support complet des themes
CI / lint (push) Successful in 38s
CI / security (push) Successful in 26s
CI / test (push) Successful in 50s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m7s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Navigation/ergonomie:
- barre sticky de pilules (Stats, Audit, Backups, Utilisateurs) sous le header
- scrollspy (IntersectionObserver) + scroll fluide au clic dans admin.js
- ancres + scroll-margin-top sur chaque section, layout responsive <600px

Themes:
- admin.html importe et appelle initThemes() (themes.js) au boot: la page
  reprend le theme/mode sauvegarde dans l'app (localStorage meme-origine)
- script anti-FOUC en <head> applique vite le mode avant le CSS
2026-09-07 21:45:50 -04:00
bruno bd86ba36e2 fix(admin): scroll de la page admin bloqué par le CSS global de la SPA
CI / lint (push) Successful in 42s
CI / security (push) Successful in 28s
CI / test (push) Successful in 51s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m2s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
admin.html charge /static/style.css dont body{overflow:hidden;height:100vh}
(congu pour la SPA avec scroll interne). Sur la page admin autonome, ce bloc
desactivait le scroll de la page.

- frontend/admin.html: body.admin-page force overflow:auto + height:auto pour
  surcharger le CSS global (specificite body.admin-page > body).
2026-09-07 21:14:45 -04:00
bruno 88ab8db88d fix(admin): /admin.html retombait sur la page principale (ROADMAP #71)
CI / lint (push) Successful in 54s
CI / security (push) Successful in 26s
CI / test (push) Successful in 50s
CI / build (push) Successful in 23s
CI / e2e (push) Successful in 6m2s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Le menu Admin redirigeait vers /admin.html mais aucune route backend ne le
servait : le catch-all SPA /{full_path:path} renvoyait index.html, d'où le
retour à la page principale.

- backend/main.py: route GET /admin.html (gate require_admin) déclarée avant
  le catch-all SPA + correction des imports JS /frontend/js -> /static/js
- frontend/admin.html: chemins d'assets alignés sur le mount /static
- tests/test_admin.py: 3 tests de régression (page servie admin, refus 401/403)
2026-09-07 20:29:33 -04:00
bruno 11406034c9 fix(config): rétablir le bloc export à la colonne 0 (validation imports CI)
CI / lint (push) Successful in 38s
CI / security (push) Successful in 24s
CI / test (push) Successful in 49s
CI / build (push) Successful in 23s
CI / e2e (push) Successful in 6m1s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-07 18:35:53 -04:00
bruno eef8671912 fix(config): corriger erreur de syntaxe config.js dans la section clés API IA
CI / lint (push) Failing after 33s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 25s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-07 18:24:41 -04:00
bruno 72383b1634 feat(config): badges statut + suppression par provider dans Clés API IA
CI / lint (push) Failing after 33s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 24s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- backend: ajout DELETE /api/config/ai-keys/{provider_env} pour supprimer une clé
- frontend: badge ✓ Configuré / Non configuré sur chaque ligne provider
- frontend: bouton × Supprimer avec confirmation sur les providers configurés
- testAIKeys met à jour les badges selon les résultats du test
2026-09-07 15:43:24 -04:00
bruno 2d70be0ae2 feat(ui): badge version visible + FAB AI flottant
CI / lint (push) Successful in 37s
CI / security (push) Successful in 25s
CI / test (push) Successful in 48s
CI / build (push) Successful in 21s
CI / e2e (push) Successful in 6m4s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
2026-09-07 14:37:37 -04:00
bruno 5350738c22 feat(bookslm): bouton toggle cacher/afficher la sidebar dans le header
CI / lint (push) Successful in 37s
CI / security (push) Successful in 24s
CI / test (push) Successful in 49s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m16s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Même mécanisme que le sidebar de navigation gauche :
- Bouton .bookslm-btn-toggle dans le header (icône panel-right-close/open)
- Toggle la classe .hidden sur le panel, persiste dans localStorage
- État restauré au prochain open()
- Le panel reste monté (pas de teardown), juste slid off-screen
- CSS : .bookslm-panel.open.hidden → translateX(100%)
- CSS : .bookslm-btn-toggle suit le pattern .sidebar-toggle-btn

i18n : bookslm.toggle_sidebar ajouté (fr + en)
Import safeCreateIcons ajouté pour rafraîchir l'icône Lucide au toggle.

Vérifié :
- pytest : 504 passed (zéro régression)
- ruff : All checks passed
- frontend unit : 7 passed
- validate-imports : 30 modules / 204 exports
- pane-manager JSDOM : 9/9
2026-09-07 13:59:00 -04:00
bruno 0d29dd00fd fix(bookslm): 422 + thème + picker zindex
CI / lint (push) Successful in 37s
CI / security (push) Successful in 25s
CI / test (push) Successful in 49s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m7s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- Bug 1 (422): bookslm.js envoie maintenant {message, conversation_history}
  conformes au backend (au lieu de messages:[...])
- Bug 2 (thème BooksLM): style.css lignes 9015-9061 — suppression de
  tous les fallbacks hex (--bg, --muted, --text, --surface, --accent,
  --border) au profit des variables ObsiGate existantes (--bg-primary,
  --text-primary, --text-secondary, --surface, --surface2, --accent,
  --border). Ajout de color: var(--text-primary) sur .bookslm-panel
- Bug 3 (picker zindex/flex): bookslm-picker-host + ai-picker +
  .bookslm-header button obtiennent flex-shrink:0 + z-index:1 pour
  éviter que les selects compressent ou masquent les boutons
  header (Nouvelle conversation / Exporter / Plein écran / Fermer)
- Bug 4 (theme picker): ai.js — var(--bg-tertiary, transparent) →
  var(--surface), var(--border-color) → var(--border) sur tous les
  selects, menus et createSeparator. Plus de variables inexistantes.

Tests: pytest 504 passed, frontend unit 7/7, validate-imports 30 modules
204 exports, pane-manager 9/9, ruff All checks passed.
2026-09-07 13:53:49 -04:00
bruno d441481930 fix(xiaomi): URL MiMo + header api-key + fallback polling admin SSE
CI / lint (push) Successful in 37s
CI / security (push) Successful in 32s
CI / test (push) Successful in 48s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m1s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
1. **fix(xiaomi): la clé Xiaomi échouait avec 'Name or service not known'**
   - URL précédente api.xiaomi.com n'existe pas (DNS fail)
   - Vraie URL: https://api.xiaomimimo.com/v1/models
   - Xiaomi MiMo utilise un header custom 'api-key:' (PAS 'Authorization: Bearer')
   - Modèles par défaut mis à jour: mimo-v2.5-pro, mimo-v2.5, mimo-v2.5-asr, etc.
   - Le chat dans ai.py supporte maintenant un header custom via auth_header_name

2. **fix(admin): EventSource 503 → fallback polling**
   - Ajout d'un fallback: si EventSource ne reçoit jamais le premier event
     (cookie expiré, réseau bloqué, proxy timeout), on bascule sur du polling
     /api/admin/stats toutes les 5s. Le UI continue de se mettre à jour.
   - Cleanup correct du timer dans disconnectSSE

3. **fix(test_ai_models)**: 2 nouveaux tests de régression
   - test_xiaomi_uses_api_key_header_not_bearer: vérifie URL + header
   - test_xiaomi_test_endpoint_uses_real_url: vérifie /api/config/ai-keys/test
   - Patche backend.ai ET backend.main (import local)
   - Header case-insensitive (urllib normalise à 'Api-key', HTTP est insensible)

Vérifié :
- pytest : 504 passed (502 + 2 nouveaux Xiaomi)
- frontend unit : 7 passed
- validate-imports : 30 modules / 204 exports
- pane-manager JSDOM : 9/9
- ruff check backend/ : All checks passed
2026-09-07 12:47:24 -04:00
bruno 7ff9b854b6 fix(admin,ai): redirect admin.html + modèles fallback + picker provider/modèle par requête
CI / lint (push) Successful in 41s
CI / security (push) Successful in 28s
CI / test (push) Successful in 48s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m5s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Trois bugs corrigés + une amélioration demandée :

1. **fix(admin): /admin.html redirigeait toujours vers /**
   - admin.js _gateAdmin() lisait /api/auth/status qui ne contient PAS le rôle user
   - Remplacé par /api/auth/me (retourne username, role, vaults)
   - Le code distingue maintenant le cas 'auth désactivé' (admin anonyme) du
     cas 'auth requise non admin' (affiche écran forbidden)

2. **fix(ai): les modèles Nvidia/Xiaomi ne se chargeaient pas dans les dropdowns**
   - Xiaomi : l'endpoint public /v1/models est instable, échec réseau fréquent
   - Toutes les erreurs réseau/d'API renvoyaient models=[] → dropdown vide
   - Ajout d'un fallback curé : _FALLBACK_MODELS dict avec 4-8 modèles populaires
     par provider, TOUJOURS retourné si la clé manque OU si l'API distante échoue
   - Le frontend voit désormais 'fallback' vs 'live' comme hint pour l'utilisateur
   - Gemini parsing : strip du préfixe 'models/' retourné par l'API Gemini
   - 7 nouveaux tests pytest pour _FALLBACK_MODELS + endpoint /api/config/ai-models

3. **feat(ai): picker provider/model dans la toolbar AI + BooksLM**
   - Plusieurs providers peuvent maintenant être activés simultanément
   - Sélection provider+model par section (Forge, BooksLM, etc.) via dropdown
   - État persisté en localStorage (le choix suit l'utilisateur entre sections)
   - Chaque appel AI passe maintenant {provider, model} au backend
   - ai.js : aiAction() lit le picker et l'injecte dans le body
   - bookslm.js : envoie provider+model à /api/ai/bookslm/chat
   - ai_routes.py + bookslm_routes.py : AIRequest et BooksLMChatRequest
     acceptent provider+model, avec save/restore du modèle original
     pour ne pas affecter les autres requêtes concurrentes
   - 7 nouvelles clés i18n (ai.provider, ai.model, ai.model_loading, etc.)
   - 1 nouveau test bookslm vérifie que le schema accepte provider+model
   - validate-imports.mjs : fix faux positif sur 'export { X as Y }'

Vérifié :
- pytest : 502 passed, 5 skipped (494 baseline + 7 AI models + 1 BooksLM)
- frontend unit : 7 passed
- validate-imports : 30 modules / 204 exports / 0 erreur
- pane-manager JSDOM : 9/9
- ruff check backend/ : All checks passed
2026-09-07 12:00:05 -04:00
bruno 46be24f6a3 feat(admin): frontend dashboard complet pour #71
CI / lint (push) Successful in 36s
CI / security (push) Successful in 23s
CI / test (push) Successful in 46s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 5m43s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Page admin standalone + widgets temps réel + CRUD users via AdminPanel existant.

- frontend/admin.html : page admin (header + 4 sections : stats temps réel,
  audit logs, backups, gestion utilisateurs + footer)
- frontend/js/admin.js : module ES avec init(), connectSSE(), loadStatsOnce(),
  loadAuditLogs(), loadBackupStats(), renderStatsWidget(), renderAuditTable(),
  renderBackups() + helpers (formatBytes, formatUptime, severityColor)
- frontend/locales/{fr,en}.json : 36 clés admin.* identiques (diff vérifié)
- frontend/js/auth.js : adminRow.onclick redirige vers /admin.html (au lieu de
  AdminPanel.show() qui reste fonctionnel)
- tests/frontend/unit.test.mjs : 3 nouveaux tests (admin.js existe + exports + parse)
- tests/test_auth_api.py : 2 nouveaux tests smoke (PATCH + DELETE /api/auth/admin/users)

Vérifié :
- pytest : 494 passed, 5 skipped (492 baseline + 2 nouveaux)
- frontend unit : 7 passed (4 baseline + 3 admin)
- validate-imports : 30 modules validated (29 + admin.js)
- pane-manager JSDOM : 9/9 passed
- ruff check backend/ : All checks passed
2026-09-07 11:14:46 -04:00
bruno 3908e1601a fix(ci): contournement DNS flaky + retry pour le job Docker build
CI / lint (push) Successful in 37s
CI / security (push) Successful in 25s
CI / test (push) Successful in 46s
CI / build (push) Successful in 1m22s
CI / e2e (push) Successful in 5m48s
Le job 'CI / build' échouait avec :
  failed to fetch anonymous token: dial tcp: lookup auth.docker.io on
  127.0.0.11:53: server misbehaving

Le résolveur DNS embarqué du daemon Docker sur le runner GitHub Actions
est bogué de manière intermittente. Cette PR ajoute :

1. Étape 'Configure DNS' qui configure /etc/docker/daemon.json avec
   les DNS publics Google/Cloudflare/Quad9 (8.8.8.8, 1.1.1.1, 9.9.9.9)
   puis redémarre le daemon Docker
2. Retry jusqu'à 3 fois (10s, 20s d'attente) sur la commande
   docker build pour absorber les flakes réseau ponctuels

Aucun changement au Dockerfile lui-même — il fonctionne en local
et le bug est purement infrastructure.
2026-09-07 10:20:52 -04:00
bruno 1ef0a7ed82 chore(lint): combine nested if dans bookslm_routes (SIM102)
CI / lint (push) Successful in 36s
CI / security (push) Successful in 23s
CI / test (push) Successful in 48s
CI / build (push) Failing after 24s
CI / e2e (push) Skipped
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Fix final pour atteindre 'All checks passed!' sur ruff check backend/.
Le commit précédent avait raté ce fichier (modifié par --unsafe-fixes
mais non inclus dans le commit).
2026-09-07 09:09:27 -04:00
bruno 43f5b4a078 chore(lint): fixe les 3 dernières erreurs ruff pré-existantes
CI / lint (push) Failing after 19s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- bookslm.py: PERF402 — list(_cache) au lieu de la boucle d'append
- bookslm_routes.py: SIM102 — combine les conditions imbriquées (via --unsafe-fixes)
- export.py: DTZ005 — datetime.now(tz=timezone.utc) au lieu de naive now()
- watcher.py: SIM102 — combine les conditions imbriquées

Résultat : ruff check backend/ → All checks passed!
Vérifié : pytest full suite reste 492 passed, 5 skipped, 0 failed.
2026-09-07 09:06:11 -04:00
bruno 83355a25c8 chore(lint): ruff --fix sur le backend (cleanup pré-existant)
Réduit les erreurs ruff de 11 à 5 (toutes pré-existantes non auto-fixables) :
- F401 imports inutilisés dans auth/mfa.py
- I001 blocs d'imports non triés dans auth/router.py + main.py + pdf_reader.py

Les 5 restantes sont dans bookslm/export/watcher (code pré-existant, hors scope).

Vérifié : pytest full suite reste 492 passed, 5 skipped, 0 failed.
2026-09-07 09:01:40 -04:00
bruno ec56bc32f8 feat(pdf): tests pytest + fix bug NameError pour #74
CI / lint (push) Failing after 19s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 23s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- tests/test_pdf.py : 13 tests (100% verts) couvrant :
  - extract_pdf_text/metadata/toc avec edge cases (corrupt, missing, truncation)
  - .pdf dans SUPPORTED_EXTENSIONS
  - _scan_vault() extrait le texte des PDFs (vérifié avec fixture reportlab)
  - parseur du filtre ext:pdf
- backend/pdf_reader.py : fix NameError quand pymupdf est installé
  (PdfReader n'était déclaré que dans la branche except ImportError)
- backend/requirements-test.txt : reportlab pour générer des PDFs de test (devDep only)
- README.md + README.fr.md : section 'PDF support' documentée
- docs/ROADMAP.md : #74 marqué 'pratiquement complet' avec détail honnête des items livrés vs non
- CHANGELOG.md : entrées pour #71 admin (déjà dans commit précédent), #75 I2 et #74
2026-09-07 08:54:55 -04:00
bruno 050c3d2515 test(pane-manager): tests JSDOM pour #75 I2
- tests/frontend/pane-manager.test.mjs : 9 tests d'intégration frontend via JSDOM 30
  Couvre factory, init, open/activate/close, isolation entre panes, drag/drop smoke
- tests/frontend/package.json + package-lock.json : jsdom en devDependency
- .gitea/workflows/ci.yml : nouvelle étape 'Frontend JSDOM tests (PaneManager)'
  qui installe jsdom au besoin puis lance pane-manager.test.mjs
2026-09-07 08:54:41 -04:00
bruno b649c6b301 feat(admin): backend dashboard pour #71
- backend/admin.py : module FastAPI avec 4 endpoints admin-gated
  - GET /api/admin/stats (CPU/RAM/Disk/Uptime via psutil)
  - GET /api/admin/audit (filtres user/action/limit/offset)
  - GET /api/admin/backup-stats (count + size + age par vault)
  - GET /api/admin/stream (Server-Sent Events 5s)
- backend/main.py : routeur monté + middleware gzip bypass pour SSE
- backend/requirements.txt : ajout psutil>=5.9
- tests/test_admin.py : 13 tests (auth + filtres + format SSE), 100% verts

Pas de frontend dans cette PR — page admin.html + admin.js restent à faire.
2026-09-07 08:54:25 -04:00
bruno 99c9acccb0 docs(roadmap): #76 BooksLM marqué FAIT + providers AI étendus
CI / lint (push) Failing after 17s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 21s
2026-09-06 19:43:07 -04:00
bruno 524e6da591 feat(bookslm): v2.2.0 - BooksLM chat AI contextuel par répertoire + 4 providers AI
CI / lint (push) Failing after 16s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 21s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
BooksLM (#76):
- Panneau chat slide-in 450px, clic-droit répertoire → 🧠 BooksLM
- Collecte récursive .md avec limites (200 fichiers, 200K chars)
- Cache SHA-256, redaction secrets, priorité README/index
- SSE streaming, badges sources cliquables, historique localStorage
- Commandes palette: BooksLM ouvrir/nouvelle conversation

Providers AI (4 nouveaux):
- NVIDIA (integrate.api.nvidia.com)
- QwenCloud (dashscope.aliyuncs.com)
- Xiaomi (api.xiaomi.com)
- Mistral (api.mistral.ai)
- Tous OpenAI-compatible, auto-listing modèles

Fix: dropdown config-select suit maintenant le thème (option bg/color)
27 tests BooksLM + 466 tests au total
2026-09-06 19:42:40 -04:00
bruno 6292bfd9cb docs(roadmap): #64 #65 #66 marqués FAIT
CI / lint (push) Failing after 17s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 20s
2026-09-06 18:43:57 -04:00
bruno 83063d3a18 feat(auth): v2.1.0 - MFA TOTP avec QR code, recovery codes
CI / lint (push) Failing after 16s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 21s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- backend/auth/mfa.py: TOTP (pyotp), recovery codes SHA-256
- Login flow: mfa_required → totp/verify → token (ou recovery)
- 6 nouveaux endpoints /api/auth/mfa/*
- Frontend: QR code setup, 6-digit auto-submit, recovery codes
- Settings: section Sécurité avec enable/disable MFA
- CSS: mfa-challenge, setup-card, recovery-list, badges
- i18n: 36 nouvelles clés EN/FR
- pyotp ajouté aux dépendances
- 30 tests (TOTP, recovery, API endpoints, login flow)
- 439 tests passent au total
2026-09-06 18:42:32 -04:00
bruno 7b0dacabdd feat(export): v2.1.0 - export multi-formats HTML/MD-bundle/ePub
- backend/export.py: 3 exporters (HTML standalone, ZIP MD, ePub 3)
- HTML: CSS inliné, images base64, navigation wikilinks
- MD bundle: ZIP préserve structure répertoire
- ePub: zipfile + mistune, zero nouvelle dépendance
- 3 endpoints API: /api/export/{html,md-bundle,epub}
- UI: dropdown Export dans toolbar viewer (HTML/MD/ePub)
- 20 tests (validité, contenu, erreurs, API)
- 439 tests passent au total
2026-09-06 18:42:26 -04:00
bruno c8b861f0b6 feat(themes): v2.1.0 - themes personnalisés avec presets light/dark/high-contrast/sepia
- 4 modes: dark, light, high-contrast, sepia (40+ CSS vars chacun)
- Import/export thèmes personnalisés en JSON
- Grid swatches dans les paramètres avec preview
- toggleThemeMode() cycle through all 4 modes
- CustomEvent 'themechange' dispatché
- 18 tests unitaires (structure, generators, colors, i18n keys)
- 439 tests passent au total
2026-09-06 18:42:20 -04:00
bruno 09912509cb docs(roadmap): #77 Desktop marqué complet — D/E/F mis à jour
CI / lint (push) Failing after 16s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 21s
- D. Build et distribution: build local vérifié, CI existant
- E. UX: crash handler, preferences save, first-run defaults
- F. Tests: 16 tests Rust, build debug+release OK
- Items optionnels restants: wizard interactif, jumplist, signature code
2026-09-06 18:25:29 -04:00
bruno 3e0940da8c feat(desktop): v2.0.0 - tests unitaires Rust #77 complets
CI / lint (push) Failing after 17s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 21s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- 16 tests Rust: config roundtrip, JSON parsing, vault dedup,
  dir remove, backend URL, paths, branding, edge cases
- Ajout tempfile dev-dependency pour tests avec tmpdir
- .gitignore: exclusions junction points backend/frontend
- Build debug + release vérifié et fonctionnel
- Compilation: rustc 1.94.1, tauri-cli 2.11.4
2026-09-06 18:24:45 -04:00
bruno d20e623f0c fix(docker): build sans fichier VERSION pre-genere (docker compose build direct)
CI / lint (push) Failing after 23s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 32s
backend/VERSION est gitignore (genere par build.sh/CI) donc le COPY
echouait sur un clone propre. Remplace par le COPY backend/ existant +
ARG VERSION avec RUN fallback ; docker-compose.yml injecte VERSION via
build args. docker compose build --no-cache fonctionne desormais sans
build.sh.
2026-09-02 08:41:45 -04:00
bruno ef7a3ff1ed fix(watcher): auto-polling sur mounts reseau (NFS/SMB) pour maj temps reel des recents
CI / lint (push) Failing after 25s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 28s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
inotify ne voit pas les ecritures faites depuis d'autres clients NFS
(Obsidian sur Windows), donc index_updated n'etait jamais emis et la
section Recent du sidebar ne se rafraichissait pas. Detection du fstype
par vault via /proc/mounts -> PollingObserver (stat-based) sur mounts
reseau, Observer natif conserve sur les mounts locaux.
2026-09-02 08:16:21 -04:00
bruno 2115cb9085 chore: ignore desktop/checksums.txt (artefact regenere par publish_release.py)
CI / lint (push) Successful in 29s
CI / security (push) Successful in 26s
CI / test (push) Successful in 39s
CI / build (push) Successful in 13s
CI / e2e (push) Successful in 6m7s
2026-08-25 21:23:21 -04:00
bruno 5f981509ac fix(publish): user-agent navigateur pour contourner le blocage Cloudflare 1010 (writes Gitea)
CI / lint (push) Successful in 30s
CI / security (push) Successful in 22s
CI / test (push) Successful in 35s
CI / build (push) Successful in 13s
CI / e2e (push) Successful in 6m7s
2026-08-25 21:23:04 -04:00
97 changed files with 18837 additions and 765 deletions
+1
View File
@@ -15,6 +15,7 @@ docker-compose.yml
.env.*
*.log
.obsigate-backup
backend/VERSION
.pytest_cache
htmlcov
.coverage
+9
View File
@@ -33,6 +33,15 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# Backup
# OBSIGATE_BACKUP_DIR=.obsigate-backup
# PDF (ROADMAP #74)
# OBSIGATE_PDF_MAX_SIZE_MB=50 # PDFs plus volumineux = texte non indexé
# OBSIGATE_PDF_EXTRACT_TIMEOUT=30 # secondes avant abandon de l'extraction
# WebAuthn / MFA (ROADMAP #64) — nécessaire hors localhost
# OBSIGATE_WEBAUTHN_RP_ID=obsigate.example.com
# OBSIGATE_WEBAUTHN_RP_NAME=ObsiGate
# OBSIGATE_WEBAUTHN_ORIGINS=https://obsigate.example.com
# ── AI Provider Configuration ──
# Définir au moins un provider pour activer les fonctionnalités AI dans l'éditeur
+50 -3
View File
@@ -38,6 +38,23 @@ jobs:
- name: Frontend unit tests
run: node tests/frontend/unit.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI)
run: |
cd tests/frontend
if [ -d node_modules ]; then
node pane-manager.test.mjs
node excalidraw-viewer.test.mjs
node plugins.test.mjs
node ai.test.mjs
else
echo "tests/frontend/node_modules missing — installing jsdom"
npm install --no-audit --no-fund --silent
node pane-manager.test.mjs
node excalidraw-viewer.test.mjs
node plugins.test.mjs
node ai.test.mjs
fi
# ── Tests ─────────────────────────────────────────────────────────
test:
needs: lint
@@ -54,6 +71,7 @@ jobs:
run: |
pip install pytest pytest-cov pytest-asyncio httpx
pip install -r backend/requirements.txt
pip install -r backend/requirements-test.txt || echo "test deps install failed (non-blocking — PDF tests will skip)"
- name: Run tests
run: pytest tests/ --cov=backend --cov-report=xml --cov-report=term -q
@@ -99,8 +117,33 @@ jobs:
VERSION=$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo "0.0.0-dev")
echo "$VERSION" > backend/VERSION
- name: Build Docker image
run: docker build -t obsigate:ci .
- name: Configure DNS (workaround flaky 127.0.0.11 resolver)
# GitHub Actions runners occasionally fail to resolve auth.docker.io via
# the embedded Docker DNS (127.0.0.11:53 → "server misbehaving").
# Force the daemon to use public DNS as a fallback.
run: |
sudo mkdir -p /etc/docker
if ! grep -q "dns" /etc/docker/daemon.json 2>/dev/null; then
echo '{"dns": ["8.8.8.8", "1.1.1.1", "9.9.9.9"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker || sudo service docker restart || true
sleep 3
fi
- name: Build Docker image (retry on transient DNS/network errors)
run: |
for attempt in 1 2 3; do
echo "=== docker build attempt $attempt/3 ==="
if docker build -t obsigate:ci . ; then
echo "✓ Docker build succeeded"
exit 0
fi
echo "✗ Build failed (attempt $attempt)"
if [ $attempt -lt 3 ]; then
sleep $((attempt * 10))
fi
done
echo "✗ Docker build failed after 3 attempts"
exit 1
- name: Verify image
run: docker images obsigate:ci
@@ -151,6 +194,10 @@ jobs:
sleep 1
done
curl -sf "http://$GW:2029/api/health" >/dev/null || { echo "App not reachable at $GW:2029"; exit 1; }
# Les fixtures sont copiées via `docker cp` en root → rendre le vault
# inscriptible par l'utilisateur non-root de l'app (UID 1000), sinon
# toute création/édition de fichier renvoie 403 « Vault is read-only ».
docker exec -u 0 obsigate-e2e chmod -R a+rwX /vaults/TestVault /vaults/TestDir || true
- name: Run E2E tests
run: |
@@ -168,4 +215,4 @@ jobs:
- name: Cleanup
if: always()
run: docker rm -f obsigate-e2e
run: docker rm -f obsigate-e2e
+3
View File
@@ -26,5 +26,8 @@ playwright-report/
desktop/target/
desktop/python-embed/
desktop/frontend-static/
desktop/checksums.txt
desktop/backend/
desktop/frontend/
backend/VERSION
+167 -1
View File
@@ -6,7 +6,164 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements non publiés sont dans la section
> [2.0.0 — Unreleased](#200--unreleased). La dernière version publiée est **1.8.0**.
> [Unreleased](#unreleased). La dernière version publiée est **2.1.0**.
---
## [Unreleased]
### Ajouté
- **Assistant AI contextuel (sidebar)** — l'assistant s'adapte désormais au contexte d'ouverture :
- **Répertoire** (`directory`) : ouvert depuis le menu contextuel d'un dossier — le contexte est
le dossier et son contenu (mode historique BooksLM).
- **Documents** (`documents`) : ouvert via le bouton flottant quand un ou plusieurs documents
sont ouverts — le contexte est la liste des fichiers ouverts (tous onglets/panneaux).
- **Général** (`general`) : ouvert via le bouton flottant quand aucun document n'est ouvert —
l'assistant répond sur l'application et peut proposer des actions (créer un fichier/dossier)
via un bloc `obsigate-action` rendu en carte avec bouton « Appliquer » (aucune exécution
automatique).
- Le contexte est affiché dans le header de la sidebar (icône, titre, sous-titre) et les
suggestions de questions s'adaptent au mode.
- Backend : `/api/ai/bookslm/context` et `/chat` acceptent `mode` (`directory`/`documents`/
`general`) et `context_files` ; `collect_files_context()`, `empty_context()` et
`build_general_system_prompt()` dans `backend/bookslm.py` ; le mode documents dégrade
proprement vers le mode général si aucun fichier n'est lisible.
- Tests : `tests/test_bookslm.py` (+13 tests modes/documents/général) et
`tests/frontend/ai.test.mjs` (+8 tests détection de contexte, clavier, actions, header).
- **#72 API publique documentée — OpenAPI 3.1 enrichie** — documentation interactive complète
de l'API REST, générée automatiquement et enrichie.
- Backend `backend/openapi_docs.py` : métadonnées de tags (18 catégories : System, Auth, Files,
PDF, Vaults, Search, Bookmarks, Backups, Export, AI, BooksLM, Sharing, Webhooks, Conflicts,
Admin, Plugins, Push, Frontend), assignation automatique des tags par préfixe de route
(normalisation des tags des routeurs), schémas de sécurité (`bearerAuth`, `cookieAuth`),
exemples de requête/réponse sur les endpoints clés, réponses d'erreur documentées
(401/403/404/422/500), serveur et `externalDocs`.
- `backend/schemas.py` : nouveaux `response_model` Pydantic pour les endpoints qui n'en avaient
pas (recent, bookmarks, saved searches, backups, diff/restore/backlinks, pdf/info, replace,
vaults, attachments, settings, config, AI keys/models, diagnostics, dashboard, webhooks,
shares, conflicts, BooksLM context, AI status).
- `backend/main.py` : `FastAPI(...)` enrichi (description Markdown, contact, licence, tags) et
override `app.openapi` pour post-traiter le schéma ; page de documentation `/api` (HTML
autonome listant les endpoints groupés par catégorie, liens vers `/docs`, `/redoc`,
`/openapi.json`).
- Frontend : entrée « API » dans le menu d'options (i18n FR/EN) ouvrant Swagger UI.
- Tests : `tests/test_openapi.py` (58 tests — version 3.1, tags, sécurité, erreurs, exemples,
schémas de réponse, pages `/api`, `/docs`, `/redoc`, `/openapi.json`).
- **#61 Plugins système au complet** — système de plugins permettant d'étendre ObsiGate
(renderers personnalisés, filtres de recherche, actions d'éditeur), sandboxé pour la sécurité.
- Backend `backend/plugins.py` : validation du manifest `plugin.json` (name regex lowercase,
semver, hooks/permissions autorisés), stockage par vault `<vault>/.obsigate-plugins/`, lifecycle
complet (install/uninstall/enable/disable via marker `.disabled`), validation ZIP à l'upload
(anti path-traversal, max 100 fichiers, 500KB/fichier, 5MB upload), 9 endpoints `/api/plugins/*`
(list/get/hooks/code/template + install/uninstall/enable/disable admin-gated), template API,
scan au démarrage par vault (`get_plugin_registry().scan_vault`).
- Frontend `frontend/js/plugins.js` : PluginManager (install/uninstall/enable/disable/view-code),
sandbox d'exécution via Web Worker (code chargé par blob URL, protocole `postMessage` structuré,
isolation DOM/localStorage/network selon permissions), hooks dispatch
(`executeHook`, `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`,
`onFileCreate`, `onFileDelete`, `onVaultMount`), UI Settings > Plugins.
- Sécurité : manifest de permissions (`read_files`, `write_files`, `network_request`, …
restreint), CSP stricte sans `importScripts`, limites de taille.
- Tests : `tests/test_plugins.py` (44 tests — validation manifest, lifecycle manager,
validation ZIP/directory, démarrage scan) + `tests/frontend/plugins.test.mjs`
(21 tests JSDOM — protocole sandbox Worker, isolation DOM, lifecycle mirror).
- CI : job lint ajoute `node plugins.test.mjs` aux tests JSDOM.
- Docs : `docs/PLUGINS.md` (manifest, hooks, permissions, modèle de sécurité, API, guide).
### Documentation
- **#78 Excalidraw — doc utilisateur finalisée** : `.excalidraw` / `.excalidraw.md` ajoutés aux
formats supportés (README FR/EN), nouvelle section « Diagrammes Excalidraw » dans le guide
intégré (i18n FR/EN) et note de compatibilité avec le plugin Obsidian Excalidraw.
- **Roadmap** : #78 marqué terminé (B5 extraction texte, C8 menu contextuel, F3 E2E
`tests/e2e/excalidraw.spec.js`, H1-H3 docs) ; F2 non retenu. Points optionnels #61
(dépôt communautaire de plugins) et #74 (D2/E4/H2/I2) explicitement marqués non retenus.
### Corrigé
- **Assistant AI — sidebar qui ne s'ouvrait plus & erreur de contexte** :
- Le bouton flottant passait le **chemin d'un fichier** comme répertoire de contexte, d'où
l'erreur « Aucun fichier markdown trouvé dans ce dossier » à l'ouverture sur un document.
Le contexte est maintenant détecté automatiquement (documents ouverts → mode documents,
sinon mode général).
- Un panneau précédemment masqué (`obsigate-bookslm-hidden=true` persisté en localStorage)
restait caché lors d'une ouverture explicite → l'ouverture force désormais l'affichage.
- **Ergonomie du header** : le sélecteur fournisseur/modèle était sur la même ligne que les
actions et masquait ces dernières ; il est déplacé sur une seconde ligne dédiée (toolbar)
pour que « nouvelle conversation / export / plein écran / fermer » restent toujours visibles.
- **Clavier de la boîte de chat** : `Entrée` envoie la requête, `Ctrl+Entrée` insère un saut
de ligne (au lieu de l'inverse), avec un texte d'aide sous le champ.
- **Outil AI de l'UI — fiabilisation** :
- **BooksLM (chat IA par répertoire)** : les requêtes `context`/`chat` n'envoyaient pas le
jeton d'authentification (échec 401 quand l'auth est activée) et la réponse SSE était
mal parsée (le backend émet `{token: ...}`, le front lisait `data.content`) → les réponses
n'apparaissaient jamais. Le message utilisateur envoyé était le placeholder assistant vide
au lieu du texte saisi. Corrigé : requêtes authentifiées via `AuthManager` (retry 401),
parsing SSE `event:`/`data:` avec support `token`/`error`, historique construit avant
l'ajout du message courant, barre de progression basée sur `max_total_chars`.
- **AI Editor** : le raccourci `Ctrl/Cmd+J` (complétion inline) était re-lié à chaque
ouverture de l'éditeur (N listeners → N requêtes) ; un seul listener global est désormais
posé. Les libellés de la barre d'outils AI étaient codés en dur (FR/EN mélangés) → i18n
complet. La réécriture personnalisée utilisait `window.prompt()` → remplacée par une modale
accessible (Échap annule, Ctrl/Cmd+Entrée valide).
- **Recherche cassée (régression plugins)** : `onSearchFilter()` était appelée avec 2 arguments
au lieu de 3 (`frontend/js/search.js`), renvoyait `undefined`, faisait échouer le rendu des
résultats et basculait silencieusement sur la recherche hors-ligne (0 résultat). Corrigé →
la recherche full-text et l'autocomplétion refonctionnent (20 tests E2E).
- **`.excalidraw.md` corrompu à l'ouverture** : Excalidraw émet `onChange` au montage, ce qui
déclenchait un auto-save 2 s plus tard réécrivant le fichier au format JSON brut et détruisant
le format plugin Obsidian (`frontend/excalidraw-editor.html` + `frontend/js/excalidraw-viewer.js`).
Les changements initiaux sont désormais ignorés et le format `frontmatter + compressed-json`
est préservé à la sauvegarde.
- **Splash / tests E2E** : `window.__OBSIGATE_BOOTED` était posé avant la fin de `init()` — le
splash disparaissait trop tôt et les tests E2E interagissaient avant que les handlers soient
liés. Le flag est maintenant posé après résolution de `init()` (`frontend/js/app.js`), et
`goHome()`/`login()` attendent ce signal.
- **Tests E2E Excalidraw** : réécriture de `tests/e2e/excalidraw.spec.js` avec les sélecteurs
réels (modale `.obsigate-modal` + `#file-ext-select`/`#file-name-input`, menu contextuel,
`canvas.first()` pour les deux couches canvas).
---
## [2.1.0] — 2026-09-08
### Ajouté
- **#74 Support PDF au complet** — `GET /api/file/{vault}/pdf/info` (métadonnées sans
contenu), streaming avec HTTP Range / 206 Partial Content (rendu progressif des gros PDF),
config `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s).
16 tests supplémentaires (26 au total dans `test_pdf.py`).
- **#64 MFA — WebAuthn** (second facteur en plus du TOTP) : enregistrement de clés de
sécurité / biométrie (Windows Hello, Touch ID), assertion au login, gestion des clés dans
« Sécurité du compte », codes de récupération émis à l'activation. Nouveau module
`backend/auth/webauthn_mfa.py` (lib `webauthn==2.6.0`, challenges in-memory TTL 180s à
usage unique). 10 tests avec authentificateur virtuel (CBOR réel, ECDSA P-256).
i18n FR/EN (13 clés). Config : `OBSIGATE_WEBAUTHN_RP_ID` / `_RP_NAME` / `_ORIGINS`.
- **#77 Desktop — port auto-increment** : `pick_free_port()` scanne 17890..17899 si le port
est occupé (2 instances côte à côte possibles) + 3 tests Rust (19 au total côté desktop).
- Docs : sections PDF README FR/EN (Range, /pdf/info), variables env, guide WebAuthn.
- **#75 Split View — matrice E2E complète (37 tests)** : 16 tests `split-view.spec.js` (raccourcis,
drag & drop, persistance, navigation clavier) + 21 tests `split-view-matrix.spec.js` couvrant
100% des cas d'ouverture/fermeture : boutons ⊞→/⊞↓, menu contextuel (diviser bas, fermer, fermer
les autres/à droite/tout, fermer le panneau, flyout « Déplacer vers… »), palette (diviser, focus
suivant/précédent, reset), fermeture par X/double-clic/molette/Ctrl+W, auto-collapse d'un panneau
vidé, plafond 4 panneaux, drag-to-split bas, pas de doublon à la réouverture.
- Fix `scripts/run-e2e-local.sh` : chemins Windows via `cygpath` (les env vars MSYS n'étaient pas
converties → vaults vides, les 16 tests split-view échouaient en local alors qu'ils passaient
en CI).
### Corrigé
- **PDF stream 500** : `api_pdf_stream` plantait systématiquement (`NameError: current_user`
non injecté — endpoint jamais couvert par un test). Désormais authentifié + testé.
- **Indexation incrémentale des PDF** : le chemin watcher (`_index_single_file_sync`) lisait
les PDFs en `read_text()` → contenu garbage indexé. Utilise maintenant `extract_pdf_text()`
comme le scan complet.
---
@@ -18,6 +175,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Ajouté
- **Application bureau native (Tauri 2)** — Porte d'entrée desktop pour vaults Obsidian (`desktop/`)
- **Dashboard administrateur (#71, backend)** — 4 endpoints admin-gated pour monitoring serveur
- `GET /api/admin/stats` — CPU/RAM/Disk/Uptime via psutil
- `GET /api/admin/audit` — 500 dernières entrées d'audit avec filtres `user`/`action`/`limit`/`offset`
- `GET /api/admin/backup-stats` — compte + taille + age par vault
- `GET /api/admin/stream` — Server-Sent Events qui push les stats toutes les 5s
- Nouveau module `backend/admin.py` + 13 tests pytest
- **Tests JSDOM PaneManager (#75, sous-tâche I2)** — 9 tests d'intégration frontend via JSDOM 30
- Couvre : factory, init, open/activate/close, isolation entre panes, drag/drop smoke
- Wired dans le CI Gitea (`.gitea/workflows/ci.yml`)
- Shell Rust + Tauri 2 (tray icon, notifications OS, auto-update depuis Gitea)
- Backend Python embarqué (python-embed) avec health check, démarrage ~2s
- Sélecteur de dossiers natif, config persistante des vaults (type VAULT vs DIR)
+6 -2
View File
@@ -31,8 +31,12 @@ RUN apt-get update \
COPY backend/ ./backend/
COPY frontend/ ./frontend/
# Copy pre-generated VERSION file (generated by build.sh before docker build)
COPY backend/VERSION ./backend/VERSION
# Bake version: build.sh/CI pre-generate backend/VERSION; backend/VERSION est
# exclu du .dockerignore pour que le build utilise TOUJOURS l'ARG ci-dessous
# (jamais un fichier stale "1.8.0-...-dirty" du contexte build qui afficherait
# une vieille version au lieu du tag courant).
ARG VERSION=2.1.0
RUN test -f backend/VERSION || echo "$VERSION" > backend/VERSION
# Create non-root user for security + data directory for auth persistence
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
+20
View File
@@ -65,6 +65,7 @@
- **🏷️ Tag cloud** : Filtrage par tags extraits des frontmatters YAML
- **🔗 Wikilinks** : Les `[[liens internes]]` Obsidian sont cliquables
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
- **🎨 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)
- **🎨 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
@@ -272,6 +273,8 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr
| `OBSIGATE_REFRESH_TOKEN_TTL` | Durée de vie refresh token (secondes) | `2592000` |
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Tentatives de login max par IP | `10` |
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Fenêtre de rate limiting (secondes) | `900` |
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
### Volume pour la persistance
@@ -611,6 +614,23 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
Les opérateurs sont combinables : `tag:linux vault:IT ext:md serveur web`.
### Support PDF
Les fichiers PDF de vos vaults s'affichent en ligne dans le navigateur via le visualiseur PDF natif (iframe + `<embed>`).
Le visualiseur streame le fichier via HTTP Range (206 Partial Content) — les gros PDF se chargent progressivement.
Le texte est extrait à l'indexation (pypdf / pymupdf) — le contenu PDF est donc recherchable via la recherche full-text.
Filtrez avec `ext:pdf` pour restreindre les résultats aux PDF.
Les métadonnées (pages, titre, auteur) sont disponibles via `GET /api/file/{vault}/pdf/info` sans transférer le document.
**Limitations :** pas d'OCR (les PDF scannés ne sont pas recherchables), pas d'annotation, pas d'édition du PDF lui-même.
### Diagrammes Excalidraw
Les fichiers `.excalidraw` et `.excalidraw.md` s'ouvrent dans un éditeur visuel Excalidraw complet, intégré dans une iframe sandboxée — dessinez, modifiez et sauvegardez sans quitter ObsiGate.
Les modifications sont sauvegardées automatiquement (2 s) ou avec `Ctrl+S` ; l'éditeur suit le thème clair/sombre.
Le texte des éléments du diagramme est extrait à l'indexation : il est donc recherchable via la recherche full-text (`ext:excalidraw`).
Les fichiers créés avec le **plugin Obsidian Excalidraw** (y compris le format `.excalidraw.md` compressé) sont compatibles.
### Raccourcis clavier
| Raccourci | Action |
+21 -1
View File
@@ -58,6 +58,7 @@
- **🏷️ Tag Cloud** : Filtering by tags extracted from YAML frontmatters
- **🔗 Wikilinks** : `[[internal links]]` from Obsidian are clickable
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
- **🎨 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
@@ -310,6 +311,8 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl
| `OBSIGATE_REFRESH_TOKEN_TTL` | Refresh token lifetime (seconds) | `2592000` |
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Max login attempts per IP | `10` |
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Rate limiting window (seconds) | `900` |
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
>All these variables are documented in `.env.example`.
@@ -738,7 +741,24 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
| `ext:<type>` | Filter by file type | `ext:md kubernetes` |
| `"exact phrase"` | Phrase search | `tag:"multiple words"` |
Extension filter examples: `ext:sh` for bash scripts, `ext:py` for Python scripts, `ext:md` for Markdown files.
Extension filter examples: `ext:sh` for bash scripts, `ext:py` for Python scripts, `ext:md` for Markdown files, `ext:pdf` for PDF documents, `ext:excalidraw` for Excalidraw diagrams (text-extracted content is indexed).
### PDF support
PDF files in your vaults are rendered inline in the browser via the native PDF viewer (iframe + `<embed>`).
The viewer streams the file over HTTP Range requests (206 Partial Content), so large PDFs load progressively.
Text is extracted on indexing (pypdf / pymupdf) so PDF content is searchable via the full-text search.
Filter with `ext:pdf` to restrict results to PDFs.
PDF metadata (pages, title, author) is available via `GET /api/file/{vault}/pdf/info` without transferring the document.
**Limitations:** no OCR (scanned PDFs aren't searchable), no annotation, no editing of the PDF itself.
### Excalidraw diagrams
`.excalidraw` and `.excalidraw.md` files open in a full visual Excalidraw editor embedded in a sandboxed iframe — draw, edit and save without leaving ObsiGate.
Changes are saved automatically (2s debounce) or with `Ctrl+S`; the editor follows the light/dark theme.
Text labels inside the diagram elements are extracted on indexing, so diagram content is searchable via the full-text search (`ext:excalidraw`).
Files created with the **Obsidian Excalidraw plugin** (including the compressed `.excalidraw.md` format) are compatible.
Operators are combinable: `tag:linux vault:IT ext:md server web` searches for "server web" in Markdown files of the IT vault with the linux tag.
+262
View File
@@ -0,0 +1,262 @@
# backend/admin.py
# Admin Dashboard endpoints — system stats, audit logs, backup stats,
# and Server-Sent Events stream for real-time widgets.
#
# All endpoints protected with require_admin.
import asyncio
import json
import logging
import os
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from backend.audit import AUDIT_LOG_FILE, get_recent_entries
from backend.auth.middleware import require_admin
from backend.indexer import get_vault_data, index
logger = logging.getLogger("obsigate.admin")
router = APIRouter(prefix="/api/admin", tags=["admin"])
# Server start time — set at module import. Approximates uptime even
# if used before lifespan startup fully runs.
_SERVER_START_TIME = time.time()
def _server_uptime_seconds() -> float:
return time.time() - _SERVER_START_TIME
def _format_timestamp_for_stream(payload: dict) -> str:
"""Serialize a payload as an SSE-compatible data line."""
return json.dumps(payload, default=str, ensure_ascii=False)
def _get_disk_stats() -> tuple[float, float]:
"""Return (used_gb, total_gb) for the data volume or cwd fallback."""
# Prefer /data mount when present (production), else use cwd
target = "/data" if os.path.isdir("/data") else "."
try:
usage = shutil.disk_usage(target)
used_gb = round(usage.used / (1024 ** 3), 2)
total_gb = round(usage.total / (1024 ** 3), 2)
return used_gb, total_gb
except OSError as e:
logger.warning(f"disk_usage failed for {target}: {e}")
return 0.0, 0.0
def _count_active_sessions() -> int:
"""Count currently-revoked-free active sessions.
Best-effort estimate: we don't keep an in-memory session store, so we
fall back to 1 if the server is up. Useful for the dashboard tile.
"""
return 1
# ── /api/admin/stats ────────────────────────────────────────────────────
@router.get("/stats")
async def get_admin_stats(_admin=Depends(require_admin)):
"""Return real-time system stats for the admin dashboard."""
import psutil
try:
cpu_pct = psutil.cpu_percent(interval=None)
except Exception:
cpu_pct = 0.0
try:
vm = psutil.virtual_memory()
mem_used_mb = round(vm.used / (1024 ** 2), 1)
mem_total_mb = round(vm.total / (1024 ** 2), 1)
except Exception:
mem_used_mb, mem_total_mb = 0.0, 0.0
disk_used_gb, disk_total_gb = _get_disk_stats()
uptime_seconds = int(_server_uptime_seconds())
active_sessions = _count_active_sessions()
return {
"cpu_pct": cpu_pct,
"mem_used_mb": mem_used_mb,
"mem_total_mb": mem_total_mb,
"disk_used_gb": disk_used_gb,
"disk_total_gb": disk_total_gb,
"uptime_seconds": uptime_seconds,
"active_sessions": active_sessions,
}
# ── /api/admin/audit ───────────────────────────────────────────────────
@router.get("/audit")
async def get_admin_audit(
user: str | None = Query(None, description="Filter by username (substring)"),
action: str | None = Query(None, description="Filter by exact action"),
limit: int = Query(500, ge=1, le=2000, description="Max entries"),
offset: int = Query(0, ge=0, description="Skip first N entries"),
_admin=Depends(require_admin),
):
"""Return recent audit log entries with optional filters."""
entries = get_recent_entries(limit=offset + limit, action=action)
if user:
needle = user.lower()
entries = [e for e in entries if needle in str(e.get("username", "")).lower()]
if offset:
entries = entries[offset:]
return {
"entries": entries,
"total": len(entries),
"log_file": str(AUDIT_LOG_FILE),
}
# ── /api/admin/backup-stats ───────────────────────────────────────────
def _scan_backups() -> list[dict]:
"""Walk every vault's backup directory and return one row per .bak file."""
rows: list[dict] = []
for vault_name in list(index.keys()):
vd = get_vault_data(vault_name)
if not vd:
continue
vault_root = Path(vd["path"])
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
if not backup_root.is_absolute():
backup_root = vault_root / backup_root
vault_backup_dir = backup_root / vault_name
if not vault_backup_dir.exists():
continue
try:
for fpath in vault_backup_dir.rglob("*.bak"):
if not fpath.is_file():
continue
try:
st = fpath.stat()
except OSError:
continue
# Backup filename: {orig}.{timestamp}.bak — split from the right
name = fpath.name
parts = name.rsplit(".", 2)
if len(parts) < 3 or not parts[-2].isdigit():
continue
try:
ts = int(parts[-2])
except ValueError:
continue
rows.append({
"vault": vault_name,
"filename": name,
"timestamp": ts,
"size": st.st_size,
})
except OSError as e:
logger.warning(f"Backup scan error in {vault_backup_dir}: {e}")
rows.sort(key=lambda r: r["timestamp"], reverse=True)
return rows
@router.get("/backup-stats")
async def get_admin_backup_stats(_admin=Depends(require_admin)):
"""Return backup statistics across all vaults."""
rows = _scan_backups()
now_ts = int(time.time())
total_size_mb = round(sum(r["size"] for r in rows) / (1024 ** 2), 2)
by_vault: dict[str, dict[str, Any]] = {}
for r in rows:
bucket = by_vault.setdefault(r["vault"], {"count": 0, "size_mb": 0.0})
bucket["count"] += 1
bucket["size_mb"] = round(bucket["size_mb"] + r["size"] / (1024 ** 2), 2)
if rows:
newest_ts = rows[0]["timestamp"]
oldest_ts = rows[-1]["timestamp"]
newest_age_days = round((now_ts - newest_ts) / 86400, 2)
oldest_age_days = round((now_ts - oldest_ts) / 86400, 2)
else:
newest_age_days = 0.0
oldest_age_days = 0.0
return {
"total_backups": len(rows),
"total_size_mb": total_size_mb,
"oldest_age_days": oldest_age_days,
"newest_age_days": newest_age_days,
"by_vault": by_vault,
}
# ── /api/admin/stream — Server-Sent Events ─────────────────────────────
async def _stats_event_generator():
"""Yield an SSE `stats` event with the current system stats."""
try:
import psutil
# Prime cpu_percent so the first real measurement isn't 0.0
psutil.cpu_percent(interval=None)
except Exception:
pass
last_keepalive = time.time()
KEEPALIVE_INTERVAL = 15.0
STATS_INTERVAL = 5.0
try:
while True:
now = time.time()
try:
data = await asyncio.to_thread(_build_stats_payload)
except Exception as e:
logger.warning(f"SSE stats build failed: {e}")
data = {"error": str(e)}
yield f"event: stats\ndata: {_format_timestamp_for_stream(data)}\n\n"
if now - last_keepalive >= KEEPALIVE_INTERVAL:
last_keepalive = now
yield ": keepalive\n\n"
await asyncio.sleep(STATS_INTERVAL)
except asyncio.CancelledError:
logger.info("SSE stats stream cancelled")
raise
def _build_stats_payload() -> dict:
"""Synchronous stats builder for the SSE generator."""
import psutil
cpu_pct = psutil.cpu_percent(interval=None)
vm = psutil.virtual_memory()
disk_used_gb, disk_total_gb = _get_disk_stats()
return {
"cpu_pct": cpu_pct,
"mem_used_mb": round(vm.used / (1024 ** 2), 1),
"mem_total_mb": round(vm.total / (1024 ** 2), 1),
"disk_used_gb": disk_used_gb,
"disk_total_gb": disk_total_gb,
"uptime_seconds": int(_server_uptime_seconds()),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@router.get("/stream")
async def stream_admin_stats(_admin=Depends(require_admin)):
"""Server-Sent Events stream of system metrics, every 5 seconds."""
return StreamingResponse(
_stats_event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
+43 -4
View File
@@ -1,6 +1,6 @@
"""ObsiGate AI — Multi-provider AI service for editor enhancement.
Supports: DeepSeek, OpenRouter, Google Gemini.
Supports: DeepSeek, OpenRouter, Google Gemini, Ollama, NVIDIA, QwenCloud, Xiaomi, Mistral.
Configured via environment variables.
"""
@@ -14,7 +14,7 @@ import httpx
logger = logging.getLogger("obsigate.ai")
ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama"]
ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama", "nvidia", "qwencloud", "xiaomi", "mistral"]
# Provider configurations — keys loaded from file or .env
AI_KEYS_FILE = Path("data/api_keys.json")
@@ -61,6 +61,34 @@ def _load_provider_keys():
"model": os.getenv("OLLAMA_MODEL", "qwen2.5-coder:1.5b"),
"auth_header": "Bearer {api_key}",
},
"nvidia": {
"api_key": get_ai_key("NVIDIA_API_KEY"),
"base_url": "https://integrate.api.nvidia.com/v1",
"model": os.getenv("NVIDIA_MODEL", "meta/llama-3.1-405b-instruct"),
"auth_header": "Bearer {api_key}",
},
"qwencloud": {
"api_key": get_ai_key("QWENCLOUD_API_KEY"),
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": os.getenv("QWENCLOUD_MODEL", "qwen-max"),
"auth_header": "Bearer {api_key}",
},
"xiaomi": {
"api_key": get_ai_key("XIAOMI_API_KEY"),
"base_url": os.getenv("XIAOMI_BASE_URL", "https://api.xiaomimimo.com/v1"),
"model": os.getenv("XIAOMI_MODEL", "mimo-v2.5-pro"),
# Xiaomi MiMo uses a dedicated `api-key` header (NOT Authorization: Bearer).
# The `_call_deepseek_openrouter` helper substitutes {api_key} verbatim,
# so we just emit the raw key value here.
"auth_header": "{api_key}",
"auth_header_name": "api-key",
},
"mistral": {
"api_key": get_ai_key("MISTRAL_API_KEY"),
"base_url": "https://api.mistral.ai/v1",
"model": os.getenv("MISTRAL_MODEL", "mistral-large-latest"),
"auth_header": "Bearer {api_key}",
},
}
PROVIDERS = _load_provider_keys()
@@ -86,13 +114,23 @@ def _get_provider_config(provider: ProviderName | None = None) -> dict:
async def _call_deepseek_openrouter(prompt: str, system: str, provider: ProviderName | None = None,
temperature: float = 0.7, max_tokens: int = 2048) -> str:
"""Call OpenAI-compatible API (DeepSeek, OpenRouter)."""
"""Call OpenAI-compatible API (DeepSeek, OpenRouter, Xiaomi MiMo, etc.)."""
cfg = _get_provider_config(provider)
# Debug: log masked key to diagnose 401
key_preview = cfg["api_key"][:8] + "..." + cfg["api_key"][-4:] if len(cfg["api_key"]) > 12 else "***"
logger.info(f"AI call: provider={cfg['name']} model={cfg['model']} key={key_preview}")
# Most providers use "Authorization: Bearer KEY". Some (Xiaomi MiMo) use a
# dedicated header like "api-key: KEY". We support both via the
# `auth_header_name` key in PROVIDERS — defaults to "Authorization".
header_name = cfg.get("auth_header_name") or "Authorization"
header_value = cfg["auth_header"].format(api_key=cfg["api_key"])
# If the auth_header template doesn't include "Bearer " but the default
# header is Authorization, prepend it. This preserves backward compatibility
# for providers that store just the raw key.
if header_name == "Authorization" and not header_value.lower().startswith("bearer "):
header_value = "Bearer " + header_value
headers = {
"Authorization": cfg["auth_header"].format(api_key=cfg["api_key"]),
header_name: header_value,
"Content-Type": "application/json",
}
payload = {
@@ -139,6 +177,7 @@ async def ai_complete(prompt: str, provider: ProviderName | None = None) -> str:
cfg = _get_provider_config(provider)
if cfg["name"] == "gemini":
return await _call_gemini(prompt, "You are a helpful assistant.")
# All other providers use OpenAI-compatible format
return await _call_deepseek_openrouter(prompt, "You are a helpful assistant.", provider)
+17 -2
View File
@@ -25,12 +25,13 @@ from backend.ai import (
ai_summarize,
ai_translate,
)
from backend.schemas import AIStatusResponse
logger = logging.getLogger("obsigate.ai_routes")
router = APIRouter(prefix="/api/ai", tags=["AI"])
@router.get("/status")
@router.get("/status", response_model=AIStatusResponse)
async def api_status():
"""Check if AI is configured and which providers are available.
@@ -42,6 +43,10 @@ async def api_status():
"deepseek": "DEEPSEEK_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"gemini": "GEMINI_API_KEY",
"nvidia": "NVIDIA_API_KEY",
"qwencloud": "QWENCLOUD_API_KEY",
"xiaomi": "XIAOMI_API_KEY",
"mistral": "MISTRAL_API_KEY",
}
providers = {}
for name, env_var in provider_keys.items():
@@ -97,7 +102,8 @@ class AIRequest(BaseModel):
instruction: str | None = Field(None, description="Custom instruction for rewrite")
target_lang: str | None = Field(None, description="Target language for translation")
tone: str | None = Field(None, description="Target tone (professional, casual, etc.)")
provider: str | None = Field(None, description="AI provider override")
provider: str | None = Field(None, description="AI provider override (e.g. 'deepseek', 'nvidia')")
model: str | None = Field(None, description="Model name override for this request")
class AIResponse(BaseModel):
@@ -107,6 +113,12 @@ class AIResponse(BaseModel):
async def _handle(action, request: AIRequest):
"""Wrapper with error handling."""
from backend.ai import PROVIDERS
# Apply per-request model override (saved/restored around the call)
original_model = None
if request.model and request.provider and request.provider in PROVIDERS:
original_model = PROVIDERS[request.provider].get("model")
PROVIDERS[request.provider]["model"] = request.model
try:
result = await action(request.text, request.provider)
return AIResponse(result=result, provider=request.provider or "default")
@@ -115,6 +127,9 @@ async def _handle(action, request: AIRequest):
except Exception as e:
logger.error(f"AI error: {e}")
raise HTTPException(status_code=500, detail=f"AI service error: {e!s}")
finally:
if original_model is not None and request.provider in PROVIDERS:
PROVIDERS[request.provider]["model"] = original_model
@router.post("/improve", response_model=AIResponse)
+63
View File
@@ -0,0 +1,63 @@
# backend/auth/mfa.py
# Multi-Factor Authentication: TOTP + recovery codes.
# TOTP via pyotp, recovery codes hashed with argon2 for single-use storage.
import hashlib
import logging
import secrets
import pyotp
logger = logging.getLogger("obsigate.auth.mfa")
TOTP_ISSUER = "ObsiGate"
def generate_secret() -> str:
"""Generate a new TOTP secret (base32-encoded, 160 bits)."""
return pyotp.random_base32()
def generate_qr_uri(secret: str, username: str, issuer: str = TOTP_ISSUER) -> str:
"""Generate an otpauth:// URI for QR code generation."""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name=issuer)
def verify_totp(secret: str, code: str) -> bool:
"""Verify a TOTP code with a ±1 window tolerance."""
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
def generate_recovery_codes(n: int = 8) -> list[str]:
"""Generate n human-readable recovery codes (XXXX-XXXX format)."""
codes = []
for _ in range(n):
# 8 chars alphanumeric, grouped with dash for readability
raw = secrets.token_hex(4).upper()
code = f"{raw[:4]}-{raw[4:]}"
codes.append(code)
return codes
def hash_recovery_code(code: str) -> str:
"""Hash a recovery code for storage (SHA-256 for fast comparison).
We use SHA-256 instead of argon2 here because recovery codes are
high-entropy random strings, not user-chosen passwords.
"""
return hashlib.sha256(code.upper().encode("utf-8")).hexdigest()
def verify_recovery_code(code: str, hashed_codes: list[str]) -> int | None:
"""Verify a recovery code against stored hashes.
Returns the index of the matched code (for removal), or None if invalid.
Comparison is case-insensitive.
"""
code_hash = hash_recovery_code(code)
for i, stored_hash in enumerate(hashed_codes):
if secrets.compare_digest(code_hash, stored_hash):
return i
return None
+407 -11
View File
@@ -5,7 +5,7 @@
import logging
import re
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from pydantic import BaseModel, validator
from backend.ratelimit import is_rate_limited
@@ -20,6 +20,14 @@ from .jwt_handler import (
is_token_revoked,
revoke_token,
)
from .mfa import (
generate_qr_uri,
generate_recovery_codes,
generate_secret,
hash_recovery_code,
verify_recovery_code,
verify_totp,
)
from .middleware import is_auth_enabled, require_admin, require_auth
from .password import hash_password, verify_password
from .user_store import (
@@ -136,16 +144,32 @@ async def login(body: LoginRequest, response: Response, request: Request):
detail += f" ({remaining} tentative(s) restante(s))"
raise HTTPException(401, detail)
# Success — clear rate limits and generate tokens
record_login_success(body.username)
# Success — clear rate limits
rl_record_success(client_ip)
# If MFA is enabled, don't issue token yet — require second factor
if user.get("mfa_enabled"):
method = "totp" if user.get("mfa_secret") else _preferred_mfa_method(user)
logger.info(f"User '{body.username}' login deferred — MFA required ({method})")
return {
"mfa_required": True,
"mfa_method": method,
"username": body.username,
"remember_me": body.remember_me,
}
return _issue_tokens(user, body.username, body.remember_me, response)
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
record_login_success(username)
access_token = create_access_token(user)
refresh_token, refresh_jti = create_refresh_token(body.username)
refresh_token, refresh_jti = create_refresh_token(username)
# Set refresh token as HttpOnly cookie (path-restricted to /api/auth/refresh)
max_age = 2592000 if body.remember_me else 604800 # 30d or 7d
import os
max_age = 2592000 if remember_me else 604800 # 30d or 7d
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
response.set_cookie(
key="refresh_token",
@@ -156,10 +180,7 @@ async def login(body: LoginRequest, response: Response, request: Request):
secure=secure,
path="/api/auth/refresh",
)
logger.info(f"User '{body.username}' logged in")
# Set access token as cookie for same-origin requests (e.g. popout window)
logger.info(f"User '{username}' logged in")
response.set_cookie(
key="access_token",
value=access_token,
@@ -169,7 +190,6 @@ async def login(body: LoginRequest, response: Response, request: Request):
secure=secure,
path="/",
)
return {
"access_token": access_token,
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
@@ -304,6 +324,382 @@ async def change_password(
return {"message": "Mot de passe mis à jour"}
# ── MFA endpoints ────────────────────────────────────────────────────
class MfaVerifyRequest(BaseModel):
username: str
code: str
remember_me: bool = False
class MfaRecoveryRequest(BaseModel):
username: str
recovery_code: str
class MfaDisableRequest(BaseModel):
password: str
code: str
class MfaEnableRequest(BaseModel):
code: str
@router.post("/mfa/totp/setup")
async def mfa_totp_setup(current_user=Depends(require_auth)):
"""Generate a TOTP secret and QR URI for MFA setup.
Returns the secret and otpauth URI — client displays QR code.
Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify.
"""
from .user_store import update_user
secret = generate_secret()
qr_uri = generate_qr_uri(secret, current_user["username"])
# Store secret temporarily (not yet enabled)
update_user(current_user["username"], {
"mfa_secret_pending": secret,
})
return {
"secret": secret,
"qr_uri": qr_uri,
"otpauth_uri": qr_uri,
}
@router.post("/mfa/totp/enable")
async def mfa_totp_enable(
req: MfaEnableRequest,
current_user=Depends(require_auth),
):
"""Enable MFA after verifying the first TOTP code.
On success: generates recovery codes, enables MFA, returns recovery codes.
"""
from .user_store import get_user, update_user
user = get_user(current_user["username"])
secret = user.get("mfa_secret_pending")
if not secret:
raise HTTPException(400, "Aucune configuration MFA en cours. Commencez par /mfa/totp/setup")
if not verify_totp(secret, req.code):
raise HTTPException(400, "Code TOTP invalide")
# Generate recovery codes
recovery_codes = generate_recovery_codes()
hashed_codes = [hash_recovery_code(c) for c in recovery_codes]
# Enable MFA
update_user(current_user["username"], {
"mfa_enabled": True,
"mfa_secret": secret,
"mfa_method": "totp",
"mfa_recovery_codes": hashed_codes,
"mfa_secret_pending": None, # clear pending
})
logger.info(f"MFA enabled for user '{current_user['username']}'")
return {
"mfa_enabled": True,
"recovery_codes": recovery_codes, # shown once, client must display/save
}
@router.post("/mfa/totp/disable")
async def mfa_totp_disable(
req: MfaDisableRequest,
current_user=Depends(require_auth),
):
"""Disable MFA. Requires current password + valid TOTP code."""
from .user_store import get_user, update_user
user = get_user(current_user["username"])
if not user.get("mfa_enabled"):
raise HTTPException(400, "MFA non activé")
if not verify_password(req.password, user["password_hash"]):
raise HTTPException(400, "Mot de passe incorrect")
if not verify_totp(user["mfa_secret"], req.code):
raise HTTPException(400, "Code TOTP invalide")
update_user(current_user["username"], {
"mfa_enabled": False,
"mfa_secret": None,
"mfa_method": None,
"mfa_recovery_codes": [],
})
logger.info(f"MFA disabled for user '{current_user['username']}'")
return {"mfa_enabled": False}
# ── WebAuthn endpoints (ROADMAP #64) ─────────────────────────────────
def _preferred_mfa_method(user: dict) -> str:
"""Which second factor to offer at login: webauthn when keys exist, else totp."""
if user.get("webauthn_credentials"):
return "webauthn"
return "totp"
class WebauthnRegisterRequest(BaseModel):
credential: dict
label: str = ""
class WebauthnVerifyRequest(BaseModel):
username: str
credential: dict
remember_me: bool = False
class WebauthnRemoveRequest(BaseModel):
credential_id: str
password: str
@router.post("/mfa/webauthn/register/options")
async def mfa_webauthn_register_options(current_user=Depends(require_auth)):
"""Start WebAuthn key enrolment — returns publicKey creation options for the browser."""
from .webauthn_mfa import begin_registration
options = begin_registration(current_user["username"],
current_user.get("display_name", ""))
return {"options": options}
@router.post("/mfa/webauthn/register")
async def mfa_webauthn_register(
req: WebauthnRegisterRequest,
current_user=Depends(require_auth),
):
"""Verify the created credential, store it, and enable MFA if not already on.
Returns recovery codes when MFA is newly enabled (they were never issued).
"""
from datetime import datetime, timezone
from .user_store import get_user, update_user
from .webauthn_mfa import complete_registration
user = get_user(current_user["username"])
try:
record = complete_registration(current_user["username"], req.credential,
label=req.label)
except ValueError as e:
raise HTTPException(400, str(e))
except Exception as e:
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
raise HTTPException(400, "Validation du credential WebAuthn échouée")
record["registered_at"] = datetime.now(timezone.utc).isoformat()
creds = list(user.get("webauthn_credentials", []))
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
creds.append(record)
updates: dict = {"webauthn_credentials": creds}
issued_recovery: list[str] = []
if not user.get("mfa_enabled"):
issued_recovery = generate_recovery_codes()
updates.update({
"mfa_enabled": True,
"mfa_method": "webauthn",
"mfa_recovery_codes": [hash_recovery_code(c) for c in issued_recovery],
})
update_user(current_user["username"], updates)
logger.info(f"WebAuthn credential registered for user '{current_user['username']}' "
f"({record['label']})")
return {
"ok": True,
"credentials": user_credentials_response(creds),
"mfa_enabled": True,
"recovery_codes": issued_recovery,
}
def user_credentials_response(creds: list[dict]) -> list[dict]:
from .webauthn_mfa import credentials_for_api
return credentials_for_api(creds)
@router.get("/mfa/webauthn/credentials")
async def mfa_webauthn_list(current_user=Depends(require_auth)):
from .user_store import get_user
user = get_user(current_user["username"])
return {"credentials": user_credentials_response(user.get("webauthn_credentials", []))}
@router.post("/mfa/webauthn/credentials/remove")
async def mfa_webauthn_remove(
req: WebauthnRemoveRequest,
current_user=Depends(require_auth),
):
"""Remove a WebAuthn key. Requires password. Disables MFA if no second factor remains."""
from .user_store import get_user, update_user
from .webauthn_mfa import clear_pending
user = get_user(current_user["username"])
if not verify_password(req.password, user["password_hash"]):
raise HTTPException(400, "Mot de passe incorrect")
creds = [c for c in user.get("webauthn_credentials", [])
if c.get("credential_id") != req.credential_id]
if len(creds) == len(user.get("webauthn_credentials", [])):
raise HTTPException(404, "Credential inconnu")
updates: dict = {"webauthn_credentials": creds}
if not creds and not user.get("mfa_secret"):
updates.update({"mfa_enabled": False, "mfa_method": None, "mfa_recovery_codes": []})
elif not creds and user.get("mfa_secret"):
updates["mfa_method"] = "totp"
update_user(current_user["username"], updates)
clear_pending(current_user["username"])
return {"ok": True, "credentials": user_credentials_response(creds),
"mfa_enabled": bool(updates.get("mfa_enabled", user.get("mfa_enabled"))) and bool(creds or user.get("mfa_secret"))}
@router.post("/mfa/webauthn/options")
async def mfa_webauthn_login_options(body: dict = Body(...)):
"""Unauthenticated: begin the login assertion for a user with registered keys.
Enumeration-safe: always 200 — returns null options (caller falls back to
TOTP/recovery UI) when the user has no WebAuthn key or MFA is off.
"""
username = str(body.get("username", ""))
user = get_user(username)
creds = (user or {}).get("webauthn_credentials", [])
if not user or not user.get("mfa_enabled") or not creds:
return {"mfa_method": "totp", "options": None}
from .webauthn_mfa import begin_authentication
options = begin_authentication(username, creds)
if options is None:
return {"mfa_method": "totp", "options": None}
return {"mfa_method": "webauthn", "options": options}
@router.post("/mfa/webauthn/verify")
async def mfa_webauthn_verify(
body: WebauthnVerifyRequest,
response: Response,
request: Request,
):
"""Unauthenticated: verify the WebAuthn assertion and issue JWT tokens."""
from .user_store import get_user, update_user
from .webauthn_mfa import complete_authentication
user = get_user(body.username)
if not user:
hash_password("dummy_timing_protection")
raise HTTPException(401, "Identifiants invalides")
if not user.get("mfa_enabled"):
raise HTTPException(400, "MFA non activé pour cet utilisateur")
creds = user.get("webauthn_credentials", [])
try:
credential_id = body.credential.get("id", "")
stored = next((c for c in creds if c.get("credential_id") == credential_id), None)
if stored is None:
raise ValueError("Credential non enregistré")
new_count = complete_authentication(body.username, body.credential, stored)
except ValueError as e:
raise HTTPException(401, str(e))
except Exception as e:
logger.warning(f"WebAuthn verification failed for {body.username}: {e}")
raise HTTPException(401, "Vérification WebAuthn échouée")
updated = [dict(c) for c in creds]
for c in updated:
if c.get("credential_id") == body.credential.get("id"):
c["sign_count"] = new_count
update_user(body.username, {"webauthn_credentials": updated})
client_ip = request.client.host if request.client else "unknown"
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)
@router.get("/mfa/status")
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"])
return {
"mfa_enabled": user.get("mfa_enabled", False),
"mfa_method": user.get("mfa_method"),
"totp_enabled": bool(user.get("mfa_secret")),
"webauthn_credentials": len(user.get("webauthn_credentials", [])),
}
@router.post("/mfa/totp/verify")
async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: Request):
"""Verify TOTP code during login (second factor).
Called after login returns mfa_required=true.
On success: issues JWT tokens.
"""
from .user_store import get_user
user = get_user(body.username)
if not user:
# Timing-safe: simulate work
hash_password("dummy_timing_protection")
raise HTTPException(401, "Identifiants invalides")
if not user.get("mfa_enabled") or not user.get("mfa_secret"):
raise HTTPException(400, "MFA non activé pour cet utilisateur")
if not verify_totp(user["mfa_secret"], body.code):
raise HTTPException(401, "Code TOTP invalide")
# Clear IP rate limit on success
client_ip = request.client.host if request.client else "unknown"
rl_record_success(client_ip)
return _issue_tokens(user, body.username, body.remember_me, response)
@router.post("/mfa/recovery")
async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, request: Request):
"""Login with a recovery code (when TOTP device is unavailable).
Each recovery code is single-use.
"""
from .user_store import get_user, update_user
user = get_user(body.username)
if not user:
hash_password("dummy_timing_protection")
raise HTTPException(401, "Identifiants invalides")
if not user.get("mfa_enabled"):
raise HTTPException(400, "MFA non activé pour cet utilisateur")
hashed_codes = user.get("mfa_recovery_codes", [])
if not hashed_codes:
raise HTTPException(400, "Aucun code de récupération disponible")
idx = verify_recovery_code(body.recovery_code, hashed_codes)
if idx is None:
raise HTTPException(401, "Code de récupération invalide")
# Remove used recovery code (single-use)
hashed_codes.pop(idx)
update_user(body.username, {"mfa_recovery_codes": hashed_codes})
# Clear IP rate limit
client_ip = request.client.host if request.client else "unknown"
rl_record_success(client_ip)
logger.info(f"User '{body.username}' logged in via recovery code")
return _issue_tokens(user, body.username, False, response)
# ── Admin endpoints ───────────────────────────────────────────────────
@router.get("/admin/users")
+184
View File
@@ -0,0 +1,184 @@
# backend/auth/webauthn_mfa.py
# WebAuthn support for MFA (ROADMAP #64): security keys / platform biometrics.
# Thin wrapper over the `webauthn` library with an in-memory challenge store.
#
# Credentials are persisted in users.json under "webauthn_credentials":
# [{ "credential_id": <b64url>, "public_key": <b64url>, "sign_count": int,
# "transports": [...], "label": str, "registered_at": iso }]
from __future__ import annotations
import logging
import os
import secrets
import time
from typing import Any
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers import (
base64url_to_bytes,
bytes_to_base64url,
parse_authentication_credential_json,
parse_registration_credential_json,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
ResidentKeyRequirement,
UserVerificationRequirement,
)
logger = logging.getLogger("obsigate.auth.webauthn")
# Challenge lifetime: clients have 3 minutes to complete the ceremony.
CHALLENGE_TTL_SECONDS = 180
# In-memory pending challenges: key -> (challenge_bytes, expires_at)
_pending: dict[str, tuple[bytes, float]] = {}
def rp_id() -> str:
return os.environ.get("OBSIGATE_WEBAUTHN_RP_ID", "localhost")
def rp_name() -> str:
return os.environ.get("OBSIGATE_WEBAUTHN_RP_NAME", "ObsiGate")
def expected_origins() -> list[str]:
raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS", "http://localhost")
return [o.strip() for o in raw.split(",") if o.strip()]
def _prune_expired() -> None:
now = time.time()
for key in [k for k, (_, exp) in _pending.items() if exp < now]:
_pending.pop(key, None)
def _store_challenge(key: str) -> bytes:
_prune_expired()
challenge = secrets.token_bytes(32)
_pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS)
return challenge
def _take_challenge(key: str) -> bytes | None:
"""Pop a challenge (single-use). Returns None if missing/expired."""
_prune_expired()
entry = _pending.pop(key, None)
return entry[0] if entry else None
def clear_pending(username: str) -> None:
"""Drop all pending challenges for a user (e.g. after enable/disable)."""
for key in [k for k in _pending if k.startswith(f"{username}:")]:
_pending.pop(key, None)
# ── Registration (enrol a key in settings) ─────────────────────────────
def begin_registration(username: str, display_name: str) -> dict:
options = generate_registration_options(
rp_id=rp_id(),
rp_name=rp_name(),
user_name=username,
user_display_name=display_name or username,
challenge=_store_challenge(f"{username}:register"),
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
)
return _finalize_options(options)
def complete_registration(username: str, credential_json: dict[str, Any],
label: str = "") -> dict:
challenge = _take_challenge(f"{username}:register")
if challenge is None:
raise ValueError("Session d'enregistrement expirée — recommencez")
credential = parse_registration_credential_json(credential_json)
verification = verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id(),
expected_origin=expected_origins(),
)
transports = credential.response.transports or []
label = (label or str(credential_json.get("label") or "")).strip() or "Security key"
record = {
"credential_id": bytes_to_base64url(verification.credential_id),
"public_key": bytes_to_base64url(verification.credential_public_key),
"sign_count": int(verification.sign_count),
"transports": [str(t) for t in transports],
"label": label[:60],
}
return record
# ── Authentication (assertion at login) ────────────────────────────────
def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
if not credentials:
return None
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
allow = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
for c in credentials
]
options = generate_authentication_options(
rp_id=rp_id(),
challenge=_store_challenge(f"{username}:login"),
allow_credentials=allow,
)
return _finalize_options(options)
def complete_authentication(
username: str,
credential_json: dict[str, Any],
stored: dict,
) -> int:
"""Verify an assertion. Returns the new sign_count. Raises ValueError on failure."""
challenge = _take_challenge(f"{username}:login")
if challenge is None:
raise ValueError("Session expirée — rechargez la page")
credential = parse_authentication_credential_json(credential_json)
verification = verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id(),
expected_origin=expected_origins(),
credential_public_key=base64url_to_bytes(stored["public_key"]),
credential_current_sign_count=int(stored.get("sign_count", 0)),
)
return int(verification.new_sign_count)
def credentials_for_api(credentials: list[dict]) -> list[dict]:
"""Sanitized view for the settings UI (no public keys)."""
return [
{
"credential_id": c.get("credential_id"),
"label": c.get("label", "Security key"),
"transports": c.get("transports", []),
"registered_at": c.get("registered_at"),
}
for c in credentials
]
def _finalize_options(options) -> dict:
import json
return json.loads(options_to_json(options))
+408
View File
@@ -0,0 +1,408 @@
"""BooksLM — Context collection and caching for directory-scoped AI chat.
Collects markdown files from an Obsidian vault directory, applies secret
redaction, builds a system prompt with file contents, and caches results
for repeated queries.
"""
import hashlib
import json
import logging
import os
import time
from pathlib import Path
from typing import Any
from backend.secret_redactor import redact_file_content
logger = logging.getLogger("obsigate.bookslm")
# ── Configuration limits ──
BOOKSLM_MAX_FILES = int(os.getenv("BOOKSLM_MAX_FILES", "200"))
BOOKSLM_MAX_TOTAL_CHARS = int(os.getenv("BOOKSLM_MAX_TOTAL_CHARS", "200000"))
BOOKSLM_MAX_FILE_CHARS = int(os.getenv("BOOKSLM_MAX_FILE_CHARS", "30000"))
# ── Cache ──
_cache: dict[str, dict[str, Any]] = {}
_CACHE_TTL = 300 # seconds
def _cache_key(vault_path: Path, directory: str, file_mtimes: list[tuple[str, float]]) -> str:
"""Build a SHA-256 cache key from vault+directory+file modification times."""
raw = json.dumps({
"vault": str(vault_path),
"dir": directory,
"mtimes": sorted(file_mtimes),
}, sort_keys=True)
return hashlib.sha256(raw.encode()).hexdigest()
def _should_skip(name: str) -> bool:
"""Return True if this file/directory name should be skipped."""
skip_prefixes = (".",)
skip_names = {"_attachments", "node_modules", ".git", ".obsidian", "__pycache__"}
if name in skip_names:
return True
return bool(any(name.startswith(p) for p in skip_prefixes))
def _file_priority(path: Path) -> tuple[int, float]:
"""Sort key: README/index first, then by modification time descending.
Returns (priority_group, -mtime) so that:
- Group 0: README* and index* files (come first)
- Group 1: all other files (come after)
Within each group, newer files come first.
"""
name_lower = path.stem.lower()
if name_lower.startswith("readme") or name_lower.startswith("index"):
group = 0
else:
group = 1
try:
mtime = path.stat().st_mtime
except OSError:
mtime = 0.0
return (group, -mtime)
def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any]:
"""Walk a directory recursively, collect .md files with content.
Args:
vault_path: Absolute path to the vault root.
directory: Relative directory path within the vault (empty = root).
Returns:
Dict with keys: files, total_chars, file_count, directory_tree.
"""
target_dir = (vault_path / directory).resolve() if directory else vault_path.resolve()
vault_resolved = vault_path.resolve()
# Safety: ensure target is within vault
try:
target_dir.relative_to(vault_resolved)
except ValueError:
logger.warning(f"Directory outside vault: {target_dir}")
return {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
if not target_dir.exists() or not target_dir.is_dir():
return {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
# Check cache
file_mtimes: list[tuple[str, float]] = []
md_files: list[Path] = []
try:
for p in target_dir.rglob("*"):
# Skip hidden dirs/files and special dirs
parts = p.relative_to(target_dir).parts
if any(_should_skip(part) for part in parts):
continue
if p.is_file() and p.suffix.lower() == ".md":
md_files.append(p)
try:
file_mtimes.append((str(p.relative_to(target_dir)), p.stat().st_mtime))
except OSError:
file_mtimes.append((str(p.relative_to(target_dir)), 0.0))
except PermissionError:
logger.warning(f"Permission denied scanning {target_dir}")
return {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
key = _cache_key(vault_resolved, directory, file_mtimes)
if key in _cache:
cached = _cache[key]
if time.time() - cached.get("_ts", 0) < _CACHE_TTL:
logger.debug(f"Cache hit for {directory}")
return {k: v for k, v in cached.items() if k != "_ts"}
# Sort by priority: README/index first, then by mtime descending
md_files.sort(key=_file_priority)
# Apply limits
collected: list[dict[str, Any]] = []
total_chars = 0
for p in md_files:
if len(collected) >= BOOKSLM_MAX_FILES:
break
if total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
break
rel_path = str(p.relative_to(vault_resolved)).replace("\\", "/")
try:
content = p.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(f"Cannot read {rel_path}: {e}")
continue
# Redact secrets
content = redact_file_content(content, rel_path)
# Truncate if too long
if len(content) > BOOKSLM_MAX_FILE_CHARS:
content = content[:BOOKSLM_MAX_FILE_CHARS] + "\n\n[... tronqué]"
remaining = BOOKSLM_MAX_TOTAL_CHARS - total_chars
if len(content) > remaining:
content = content[:remaining] + "\n\n[... tronqué]"
title = p.stem.replace("-", " ").replace("_", " ").title()
file_type = "markdown"
collected.append({
"path": rel_path,
"title": title,
"content": content,
"type": file_type,
})
total_chars += len(content)
# Build directory tree
dir_tree = _build_directory_tree(target_dir, vault_resolved)
result = {
"files": collected,
"total_chars": total_chars,
"file_count": len(collected),
"directory_tree": dir_tree,
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
"max_files": BOOKSLM_MAX_FILES,
"scope": "directory",
}
# Store in cache
_cache[key] = {**result, "_ts": time.time()}
logger.info(f"Collected {len(collected)} files ({total_chars} chars) from {directory or '/'}")
return result
def _file_entry(target: Path, rel_path: str, remaining: int) -> dict[str, Any] | None:
"""Read, redact and truncate a single file into a context entry."""
suffix = target.suffix.lower()
try:
if suffix == ".pdf":
from backend.pdf_reader import extract_pdf_text
content = extract_pdf_text(target)
file_type = "pdf"
else:
content = target.read_text(encoding="utf-8", errors="replace")
file_type = "markdown"
except Exception as e:
logger.warning(f"Cannot read {rel_path}: {e}")
return None
content = redact_file_content(content, rel_path)
if len(content) > BOOKSLM_MAX_FILE_CHARS:
content = content[:BOOKSLM_MAX_FILE_CHARS] + "\n\n[... tronqué]"
if len(content) > remaining:
content = content[:remaining] + "\n\n[... tronqué]"
return {
"path": rel_path,
"title": target.stem.replace("-", " ").replace("_", " ").title(),
"content": content,
"type": file_type,
}
def collect_files_context(
vault_path: Path,
rel_paths: list[str],
scope: str = "documents",
) -> dict[str, Any]:
"""Collect an explicit list of files (open documents) as AI context.
Unlike :func:`collect_directory_context`, this reads only the requested
relative paths (markdown or PDF), never the whole directory. Paths outside
the vault are silently ignored.
Args:
vault_path: Absolute path to the vault root.
rel_paths: Relative file paths within the vault (in display order).
scope: Context label exposed to the UI ("documents").
Returns:
Same shape as :func:`collect_directory_context`.
"""
vault_resolved = vault_path.resolve()
collected: list[dict[str, Any]] = []
total_chars = 0
seen: set[str] = set()
for rel in rel_paths:
if len(collected) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
break
if not rel or rel in seen:
continue
seen.add(rel)
try:
target = (vault_resolved / rel).resolve()
target.relative_to(vault_resolved)
except (ValueError, OSError):
continue
if not target.is_file():
continue
entry = _file_entry(target, rel, BOOKSLM_MAX_TOTAL_CHARS - total_chars)
if entry is None:
continue
collected.append(entry)
total_chars += len(entry["content"])
return {
"files": collected,
"total_chars": total_chars,
"file_count": len(collected),
"directory_tree": "",
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
"max_files": BOOKSLM_MAX_FILES,
"scope": scope,
}
def empty_context(scope: str = "general") -> dict[str, Any]:
"""Return an empty context payload (used by the General assistant)."""
return {
"files": [],
"total_chars": 0,
"file_count": 0,
"directory_tree": "",
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
"max_files": BOOKSLM_MAX_FILES,
"scope": scope,
}
def _build_directory_tree(target_dir: Path, vault_root: Path) -> str:
"""Build a text representation of the directory tree (dirs + .md files)."""
lines: list[str] = []
try:
for p in sorted(target_dir.rglob("*")):
parts = p.relative_to(target_dir).parts
if any(_should_skip(part) for part in parts):
continue
if p.is_dir():
depth = len(p.relative_to(target_dir).parts)
lines.append(f"{' ' * depth}{p.name}/")
elif p.is_file() and p.suffix.lower() == ".md":
depth = len(p.relative_to(target_dir).parts)
lines.append(f"{' ' * depth}{p.name}")
except PermissionError:
pass
return "\n".join(lines)
def build_system_prompt(context: dict[str, Any], scope: str = "directory") -> str:
"""Build a system prompt for document-scoped AI chat.
Args:
context: Output of collect_directory_context()/collect_files_context().
scope: "directory" (whole folder) or "documents" (open files).
Returns:
System prompt string with file contents.
"""
files = context.get("files", [])
file_count = context.get("file_count", 0)
total_chars = context.get("total_chars", 0)
# Rough token estimate (1 token ≈ 4 chars)
est_tokens = total_chars // 4
token_warning = ""
if est_tokens > 100_000:
token_warning = f"\n⚠️ Attention : le contexte est très volumineux (~{est_tokens:,} tokens estimés). Les réponses peuvent être moins précises.\n"
if scope == "documents":
role = (
"Tu es un assistant de recherche documentaire intégré à ObsiGate. "
"Tu réponds UNIQUEMENT en te basant sur les documents ouverts par l'utilisateur ci-dessous. "
"Cite tes sources avec le nom du fichier quand tu utilises une information. "
"Si l'information ne se trouve pas dans les documents, dis-le clairement."
)
context_label = "📄 Documents ouverts"
else:
role = (
"Tu es un assistant de recherche documentaire. "
"Tu réponds UNIQUEMENT en te basant sur les documents fournis ci-dessous. "
"Cite tes sources avec le nom du fichier quand tu utilises une information. "
"Si l'information ne se trouve pas dans les documents, dis-le clairement."
)
context_label = "📚 Contexte"
prompt = (
f"{role}"
f"\n\n{context_label} : {file_count} fichier(s) ({total_chars:,} caractères)"
f"{token_warning}\n"
)
# Directory tree
tree = context.get("directory_tree", "")
if tree:
prompt += f"\n📂 Arborescence du dossier :\n```\n{tree}\n```\n"
# File contents
prompt += "\n---\n"
for f in files:
prompt += f"\n## 📄 {f['title']} (`{f['path']}`)\n\n{f['content']}\n\n---\n"
prompt += "\nFin du contexte. Réponds à la question de l'utilisateur en te basant uniquement sur ces documents."
return prompt
GENERAL_SYSTEM_PROMPT = """Tu es l'assistant intégré d'ObsiGate, une application web auto-hébergée pour consulter, rechercher et éditer des vaults Obsidian (Markdown).
Tes deux rôles :
1. **Aider sur l'application** : expliquer la navigation, la recherche (full-text, filtres `tag:`, `created:`, `path:`), l'éditeur (CodeMirror, autosave, raccourcis), les onglets et le split view, les sauvegardes et la restauration, le partage public, l'export (HTML/Markdown/ePub/PDF), Mermaid, Excalidraw, les plugins, les thèmes, le mode hors-ligne, le MFA, etc.
2. **Proposer des actions concrètes** : créer un fichier ou un dossier dans un vault.
Quand l'utilisateur demande explicitement de créer un fichier, inclus EXACTEMENT un bloc de ce type dans ta réponse (et rien d'autre à l'intérieur du bloc) :
```obsigate-action
{"action": "create_file", "vault": "<nom du vault>", "path": "<chemin/relatif.md>", "content": "<contenu markdown>"}
```
Pour créer un dossier :
```obsigate-action
{"action": "create_directory", "vault": "<nom du vault>", "path": "<chemin/relatif>"}
```
Règles :
- Ne propose une action que si l'utilisateur la demande explicitement.
- Explique en une phrase ce que fait l'action avant le bloc.
- Utilise un chemin relatif se terminant par `.md` pour un fichier.
- N'invente jamais un nom de vault : utilise l'un des vaults disponibles listés ci-dessous.
- Réponds dans la langue de l'utilisateur, de façon concise et structurée (Markdown).
"""
def build_general_system_prompt(vaults: list[str] | None = None) -> str:
"""System prompt for the General assistant (app help + actions)."""
prompt = GENERAL_SYSTEM_PROMPT
if vaults:
prompt += "\nVaults disponibles : " + ", ".join(sorted(vaults)) + "\n"
else:
prompt += "\nAucun vault n'est actuellement configuré.\n"
return prompt
def invalidate_cache(vault_path: Path | None = None, directory: str | None = None) -> int:
"""Invalidate cache entries.
Args:
vault_path: If provided, only invalidate entries for this vault.
directory: If provided, only invalidate entries for this directory.
Returns:
Number of cache entries removed.
"""
if vault_path is None and directory is None:
count = len(_cache)
_cache.clear()
return count
to_remove = list(_cache)
for k in to_remove:
del _cache[k]
return len(to_remove)
+228
View File
@@ -0,0 +1,228 @@
"""BooksLM API routes — directory-scoped AI chat for Obsidian vaults."""
import json
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from backend.auth.middleware import check_vault_access, require_auth
from backend.bookslm import (
build_general_system_prompt,
build_system_prompt,
collect_directory_context,
collect_files_context,
empty_context,
)
from backend.indexer import get_vault_data, index
from backend.schemas import BooksLMContextResponse
logger = logging.getLogger("obsigate.bookslm_routes")
router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"])
VALID_MODES = {"directory", "documents", "general"}
# ── Request models ──
class BooksLMContextRequest(BaseModel):
vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
directory: str = Field(default="", description="Relative directory path within the vault")
mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
context_files: list[str] = Field(
default_factory=list,
description="Relative file paths to use as context (documents mode)",
)
class BooksLMChatRequest(BaseModel):
vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
directory: str = Field(default="", description="Relative directory path within the vault")
mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
context_files: list[str] = Field(
default_factory=list,
description="Relative file paths to use as context (documents mode)",
)
message: str = Field(description="User message")
conversation_history: list[dict[str, str]] = Field(
default_factory=list,
description="Previous conversation turns [{role, content}]",
)
provider: str | None = Field(
default=None,
description="AI provider override (e.g. 'deepseek', 'openrouter', 'gemini', 'nvidia', 'xiaomi', 'mistral', 'qwencloud'). "
"If not set, uses DEFAULT_PROVIDER.",
)
model: str | None = Field(
default=None,
description="Model name to use for this request. If not set, uses the provider's default model.",
)
def _normalize_mode(mode: str | None) -> str:
mode = (mode or "directory").lower()
return mode if mode in VALID_MODES else "directory"
def _resolve_vault_path(vault: str | None, current_user):
"""Validate vault access and return (vault_name, vault_path)."""
if not vault:
raise HTTPException(status_code=400, detail="Champ 'vault' requis pour ce contexte")
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
vault_data = get_vault_data(vault)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault}' not found")
return vault, Path(vault_data["path"])
def _build_context(mode: str, vault_path: Path | None, directory: str, context_files: list[str]):
"""Collect the context payload for the requested mode."""
if mode == "general":
return empty_context("general")
if mode == "documents":
ctx = collect_files_context(vault_path, context_files, scope="documents") # type: ignore[arg-type]
# No open document could be read → degrade gracefully to General.
return ctx if ctx["file_count"] else empty_context("general")
return collect_directory_context(vault_path, directory) # type: ignore[arg-type]
# ── Endpoints ──
@router.post("/context", response_model=BooksLMContextResponse)
async def api_bookslm_context(
req: BooksLMContextRequest,
current_user=Depends(require_auth),
):
"""Collect context for the AI assistant.
Three modes are supported:
* ``directory`` — every supported file under a vault directory;
* ``documents`` — only the explicitly listed open documents;
* ``general`` — no document context (assistant for the app itself).
"""
mode = _normalize_mode(req.mode)
if mode == "general":
return empty_context("general")
_resolve_vault_path(req.vault, current_user)
vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
return _build_context(mode, vault_path, req.directory, req.context_files)
@router.post(
"/chat",
response_class=StreamingResponse,
responses={200: {"content": {"text/event-stream": {}}, "description": "SSE token stream"}},
)
async def api_bookslm_chat(
req: BooksLMChatRequest,
current_user=Depends(require_auth),
):
"""Chat with the AI assistant.
Builds a system prompt from the selected context (directory, open
documents or general app knowledge), then streams the provider's answer
as Server-Sent Events.
"""
mode = _normalize_mode(req.mode)
vault_path: Path | None = None
if mode != "general":
_resolve_vault_path(req.vault, current_user)
vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
# Collect context
context = _build_context(mode, vault_path, req.directory, req.context_files)
effective_mode = context.get("scope", mode)
# Build system prompt
if effective_mode == "general":
system_prompt = build_general_system_prompt(list(index.keys()))
elif effective_mode == "documents":
system_prompt = build_system_prompt(context, scope="documents")
else:
if context["file_count"] == 0:
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
system_prompt = build_system_prompt(context, scope="directory")
# Call AI provider
from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini
# Build messages with conversation history
messages_text = ""
if req.conversation_history:
for turn in req.conversation_history:
role = turn.get("role", "user")
content = turn.get("content", "")
if role == "user":
messages_text += f"\n\nUtilisateur : {content}"
elif role == "assistant":
messages_text += f"\n\nAssistant : {content}"
# Current message
user_prompt = req.message
if messages_text:
user_prompt = f"Historique de la conversation :{messages_text}\n\nQuestion actuelle : {req.message}"
async def generate_sse():
try:
# Resolve provider: explicit override wins, else default.
# Fall back to first available if the requested one isn't configured.
cfg_name = (req.provider or DEFAULT_PROVIDER).lower()
if cfg_name not in PROVIDERS or not PROVIDERS[cfg_name].get("api_key"):
# Try next available provider
for pname, pcfg in PROVIDERS.items():
if pcfg.get("api_key") and pname != "gemini":
cfg_name = pname
break
else:
# No provider available at all
err = "Aucun fournisseur AI configuré (clés API manquantes)"
error_data = json.dumps({"error": err}, ensure_ascii=False)
yield f"event: error\ndata: {error_data}\n\n"
return
# Optional per-request model override
original_model = None
if req.model and cfg_name in PROVIDERS:
original_model = PROVIDERS[cfg_name].get("model")
PROVIDERS[cfg_name]["model"] = req.model
try:
if cfg_name == "gemini":
response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096)
else:
response = await _call_deepseek_openrouter(
user_prompt, system_prompt,
provider=cfg_name,
temperature=0.3,
max_tokens=4096,
)
finally:
# Restore the original model so other calls aren't affected
if original_model is not None and cfg_name in PROVIDERS:
PROVIDERS[cfg_name]["model"] = original_model
# Send the full response as a single SSE event
data = json.dumps({"token": response, "provider": cfg_name, "model": req.model or PROVIDERS.get(cfg_name, {}).get("model", "")}, ensure_ascii=False)
yield f"event: message\ndata: {data}\n\n"
yield "event: done\ndata: {}\n\n"
except Exception as e:
logger.error(f"BooksLM chat error: {e}")
error_data = json.dumps({"error": str(e)}, ensure_ascii=False)
yield f"event: error\ndata: {error_data}\n\n"
return StreamingResponse(
generate_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+431
View File
@@ -0,0 +1,431 @@
"""
Export utilities for ObsiGate — standalone HTML, Markdown bundle (.zip) and ePub.
Each function converts vault content (markdown notes) into a self-contained
downloadable artifact. Rendering reuses ``mistune`` (already a core dependency)
so no extra markdown engine is required. The ePub generator builds a minimal
but valid EPUB 3 container by hand (``zipfile`` only) so there is no hard
dependency on ``ebooklib``.
All functions raise :class:`ExportError` on any failure (missing file, binary
content, unreadable source, ...) so callers can translate to HTTP errors.
"""
from __future__ import annotations
import base64
import datetime
import html as html_mod
import io
import logging
import mimetypes
import re
import unicodedata
import zipfile
from pathlib import Path
import frontmatter
import mistune
logger = logging.getLogger("obsigate.export")
class ExportError(Exception):
"""Raised when an export operation cannot be completed."""
# Cached mistune renderer (singleton) — mirrors backend.main's configuration.
_markdown = mistune.create_markdown(
escape=False,
plugins=["table", "strikethrough", "footnotes", "task_lists"],
)
# File extensions considered "safe" text files for markdown-like exports.
_MARKDOWN_EXTS = {".md", ".markdown", ".mdown", ".mkd", ".txt"}
_HTML_CSS = """
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 760px;
margin: 40px auto;
padding: 0 24px;
line-height: 1.7;
color: #1a1a2e;
font-size: 16px;
}
h1 { font-size: 28px; border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }
h2 { font-size: 22px; margin-top: 28px; border-bottom: 1px solid #ddd; padding-bottom: 4px; }
h3 { font-size: 18px; margin-top: 24px; }
h4, h5, h6 { font-size: 16px; margin-top: 20px; }
p { margin: 10px 0; }
pre {
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px 16px;
font-size: 14px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
code { font-size: 14px; background: #f5f5f5; padding: 2px 5px; border-radius: 3px; }
pre code { background: none; padding: 0; }
a { color: #4f46e5; text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; border-radius: 4px; }
blockquote { border-left: 3px solid #ccc; padding-left: 14px; color: #555; margin: 12px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 14px; }
th { background: #f0f0f0; font-weight: 600; }
ul, ol { margin: 8px 0; padding-left: 24px; }
li { margin: 2px 0; }
hr { border: none; border-top: 1px solid #ddd; margin: 20px 0; }
nav.export-nav {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 14px 18px;
margin: 16px 0 28px;
background: #fafafa;
}
nav.export-nav h2 { border: none; margin: 0 0 8px; font-size: 15px; text-transform: uppercase; letter-spacing: .04em; }
nav.export-nav ul { margin: 0; padding-left: 20px; }
.wikilink-missing { color: #c0392b; }
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _slugify(text: str) -> str:
"""URL-safe slug from arbitrary text (Unicode-aware)."""
text = html_mod.unescape(re.sub(r"<[^>]+>", "", text))
text = text.lower()
text = unicodedata.normalize("NFD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
cleaned = []
for ch in text:
if unicodedata.category(ch).startswith(("L", "N")) or ch in (" ", "-"):
cleaned.append(ch)
text = re.sub(r"\s+", "-", "".join(cleaned))
text = re.sub(r"-+", "-", text)
return text.strip("-") or "document"
def _resolve(vault_path: Path, file_path: Path | str) -> Path:
"""Resolve *file_path* against *vault_path*, accepting absolute or relative.
Returns an absolute :class:`Path`. Does not require the file to exist.
"""
file_path = Path(file_path)
if file_path.is_absolute():
return file_path
return (Path(vault_path) / file_path).resolve()
def _read_text(path: Path) -> str:
"""Read a text file, raising :class:`ExportError` on missing/binary content."""
if not path.exists() or not path.is_file():
raise ExportError(f"File not found: {path}")
try:
raw = path.read_bytes()
except OSError as e:
raise ExportError(f"Cannot read file {path}: {e}") from e
if b"\x00" in raw[:4096]:
raise ExportError(f"File appears to be binary: {path}")
return raw.decode("utf-8", errors="replace")
def _strip_frontmatter(text: str) -> tuple[str, dict]:
"""Split frontmatter from a markdown document.
Returns ``(body, metadata)``. Falls back gracefully when no frontmatter.
"""
try:
post = frontmatter.loads(text)
return post.content, dict(post.metadata or {})
except Exception:
return text, {}
def _safe_name(name: str) -> str:
"""ASCII-safe, filename-safe download name."""
cleaned = "".join(
c for c in name if c.isascii() and (c.isalnum() or c in " _-.")
).strip()
return cleaned or "document"
def _collect_markdown_files(vault_path: Path) -> list[Path]:
"""List all markdown files in the vault, sorted by relative path."""
vault_path = Path(vault_path)
results = []
if not vault_path.is_dir():
return results
for p in sorted(vault_path.rglob("*")):
if p.is_file() and p.suffix.lower() in _MARKDOWN_EXTS:
results.append(p)
return results
# ---------------------------------------------------------------------------
# Markdown → HTML (shared by HTML and ePub exporters)
# ---------------------------------------------------------------------------
def _inline_images(md: str, file_dir: Path, vault_path: Path) -> str:
"""Replace image references with base64 data URIs.
Supports standard markdown ``![alt](src)`` and Obsidian embeds
``![[image.png]]`` / ``![[image.png|300]]``. Sources are resolved relative
to the note's directory, then the vault root.
"""
candidates = [file_dir, vault_path]
def _to_data_uri(src: str) -> str:
src = src.strip().strip("<>")
if src.startswith(("http://", "https://", "data:")):
return src # leave remote/data URLs untouched
src_path = None
for base in candidates:
candidate = (base / src).resolve()
if candidate.is_file():
src_path = candidate
break
if src_path is None:
return src # unresolved — leave as-is
try:
data = src_path.read_bytes()
mime = mimetypes.guess_type(str(src_path))[0] or "application/octet-stream"
b64 = base64.b64encode(data).decode("ascii")
return f"data:{mime};base64,{b64}"
except OSError:
return src
# Obsidian embeds: ![[path]] or ![[path|size]]
def _embed(match):
inner = match.group(1).strip()
target = inner.split("|", 1)[0].strip()
return f"![{target}]({_to_data_uri(target)})"
md = re.sub(r"!\[\[([^\]]+)\]\]", _embed, md)
# Standard markdown images
def _img(match):
alt = match.group(1)
src = match.group(2)
return f"![{alt}]({_to_data_uri(src)})"
md = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", _img, md)
return md
def _convert_wikilinks(md: str, vault_path: Path, current: Path) -> str:
"""Convert ``[[Note]]`` / ``[[Note|display]]`` to cross-file HTML links."""
md_files = {p.stem.lower(): p for p in _collect_markdown_files(vault_path)}
def _replace(match):
target = match.group(1).strip()
display = (match.group(2) or target).strip()
# Same-document anchor
if target.startswith("#"):
return f'<a href="#{_slugify(target[1:])}">{html_mod.escape(display)}</a>'
# Resolve to another file — link to its exported .html sibling
key = Path(target).stem.lower()
if key in md_files:
href = f"{_slugify(md_files[key].stem)}.html"
return f'<a href="{href}">{html_mod.escape(display)}</a>'
return f'<span class="wikilink-missing">{html_mod.escape(display)}</span>'
return re.sub(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]", _replace, md)
def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> str:
"""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)
def _build_nav(vault_path: Path, current: Path) -> str:
"""Build a navigation block linking to every other markdown note in the vault."""
items = []
for p in _collect_markdown_files(vault_path):
if p.resolve() == Path(current).resolve():
continue
stem = p.stem
items.append(f'<li><a href="{_slugify(stem)}.html">{html_mod.escape(stem)}</a></li>')
if not items:
return ""
return (
'<nav class="export-nav"><h2>Notes</h2><ul>'
+ "".join(items)
+ "</ul></nav>"
)
# ---------------------------------------------------------------------------
# Public exporters
# ---------------------------------------------------------------------------
def export_html(vault_path: Path, file_path: Path) -> bytes:
"""Convert a markdown note into a standalone HTML document.
Images are inlined as base64 data URIs, wikilinks become cross-file links,
and a navigation block lists every other note in the vault.
Returns:
UTF-8 encoded HTML as bytes.
"""
vault_path = Path(vault_path)
path = _resolve(vault_path, file_path)
raw = _read_text(path)
body, metadata = _strip_frontmatter(raw)
title = str(metadata.get("title") or path.stem)
rendered = _render_body(body, path.parent, vault_path, path)
nav = _build_nav(vault_path, path)
html_doc = f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="ObsiGate">
<title>{html_mod.escape(title)}</title>
<style>{_HTML_CSS}</style>
</head>
<body>
<header><h1>{html_mod.escape(title)}</h1></header>
{nav}
{rendered}
</body>
</html>"""
return html_doc.encode("utf-8")
def export_md_bundle(vault_path: Path, directory_path: Path) -> bytes:
"""Zip a directory (or single file) of markdown, preserving structure.
Returns:
ZIP archive as bytes.
"""
vault_path = Path(vault_path)
path = _resolve(vault_path, directory_path)
if not path.exists():
raise ExportError(f"Path not found: {path}")
buffer = io.BytesIO()
try:
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
if path.is_dir():
base = path
for p in sorted(path.rglob("*")):
if p.is_file():
arcname = p.relative_to(base).as_posix()
zf.write(p, arcname=arcname)
# Avoid emitting an empty (invalid) zip for an empty directory
if not any(p.is_file() for p in path.rglob("*")):
zf.writestr(".empty", "")
else:
zf.write(path, arcname=path.name)
except OSError as e:
raise ExportError(f"Cannot create bundle: {e}") from e
return buffer.getvalue()
def export_epub(vault_path: Path, file_path: Path) -> bytes:
"""Convert a markdown note into a minimal, valid EPUB 3 container.
Uses only ``zipfile`` + ``mistune`` (no ``ebooklib`` dependency). Images
are inlined as base64 so the ePub is fully self-contained.
Returns:
EPUB archive as bytes.
"""
vault_path = Path(vault_path)
path = _resolve(vault_path, file_path)
raw = _read_text(path)
body, metadata = _strip_frontmatter(raw)
title = str(metadata.get("title") or path.stem)
author = str(metadata.get("author") or "ObsiGate")
rendered = _render_body(body, path.parent, vault_path, path)
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())}"
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
<head>
<title>{html_mod.escape(title)}</title>
<style>{_HTML_CSS}</style>
</head>
<body>
<h1>{html_mod.escape(title)}</h1>
{rendered}
</body>
</html>"""
# EPUB requires a valid XHTML-ish body; strip our HTML5-only doctype is not
# needed since we emit XML directly above. Escape any bare ampersands in
# text nodes outside tags is delegated to mistune's own escaping.
content_opf = f"""<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="bookid">{html_mod.escape(uid)}</dc:identifier>
<dc:title>{html_mod.escape(title)}</dc:title>
<dc:creator>{html_mod.escape(author)}</dc:creator>
<dc:language>fr</dc:language>
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="chapter1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine toc="ncx">
<itemref idref="chapter1"/>
</spine>
</package>"""
toc_ncx = f"""<?xml version="1.0" encoding="utf-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="{html_mod.escape(uid)}"/>
</head>
<docTitle><text>{html_mod.escape(title)}</text></docTitle>
<navMap>
<navPoint id="navpoint-1" playOrder="1">
<navLabel><text>{html_mod.escape(title)}</text></navLabel>
<content src="chapter1.xhtml"/>
</navPoint>
</navMap>
</ncx>"""
container_xml = """<?xml version="1.0" encoding="utf-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>"""
buffer = io.BytesIO()
try:
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
# `mimetype` must be the first entry, stored (uncompressed).
zf.writestr(
zipfile.ZipInfo("mimetype"),
"application/epub+zip",
compress_type=zipfile.ZIP_STORED,
)
zf.writestr("META-INF/container.xml", container_xml)
zf.writestr("OEBPS/content.opf", content_opf)
zf.writestr("OEBPS/toc.ncx", toc_ncx)
zf.writestr("OEBPS/chapter1.xhtml", xhtml)
except OSError as e:
raise ExportError(f"Cannot create ePub: {e}") from e
return buffer.getvalue()
+208 -9
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import logging
import os
import re
@@ -28,6 +29,9 @@ _async_index_lock: asyncio.Lock | None = None # initialized lazily
# (e.g. the inverted index in search.py) can detect staleness.
_index_generation: int = 0
# Timestamp of last full index rebuild (ISO format, empty if never built)
_last_full_index_ts: str = ""
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
_on_index_change: Callable[..., None] | None = None
@@ -197,6 +201,178 @@ def _extract_title(post: frontmatter.Post, filepath: Path) -> str:
return str(title)
_EXCALIDRAW_TEXT_FIELDS = ("text", "originalText", "label", "title")
def extract_excalidraw_text_from_elements(elements: list[dict[str, Any]]) -> str:
"""Concatenate all user-visible text from an Excalidraw elements list.
Iterates diagram elements and collects the text-bearing fields
(``text`` for text elements, ``label``/``title`` for bound shapes,
``originalText`` as the stable source of a text element). Non-text
elements and geometry-only shapes contribute nothing. This gives the
TF-IDF search engine human-readable content instead of raw JSON.
"""
chunks: list[str] = []
for el in elements:
if not isinstance(el, dict):
continue
collected = set()
for field in _EXCALIDRAW_TEXT_FIELDS:
val = el.get(field)
if isinstance(val, str) and val.strip():
collected.add(val.strip())
if collected:
chunks.append(" ".join(sorted(collected)))
return "\n".join(chunks)
_EXCALIDRAW_B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
def _decompress_excalidraw(compressed: str) -> dict[str, Any] | None:
"""Decompress the Obsidian ``compressed-json`` block of a .excalidraw.md file.
The Obsidian Excalidraw plugin stores scene data as an ``lz-string``
``compressToBase64`` payload (LZ-based compression, alphabet = standard
base64). We port the reference ``lz-string`` ``_decompress`` algorithm
(bitsPerChar=6/base64 key, resetValue=32) in pure Python so indexation
can recover the diagram text without the JS client. The port is validated
against multiple JS-truth fixtures in ``test_excalidraw.py`` including
accented text and edge cases. Returns the parsed scene dict, or None if
the payload is not valid base64-lz or does not parse as JSON.
Only ``decompress`` is needed for indexation (read side); compression
stays on the JS client.
"""
if not compressed:
return None
length = len(compressed)
def _get_char_value(index: int) -> int:
# Mirror JS: input.charAt(index), 0-indexed.
ch = compressed[index] if 0 <= index < length else "="
pos = _EXCALIDRAW_B64_ALPHABET.find(ch)
return 0 if pos == -1 else pos
# ── Build a bit reader over the base64 characters ────────────────────
# JS decompressFromBase64 calls _decompress(length, 32, getNextValue).
# state = current 6-bit value + position mask + next char index.
value = _get_char_value(0)
position = 32 # resetValue
index = 1
def _read_bit() -> int:
nonlocal value, position, index
resb = value & position
position >>= 1
if position == 0:
position = 32
value = _get_char_value(index)
index += 1
return 1 if resb > 0 else 0
def _read_bits(n: int) -> int:
out = 0
for i in range(n):
out |= _read_bit() << i
return out
# ── Decompressor state ────────────────────────────────────────────────
dictionary: list[Any] = [0, 1, 2] # values 0,1,2 as in JS
enlarge_in = 4
dict_size = 4
num_bits = 3
first = _read_bits(2)
if first == 0:
c = chr(_read_bits(8))
elif first == 1:
c = chr(_read_bits(16))
elif first == 2:
return None # empty stream
else:
return None
dictionary.append(c)
w: str = c
result = [c]
while True:
if index > length:
return None
c = _read_bits(num_bits)
if c == 0:
dictionary.append(chr(_read_bits(8)))
dict_size += 1
c = dict_size - 1
enlarge_in -= 1
elif c == 1:
dictionary.append(chr(_read_bits(16)))
dict_size += 1
c = dict_size - 1
enlarge_in -= 1
elif c == 2:
break # end of stream
if enlarge_in == 0:
enlarge_in = 1 << num_bits
num_bits += 1
if c < len(dictionary) and dictionary[c]:
entry = dictionary[c]
elif c == dict_size:
entry = w + w[0]
else:
return None
result.append(entry)
dictionary.append(w + entry[0])
dict_size += 1
enlarge_in -= 1
w = entry
if enlarge_in == 0:
enlarge_in = 1 << num_bits
num_bits += 1
text = "".join(result)
try:
data = json.loads(text)
except Exception:
return None
if not isinstance(data, dict):
return None
return data
def extract_excalidraw_indexable(raw: str) -> str:
"""Return indexable text content for a raw .excalidraw / .excalidraw.md file.
Supports both the pure JSON format (``type: "excalidraw"``) and the
Obsidian ``compressed-json`` block. Falls back to ``""`` when neither
format can be parsed so the file is still indexed (title only).
"""
# .excalidraw.md — Obsidian plugin embeds a compressed-json block
if "excalidraw-plugin:" in raw:
m = re.search(r"```compressed-json\n(.*?)\n```", raw, re.DOTALL)
if m:
parsed = _decompress_excalidraw(m.group(1).strip())
if parsed:
return extract_excalidraw_text_from_elements(parsed.get("elements") or [])
return ""
# Pure .excalidraw JSON
try:
data = json.loads(raw)
except Exception:
return ""
if not isinstance(data, dict) or data.get("type") != "excalidraw":
return ""
return extract_excalidraw_text_from_elements(data.get("elements") or [])
def parse_markdown_file(raw: str) -> frontmatter.Post:
"""Parse markdown frontmatter, falling back to plain content if YAML is invalid.
@@ -292,6 +468,12 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
tags: list[str] = []
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
raw = fpath.read_text(encoding="utf-8", errors="replace")
raw = extract_excalidraw_indexable(raw)
tags: list[str] = []
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
tags: list[str] = []
@@ -411,6 +593,10 @@ async def build_index(progress_callback=None) -> None:
if tasks:
await asyncio.gather(*tasks)
# Record timestamp of full index rebuild
global _last_full_index_ts
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
# Build attachment index
from backend.attachment_indexer import build_attachment_index
await build_attachment_index(vault_config)
@@ -546,19 +732,32 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
raw = fpath.read_text(encoding="utf-8", errors="replace")
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
tags: list[str] = []
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
if ext == ".pdf":
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
raw = extract_pdf_text(fpath, max_chars=SEARCH_CONTENT_LIMIT)
pdf_meta = extract_pdf_metadata(fpath)
title = pdf_meta.get("title") or title
content_preview = raw[:200].strip()
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
raw = fpath.read_text(encoding="utf-8", errors="replace")
raw = extract_excalidraw_indexable(raw)
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
content_preview = raw[:200].strip()
if ext == ".md":
post = parse_markdown_file(raw)
tags = _extract_tags(post)
inline_tags = _extract_inline_tags(post.content)
tags = list(set(tags) | set(inline_tags))
title = _extract_title(post, fpath)
content_preview = post.content[:200].strip()
if ext == ".md":
post = parse_markdown_file(raw)
tags = _extract_tags(post)
inline_tags = _extract_inline_tags(post.content)
tags = list(set(tags) | set(inline_tags))
title = _extract_title(post, fpath)
content_preview = post.content[:200].strip()
return {
"path": str(relative).replace("\\", "/"),
+702 -87
View File
File diff suppressed because it is too large Load Diff
+452
View File
@@ -0,0 +1,452 @@
"""OpenAPI 3.1 documentation helpers for ObsiGate (#72).
This module keeps the API-documentation concerns out of ``backend/main.py``:
* :data:`TAGS_METADATA` — human descriptions and ordering for every tag.
* :func:`enrich_openapi_schema` — post-processes the schema generated by
FastAPI to assign tags, add servers/security/examples and document common
error responses.
* :func:`render_api_landing` — a self-contained ``/api`` landing page that
reads ``/openapi.json`` at runtime and groups endpoints by tag, with direct
links to the Swagger UI (``/docs``) and ReDoc (``/redoc``).
"""
from __future__ import annotations
import re
from typing import Any
# ---------------------------------------------------------------------------
# Tag metadata
# ---------------------------------------------------------------------------
TAGS_METADATA: list[dict[str, str]] = [
{"name": "System", "description": "Health checks, runtime configuration, diagnostics, dashboard stats and the SSE event stream."},
{"name": "Auth", "description": "Authentication, sessions, user profile, MFA (TOTP/WebAuthn) and API keys."},
{"name": "Files", "description": "Browse, read, create, rename, move, delete and download vault files."},
{"name": "PDF", "description": "PDF metadata and byte-range streaming for inline viewing."},
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
{"name": "AI", "description": "AI-powered editor actions, provider status and model discovery."},
{"name": "BooksLM", "description": "Directory-scoped AI chat (NotebookLM-style) over a vault folder."},
{"name": "Sharing", "description": "Create and manage public read-only share links for documents."},
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
{"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": "Frontend", "description": "Static assets and SPA fallback routes."},
]
API_DESCRIPTION = """
**ObsiGate** exposes a REST API covering the entire application: vaults, files,
full-text search, backups, exports, AI actions, sharing and administration.
### Authentication
When authentication is enabled, send a bearer token obtained from
`POST /api/auth/login`:
```
Authorization: Bearer <access_token>
```
The same token is also accepted as an HTTP-only cookie, so browser clients can
simply use `credentials: "include"`.
### Interactive documentation
* **Swagger UI** — [/docs](/docs): try requests directly from the browser.
* **ReDoc** — [/redoc](/redoc): clean, reading-oriented reference.
* **OpenAPI JSON** — [/openapi.json](/openapi.json): machine-readable schema.
### Errors
Errors use the standard FastAPI envelope `{"detail": "..."}` with the
appropriate HTTP status (`400`, `401`, `403`, `404`, `409`, `422`, `500`).
"""
# ---------------------------------------------------------------------------
# Automatic tag assignment
# ---------------------------------------------------------------------------
# Rules are evaluated in order; the first match wins. Keep the most specific
# prefixes first (e.g. BooksLM before AI, config/ai-* before config).
_TAG_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^/api/auth"), "Auth"),
(re.compile(r"^/api/admin"), "Admin"),
(re.compile(r"^/api/plugins"), "Plugins"),
(re.compile(r"^/api/push"), "Push"),
(re.compile(r"^/api/ai/bookslm"), "BooksLM"),
(re.compile(r"^/api/ai"), "AI"),
(re.compile(r"^/api/config/ai-"), "AI"),
(re.compile(r"^/api/share"), "Sharing"),
(re.compile(r"^/api/shares"), "Sharing"),
(re.compile(r"^/s/"), "Sharing"),
(re.compile(r"^/api/webhooks"), "Webhooks"),
(re.compile(r"^/api/conflicts"), "Conflicts"),
(re.compile(r"^/api/backups"), "Backups"),
(re.compile(r"^/api/file/[^/]+/(backups|diff|restore)"), "Backups"),
(re.compile(r"^/api/export"), "Export"),
(re.compile(r"^/api/file/[^/]+/pdf"), "PDF"),
(re.compile(r"^/api/search"), "Search"),
(re.compile(r"^/api/tags"), "Search"),
(re.compile(r"^/api/tree-search"), "Search"),
(re.compile(r"^/api/suggest"), "Search"),
(re.compile(r"^/api/graph"), "Search"),
(re.compile(r"^/api/recent"), "Bookmarks"),
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
(re.compile(r"^/api/vaults"), "Vaults"),
(re.compile(r"^/api/vault/"), "Vaults"),
(re.compile(r"^/api/index/reload"), "Vaults"),
(re.compile(r"^/api/attachments"), "Vaults"),
(re.compile(r"^/api/browse"), "Files"),
(re.compile(r"^/api/file"), "Files"),
(re.compile(r"^/api/directory"), "Files"),
(re.compile(r"^/api/move"), "Files"),
(re.compile(r"^/api/image"), "Files"),
(re.compile(r"^/api/health"), "System"),
(re.compile(r"^/api/config"), "System"),
(re.compile(r"^/api/diagnostics"), "System"),
(re.compile(r"^/api/dashboard"), "System"),
(re.compile(r"^/api/events"), "System"),
]
def tag_for_path(path: str) -> str:
"""Return the documentation tag for an API path (``Frontend`` as fallback)."""
for pattern, tag in _TAG_RULES:
if pattern.match(path):
return tag
return "Frontend"
# Normalise tags declared by individual routers (``tags=["auth"]``) to the
# canonical, documented tag names.
_TAG_ALIASES: dict[str, str] = {
"auth": "Auth",
"admin": "Admin",
"plugins": "Plugins",
"push": "Push",
"files": "Files",
"vaults": "Vaults",
"search": "Search",
"backups": "Backups",
"export": "Export",
"sharing": "Sharing",
"webhooks": "Webhooks",
"conflicts": "Conflicts",
"system": "System",
"frontend": "Frontend",
"ai": "AI",
"bookslm": "BooksLM",
"pdf": "PDF",
"bookmarks": "Bookmarks",
}
_CANONICAL_TAGS = {tag["name"] for tag in TAGS_METADATA}
def canonical_tag(name: str) -> str:
"""Map a router-declared tag to its documented name (unchanged if unknown)."""
mapped = _TAG_ALIASES.get(name.lower(), name)
return mapped if mapped in _CANONICAL_TAGS else name
# ---------------------------------------------------------------------------
# Request/response examples injected into the schema
# ---------------------------------------------------------------------------
_ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
("post", "/api/file/{vault_name}"): {
"request": {"path": "notes/Nouvelle note.md", "content": "# Titre\n\nContenu"},
"response": {"success": True, "path": "notes/Nouvelle note.md"},
},
("put", "/api/file/{vault_name}/save"): {
"request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."},
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
},
("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},
},
("post", "/api/ai/improve"): {
"request": {"text": "ce texte est bof", "provider": "deepseek"},
"response": {"result": "Ce texte mérite d'être amélioré.", "provider": "deepseek"},
},
("post", "/api/ai/bookslm/chat"): {
"request": {"vault": "TestVault", "directory": "projets", "message": "Résume ce dossier", "conversation_history": []},
"response": {"token": "Voici un résumé…", "provider": "deepseek", "model": "deepseek-chat"},
},
("post", "/api/share/{vault_name}"): {
"request": {"path": "notes/Accueil.md", "expires_in_hours": 168},
"response": {"id": "abc…", "token": "abc…", "vault": "TestVault", "path": "notes/Accueil.md", "url": "/s/abc…"},
},
("post", "/api/webhooks"): {
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t"},
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True},
},
}
# Common error responses documented on every operation.
_COMMON_ERRORS: dict[str, dict[str, Any]] = {
"401": {
"description": "Authentication required or token expired",
"content": {"application/json": {"example": {"detail": "Not authenticated"}}},
},
"403": {
"description": "Access to the vault is denied",
"content": {"application/json": {"example": {"detail": "Accès refusé à la vault 'Notes'"}}},
},
"404": {
"description": "Resource not found",
"content": {"application/json": {"example": {"detail": "File not found: notes/x.md"}}},
},
"422": {
"description": "Validation error",
"content": {"application/json": {"example": {"detail": [{"loc": ["query", "path"], "msg": "field required", "type": "value_error.missing"}]}}},
},
"500": {
"description": "Internal server error",
"content": {"application/json": {"example": {"detail": "Internal server error"}}},
},
}
# Paths that return a binary/streaming body — never add a JSON error example
# to their 200 response, and skip the 500 JSON example for streams.
_BINARY_MEDIA = ("application/pdf", "application/octet-stream", "image/", "text/event-stream")
def _is_binary_operation(operation: dict[str, Any]) -> bool:
content = operation.get("responses", {}).get("200", {}).get("content", {})
return any(media.startswith(_BINARY_MEDIA) for media in content)
def enrich_openapi_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""Enrich a FastAPI-generated OpenAPI schema in place and return it.
The transformation is idempotent: calling it twice yields the same result.
"""
schema.setdefault("openapi", "3.1.0")
info = schema.setdefault("info", {})
info.setdefault("description", API_DESCRIPTION.strip())
info.setdefault("contact", {"name": "ObsiGate", "url": "https://git.dracodev.net/Projets/ObsiGate"})
info.setdefault("license", {"name": "MIT"})
schema.setdefault("servers", [{"url": "/", "description": "Current ObsiGate instance"}])
schema["externalDocs"] = {
"description": "ObsiGate source repository",
"url": "https://git.dracodev.net/Projets/ObsiGate",
}
schema["tags"] = TAGS_METADATA
components = schema.setdefault("components", {})
security_schemes = components.setdefault("securitySchemes", {})
security_schemes.setdefault("bearerAuth", {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "JWT access token issued by POST /api/auth/login.",
})
security_schemes.setdefault("cookieAuth", {
"type": "apiKey",
"in": "cookie",
"name": "obsigate_token",
"description": "HTTP-only session cookie (browser clients).",
})
components.setdefault("schemas", {}).setdefault("ErrorResponse", {
"type": "object",
"properties": {"detail": {"title": "Detail", "description": "Error message"}},
})
for path, operations in schema.get("paths", {}).items():
default_tag = tag_for_path(path)
for method, operation in operations.items():
if not isinstance(operation, dict):
continue
if operation.get("tags"):
operation["tags"] = [canonical_tag(t) for t in operation["tags"]]
else:
operation["tags"] = [default_tag]
# Document common error responses without overriding explicit ones.
responses = operation.setdefault("responses", {})
binary = _is_binary_operation(operation)
for code, spec in _COMMON_ERRORS.items():
if code == "500" and binary:
continue
responses.setdefault(code, spec)
# Inject request/response examples for key endpoints.
example = _ENDPOINT_EXAMPLES.get((method, path))
if example:
if "request" in example:
body = operation.setdefault("requestBody", {})
content = body.setdefault("content", {}).setdefault("application/json", {})
content.setdefault("example", example["request"])
if "response" in example:
ok = responses.setdefault("200", {})
content = ok.setdefault("content", {}).setdefault("application/json", {})
content.setdefault("example", example["response"])
return schema
# ---------------------------------------------------------------------------
# /api landing page
# ---------------------------------------------------------------------------
_METHOD_COLORS = {
"get": "#2563eb",
"post": "#16a34a",
"put": "#d97706",
"patch": "#7c3aed",
"delete": "#dc2626",
"head": "#64748b",
"options": "#64748b",
}
def render_api_landing(version: str = "") -> str:
"""Return the self-contained HTML for the ``/api`` documentation page."""
return f"""<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ObsiGate API{(' — v' + version) if version else ''}</title>
<style>
:root {{
--bg: #0f172a; --surface: #1e293b; --border: #334155;
--text: #e2e8f0; --muted: #94a3b8; --accent: #60a5fa; --accent-2: #34d399;
}}
html[data-theme="light"] {{
--bg: #f8fafc; --surface: #ffffff; --border: #e2e8f0;
--text: #0f172a; --muted: #64748b; --accent: #2563eb; --accent-2: #059669;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
}}
.wrap {{ max-width: 1080px; margin: 0 auto; padding: 40px 24px 80px; }}
header h1 {{ margin: 0 0 4px; font-size: 1.9rem; }}
header p {{ margin: 0; color: var(--muted); }}
.actions {{ display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0 32px; }}
.actions a {{
text-decoration: none; color: var(--text); background: var(--surface);
border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px;
font-weight: 600; font-size: .9rem; transition: border-color .15s, transform .15s;
}}
.actions a:hover {{ border-color: var(--accent); transform: translateY(-1px); }}
.actions a.primary {{ background: var(--accent); border-color: var(--accent); color: #fff; }}
.meta {{ color: var(--muted); font-size: .85rem; margin-bottom: 24px; }}
.tag {{ margin: 28px 0; }}
.tag > h2 {{
font-size: 1.1rem; margin: 0 0 2px; display: flex; align-items: center; gap: 8px;
}}
.tag > p {{ margin: 0 0 12px; color: var(--muted); font-size: .85rem; }}
.ops {{ display: flex; flex-direction: column; gap: 6px; }}
.op {{
display: flex; align-items: center; gap: 12px; padding: 8px 12px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
text-decoration: none; color: var(--text); font-size: .88rem;
}}
.op:hover {{ border-color: var(--accent); }}
.method {{
flex: 0 0 62px; text-align: center; font-weight: 700; font-size: .68rem;
text-transform: uppercase; letter-spacing: .04em; color: #fff; border-radius: 5px; padding: 3px 0;
}}
.op code {{ color: var(--accent-2); font-size: .85rem; }}
.op .summary {{ color: var(--muted); margin-left: auto; text-align: right; }}
.loading {{ color: var(--muted); }}
footer {{ margin-top: 48px; color: var(--muted); font-size: .8rem; }}
@media (max-width: 640px) {{ .op .summary {{ display: none; }} }}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>ObsiGate API</h1>
<p>Interactive reference for the ObsiGate REST API (OpenAPI 3.1).</p>
</header>
<div class="actions">
<a class="primary" href="/docs">Swagger UI — try it</a>
<a href="/redoc">ReDoc — reference</a>
<a href="/openapi.json">OpenAPI JSON</a>
<a href="/">← Back to ObsiGate</a>
</div>
<div class="meta" id="meta">Loading specification…</div>
<div id="tags"><p class="loading">Loading endpoints…</p></div>
<footer>Generated from the live OpenAPI schema. Authentication: <code>Authorization: Bearer &lt;token&gt;</code>.</footer>
</div>
<script>
(function () {{
var COLORS = {_js_colors()};
function el(tag, cls, text) {{
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}}
fetch('/openapi.json', {{ credentials: 'include' }})
.then(function (r) {{ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }})
.then(function (spec) {{
var info = spec.info || {{}};
var tagMeta = {{}};
(spec.tags || []).forEach(function (t) {{ tagMeta[t.name] = t.description || ''; }});
var groups = {{}};
var paths = spec.paths || {{}};
var opCount = 0;
Object.keys(paths).forEach(function (path) {{
var ops = paths[path];
Object.keys(ops).forEach(function (method) {{
if (['get','post','put','patch','delete','head','options'].indexOf(method) === -1) return;
var op = ops[method];
if (op.deprecated && false) return;
opCount++;
var tag = (op.tags && op.tags[0]) || 'Other';
(groups[tag] = groups[tag] || []).push({{ path: path, method: method, summary: op.summary || '' }});
}});
}});
document.getElementById('meta').textContent =
(info.title || 'ObsiGate') + (info.version ? ' v' + info.version : '') +
' — OpenAPI ' + (spec.openapi || '3.1') + ' — ' + opCount + ' operations';
var host = document.getElementById('tags');
host.innerHTML = '';
Object.keys(groups).forEach(function (tag) {{
var section = el('section', 'tag');
section.appendChild(el('h2', null, tag));
if (tagMeta[tag]) section.appendChild(el('p', null, tagMeta[tag]));
var ops = el('div', 'ops');
groups[tag].forEach(function (o) {{
var a = el('a', 'op');
a.href = '/docs#/' + encodeURIComponent(tag) + '/' + encodeURIComponent(o.path);
var m = el('span', 'method', o.method);
m.style.background = COLORS[o.method] || '#64748b';
a.appendChild(m);
a.appendChild(el('code', null, o.path));
if (o.summary) a.appendChild(el('span', 'summary', o.summary));
ops.appendChild(a);
}});
section.appendChild(ops);
host.appendChild(section);
}});
}})
.catch(function (e) {{
document.getElementById('meta').textContent = 'Failed to load OpenAPI schema: ' + e.message;
}});
}})();
</script>
</body>
</html>"""
def _js_colors() -> str:
"""Serialise the method→colour map for the landing page script."""
import json
return json.dumps(_METHOD_COLORS)
+49 -4
View File
@@ -2,11 +2,21 @@
from __future__ import annotations
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeout
from pathlib import Path
logger = logging.getLogger(__name__)
# Configurable limits (see ROADMAP #74 G3):
# OBSIGATE_PDF_MAX_SIZE_MB — PDFs larger than this are not text-extracted (default 50)
# OBSIGATE_PDF_EXTRACT_TIMEOUT — seconds before extraction is abandoned (default 30)
PDF_MAX_SIZE_MB: int = int(os.environ.get("OBSIGATE_PDF_MAX_SIZE_MB", "50"))
PDF_EXTRACT_TIMEOUT: float = float(os.environ.get("OBSIGATE_PDF_EXTRACT_TIMEOUT", "30"))
PDF_READER: str = "pypdf"
PdfReader = None # type: ignore
try:
import fitz # pymupdf
PDF_READER = "pymupdf"
@@ -16,19 +26,54 @@ except ImportError:
from pypdf import PdfReader # type: ignore
logger.info("PDF reader: pypdf (pure Python)")
except ImportError:
PdfReader = None # type: ignore
logger.warning("No PDF reader available — install pypdf or pymupdf")
def pdf_exceeds_size_limit(file_path: Path) -> bool:
"""True if the PDF is larger than OBSIGATE_PDF_MAX_SIZE_MB (skip text extraction)."""
try:
return file_path.stat().st_size > PDF_MAX_SIZE_MB * 1024 * 1024
except OSError:
return False
def _run_with_timeout(fn, *args, timeout: float):
"""Run a sync function in a worker thread with a hard timeout.
A timed-out extraction leaves its worker thread running (daemon-style pool
is abandoned), but the request itself is freed — acceptable trade-off for
pathological PDFs on a self-hosted single-user server.
"""
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="pdf-extract")
try:
future = executor.submit(fn, *args)
return future.result(timeout=timeout)
finally:
executor.shutdown(wait=False)
def extract_pdf_text(file_path: Path, max_chars: int = 100000) -> str:
"""Extract text from a PDF file. Returns empty string on failure."""
"""Extract text from a PDF file. Returns empty string on failure.
Oversized PDFs (> OBSIGATE_PDF_MAX_SIZE_MB) and extractions exceeding
OBSIGATE_PDF_EXTRACT_TIMEOUT seconds return "" instead of blocking.
"""
if PdfReader is None and PDF_READER == "pypdf":
return ""
if pdf_exceeds_size_limit(file_path):
logger.info("PDF too large to index (> %d MB), skipping text extraction: %s",
PDF_MAX_SIZE_MB, file_path)
return ""
try:
if PDF_READER == "pymupdf":
return _extract_pymupdf(file_path, max_chars)
return _run_with_timeout(_extract_pymupdf, file_path, max_chars,
timeout=PDF_EXTRACT_TIMEOUT)
else:
return _extract_pypdf(file_path, max_chars)
return _run_with_timeout(_extract_pypdf, file_path, max_chars,
timeout=PDF_EXTRACT_TIMEOUT)
except FuturesTimeout:
logger.warning("PDF text extraction timed out (%ss): %s", PDF_EXTRACT_TIMEOUT, file_path)
return ""
except Exception as e:
logger.warning("Failed to extract PDF text from %s: %s", file_path, e)
return ""
+624
View File
@@ -0,0 +1,624 @@
"""Plugin system for ObsiGate — backend API and manifest validation.
Plugins extend ObsiGate with custom renderers, search filters, and editor
actions. They run sandboxed in a Web Worker on the frontend; the backend
handles manifest validation, storage, and lifecycle (install / uninstall /
enable / disable), all scoped to a single vault.
"""
import json
import logging
import re
import time as _time
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from backend.auth.middleware import check_vault_access, require_admin, require_auth
from backend.indexer import get_vault_data
logger = logging.getLogger("obsigate.plugins")
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
# ── Constants ──────────────────────────────────────────────────────────────
PLUGINS_DIR_NAME = ".obsigate-plugins"
MANIFEST_FILENAME = "plugin.json"
MAX_PLUGIN_SIZE = 500_000 # 500 KB per plugin file
MAX_PLUGINS_PER_VAULT = 50
MAX_PLUGIN_FILES = 100 # max files in an installed plugin zip
ALLOWED_HOOKS = frozenset([
"onFileRender",
"onSearchFilter",
"onEditorAction",
"onSidebarItem",
"onFileCreate",
"onFileDelete",
"onVaultMount",
])
ALLOWED_PERMISSIONS = frozenset([
"read_files",
"write_files",
"read_vault_metadata",
"network_request",
"ui_notify",
"access_clipboard",
])
# ── Manifest ───────────────────────────────────────────────────────────────
@dataclass
class PluginManifest:
"""Validated plugin manifest from plugin.json."""
name: str
version: str
description: str
author: str
main: str
hooks: dict[str, str] = field(default_factory=dict)
permissions: list[str] = field(default_factory=list)
min_obsigate_version: str = "2.1.0"
homepage: str | None = None
repository: str | None = None
license: str = "MIT"
@classmethod
def from_dict(cls, data: dict) -> "PluginManifest":
"""Validate and build a manifest from a raw dict (JSON object)."""
required = ["name", "version", "description", "author", "main"]
for field_name in required:
if field_name not in data or not data[field_name]:
raise ValueError(f"Missing required field: {field_name}")
name = data["name"]
if not re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", name):
raise ValueError(
"Plugin name must be lowercase alphanumeric with hyphens (e.g., 'my-plugin')"
)
version = data["version"]
if not re.fullmatch(r"\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?", version):
raise ValueError("Version must be semantic version (e.g., '1.0.0')")
hooks = data.get("hooks", {})
if not isinstance(hooks, dict):
raise TypeError("'hooks' must be an object mapping hook name to handler name")
for hook_name, handler in hooks.items():
if hook_name not in ALLOWED_HOOKS:
raise ValueError(
f"Unknown hook: {hook_name}. Allowed: {sorted(ALLOWED_HOOKS)}"
)
if not isinstance(handler, str) or not handler:
raise ValueError(f"Hook handler for '{hook_name}' must be a non-empty string")
permissions = data.get("permissions", [])
if not isinstance(permissions, list):
raise TypeError("'permissions' must be a list of permission names")
for perm in permissions:
if perm not in ALLOWED_PERMISSIONS:
raise ValueError(
f"Unknown permission: {perm}. Allowed: {sorted(ALLOWED_PERMISSIONS)}"
)
return cls(
name=name,
version=version,
description=data["description"],
author=data["author"],
main=data["main"],
hooks=hooks,
permissions=permissions,
min_obsigate_version=data.get("min_obsigate_version", "2.1.0"),
homepage=data.get("homepage"),
repository=data.get("repository"),
license=data.get("license", "MIT"),
)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"version": self.version,
"description": self.description,
"author": self.author,
"main": self.main,
"hooks": self.hooks,
"permissions": self.permissions,
"min_obsigate_version": self.min_obsigate_version,
"homepage": self.homepage,
"repository": self.repository,
"license": self.license,
}
# ── Storage path helpers ───────────────────────────────────────────────────
def _vault_plugins_dir(vault_name: str) -> Path:
"""Return the plugins directory for a vault (creating it if absent)."""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
base = Path(vault_data["path"])
pd = base / PLUGINS_DIR_NAME
pd.mkdir(parents=True, exist_ok=True)
return pd
# ── Manager ────────────────────────────────────────────────────────────────
class PluginManager:
"""Manages plugin lifecycle within a single plugins directory."""
def __init__(self, plugins_dir):
self.plugins_dir = Path(plugins_dir)
self.plugins_dir.mkdir(parents=True, exist_ok=True)
def _plugin_dir(self, name: str) -> Path:
return self.plugins_dir / name
# ---- install / uninstall ----
def install(self, manifest: dict, code: str) -> dict[str, Any]:
"""Install a plugin from a manifest dict + entry point source string."""
m = PluginManifest.from_dict(manifest)
plugin_dir = self._plugin_dir(m.name)
if plugin_dir.exists():
raise ValueError(f"Plugin '{m.name}' is already installed")
installed = list(self.plugins_dir.iterdir()) if self.plugins_dir.exists() else []
if installed and len(installed) >= MAX_PLUGINS_PER_VAULT:
raise ValueError(f"Maximum of {MAX_PLUGINS_PER_VAULT} plugins per vault reached")
if len(code.encode("utf-8")) > MAX_PLUGIN_SIZE:
raise ValueError(f"Plugin file exceeds {MAX_PLUGIN_SIZE} bytes")
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / MANIFEST_FILENAME).write_text(
json.dumps(m.to_dict(), indent=2), encoding="utf-8"
)
(plugin_dir / m.main).write_text(code, encoding="utf-8")
logger.info("Installed plugin '%s' v%s", m.name, m.version)
return {
"name": m.name,
"version": m.version,
"description": m.description,
"author": m.author,
"main": m.main,
"enabled": True,
}
def uninstall(self, name: str) -> None:
plugin_dir = self._plugin_dir(name)
if not plugin_dir.exists():
raise ValueError(f"Plugin '{name}' not found")
import shutil
shutil.rmtree(plugin_dir)
logger.info("Uninstalled plugin '%s'", name)
# ---- enable / disable ----
def enable(self, name: str) -> None:
plugin_dir = self._plugin_dir(name)
if not plugin_dir.exists():
raise ValueError(f"Plugin '{name}' not found")
marker = plugin_dir / ".disabled"
if marker.exists():
marker.unlink()
def disable(self, name: str) -> None:
plugin_dir = self._plugin_dir(name)
if not plugin_dir.exists():
raise ValueError(f"Plugin '{name}' not found")
(plugin_dir / ".disabled").write_text("", encoding="utf-8")
def is_disabled(self, name: str) -> bool:
return (self._plugin_dir(name) / ".disabled").exists()
# ---- listing / metadata ----
def list_plugins(self) -> list[dict[str, Any]]:
if not self.plugins_dir.exists():
return []
out = []
for d in sorted(self.plugins_dir.iterdir()):
if not d.is_dir():
continue
manifest_file = d / MANIFEST_FILENAME
if not manifest_file.exists():
continue
try:
m = PluginManifest.from_dict(json.loads(manifest_file.read_text(encoding="utf-8")))
except (json.JSONDecodeError, ValueError) as exc:
logger.warning("Invalid plugin dir '%s': %s", d.name, exc)
continue
out.append({
"name": m.name,
"version": m.version,
"description": m.description,
"author": m.author,
"main": m.main,
"enabled": not self.is_disabled(m.name),
"hooks": m.hooks,
"permissions": m.permissions,
})
return out
def get_plugin(self, name: str) -> dict[str, Any] | None:
plugin_dir = self._plugin_dir(name)
manifest_file = plugin_dir / MANIFEST_FILENAME
if not manifest_file.exists():
return None
try:
m = PluginManifest.from_dict(json.loads(manifest_file.read_text(encoding="utf-8")))
except (json.JSONDecodeError, ValueError):
return None
return {
"manifest": m.to_dict(),
"name": m.name,
"version": m.version,
"enabled": not self.is_disabled(m.name),
}
def get_plugin_code(self, name: str, file_name: str) -> str:
plugin_dir = self._plugin_dir(name)
manifest_file = plugin_dir / MANIFEST_FILENAME
if not manifest_file.exists():
raise ValueError(f"Plugin '{name}' not found")
target = plugin_dir / file_name
# Prevent path traversal
try:
target.resolve().relative_to(plugin_dir.resolve())
except (ValueError, OSError):
raise ValueError(f"Invalid file path: {file_name}")
if not target.is_file():
raise ValueError(f"File '{file_name}' not found for plugin '{name}'")
return target.read_text(encoding="utf-8")
def get_hooks(self, name: str) -> dict[str, str]:
plugin = self.get_plugin(name)
if not plugin:
raise ValueError(f"Plugin '{name}' not found")
return plugin["manifest"].get("hooks", {})
class PluginRegistry:
"""Aggregates enabled plugins for a plugins directory."""
def get_enabled_plugins(self, plugins_dir) -> list[dict[str, Any]]:
manager = PluginManager(plugins_dir)
return [p for p in manager.list_plugins() if p["enabled"]]
def get_plugins_by_hook(self, plugins_dir, hook: str) -> list[dict[str, Any]]:
manager = PluginManager(plugins_dir)
return [
p for p in manager.list_plugins()
if p["enabled"] and hook in p.get("hooks", {})
]
def scan_vault(self, vault_name: str, vault_path) -> list[dict[str, Any]]:
"""Scan a vault directory for installed plugins (startup discovery).
Returns the list of installed-but-valid plugins so the app can log
how many are available per vault. Invalid/zombie plugin dirs are
skipped gracefully.
"""
pd = Path(vault_path) / PLUGINS_DIR_NAME
if not pd.is_dir():
return []
return PluginManager(pd).list_plugins()
# Module-level registry singleton (startup scan + runtime lookups)
_registry_singleton: PluginRegistry | None = None
def get_plugin_registry() -> PluginRegistry:
"""Return the shared PluginRegistry instance."""
global _registry_singleton
if _registry_singleton is None:
_registry_singleton = PluginRegistry()
return _registry_singleton
# ── Validation of uploads (ZIP / directory) ────────────────────────────────
def _plugin_manifest_from_member(zf: zipfile.ZipFile) -> dict[str, Any]:
try:
raw = zf.read(MANIFEST_FILENAME).decode("utf-8")
except KeyError:
raise ValueError("Plugin zip is missing plugin.json")
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise ValueError("Invalid JSON in plugin.json")
m = PluginManifest.from_dict(data)
return m.to_dict()
def _validate_plugin_zip(zip_path) -> dict[str, Any]:
"""Validate an uploaded plugin zip and return its manifest dict."""
with zipfile.ZipFile(zip_path, "r") as zf:
members = zf.namelist()
if len(members) > MAX_PLUGIN_FILES:
raise ValueError(f"Plugin contains too many files (max {MAX_PLUGIN_FILES})")
for member in members:
if member.endswith("/"):
continue
norm = member.replace("\\", "/")
clean = norm.lstrip("/")
if ".." in clean.split("/"):
raise ValueError("Path traversal detected in plugin zip")
# Reject absolute paths and any ../../ escapes
if norm.startswith("/") or "/../" in f"/{norm}" or norm.endswith("/.."):
raise ValueError("Path traversal detected in plugin zip")
manifest = _plugin_manifest_from_member(zf)
# Entry point must exist
main = manifest["main"]
if main not in members and f"{main}/" not in members:
# accept with any leading ./ or subdir normalization
found = any(m.rstrip("/") == main or m.rstrip("/") == f"./{main}" for m in members)
if not found:
raise ValueError(f"Missing entry point: {main}")
return manifest
def _validate_plugin_directory(plugin_dir) -> dict[str, Any]:
"""Validate an installed plugin directory and return its manifest dict."""
d = Path(plugin_dir)
manifest_file = d / MANIFEST_FILENAME
if not manifest_file.is_file():
raise ValueError(f"Missing {MANIFEST_FILENAME}")
try:
data = json.loads(manifest_file.read_text(encoding="utf-8"))
except json.JSONDecodeError:
raise ValueError(f"Invalid JSON in {MANIFEST_FILENAME}")
m = PluginManifest.from_dict(data)
if not (d / m.main).is_file():
raise ValueError(f"Missing entry point: {m.main}")
return m.to_dict()
# ── API Endpoints ──────────────────────────────────────────────────────────
@router.get("", response_model=list[dict])
async def api_list_plugins(vault: str, current_user=Depends(require_auth)):
"""List installed plugins for a vault."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
return PluginManager(plugins_dir).list_plugins()
@router.get("/template")
async def api_plugin_template():
"""Return a starter plugin template (manifest + sample code)."""
template_manifest = {
"name": "my-plugin",
"version": "1.0.0",
"description": "Describe what your plugin does",
"author": "your-name",
"main": "index.js",
"hooks": {"onFileRender": "render"},
"permissions": ["read_files", "ui_notify"],
"license": "MIT",
}
template_code = (
"// ObsiGate plugin template\n"
"export function render(ctx) {\n"
" // ctx: { path, content, extension, vault }\n"
" if (ctx.content && ctx.path.endsWith('.md')) {\n"
" ctx.content = `> Plugin: ${self.name}\\n\\n` + ctx.content;\n"
" }\n"
" return ctx;\n"
"}\n"
)
return {
"manifest": template_manifest,
"code": template_code,
}
@router.get("/{plugin_name}", response_model=dict)
async def api_get_plugin(vault: str, plugin_name: str, current_user=Depends(require_auth)):
"""Get a single plugin's metadata."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
plugin = PluginManager(plugins_dir).get_plugin(plugin_name)
if not plugin:
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' not found")
return plugin
@router.get("/{plugin_name}/hooks", response_model=dict)
async def api_plugin_hooks(vault: str, plugin_name: str, current_user=Depends(require_auth)):
"""Get the hooks a plugin registers."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
return PluginManager(plugins_dir).get_hooks(plugin_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
@router.get("/{plugin_name}/code/{file_name:path}", response_model=dict)
async def api_plugin_code(
vault: str, plugin_name: str, file_name: str, current_user=Depends(require_auth)
):
"""Get a plugin source file for sandboxed execution."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
code = PluginManager(plugins_dir).get_plugin_code(plugin_name, file_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return {"name": plugin_name, "file": file_name, "code": code}
@router.post("/install")
async def api_install_plugin(
vault: str,
file: UploadFile = File(...),
current_user=Depends(require_admin),
):
"""Install a plugin from an uploaded zip (admin only)."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
content = await file.read()
if len(content) > 5_000_000: # 5 MB upload cap
raise HTTPException(status_code=413, detail="Plugin zip too large")
import io
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = Path(tmp_dir) / "plugin.zip"
try:
zip_path.write_bytes(content)
manifest = _validate_plugin_zip(zip_path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid plugin: {exc}")
# Extract to a temp dir then validate the entry point exists
plugins_dir = _vault_plugins_dir(vault)
manager = PluginManager(plugins_dir)
name = manifest["name"]
with tempfile.TemporaryDirectory() as td:
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
zf.extractall(td)
except (zipfile.BadZipFile, RuntimeError):
raise HTTPException(status_code=400, detail="Invalid plugin zip")
extracted = _validate_plugin_directory(Path(td) / name if (Path(td) / name).is_dir() else Path(td))
code_file = manifest["main"]
with zipfile.ZipFile(io.BytesIO(content)) as zf:
code = zf.read(code_file).decode("utf-8")
try:
result = manager.install(extracted, code)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))
return result
@router.delete("/{plugin_name}")
async def api_uninstall_plugin(vault: str, plugin_name: str, current_user=Depends(require_admin)):
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
PluginManager(plugins_dir).uninstall(plugin_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return JSONResponse({"ok": True})
@router.post("/{plugin_name}/enable")
async def api_enable_plugin(vault: str, plugin_name: str, current_user=Depends(require_admin)):
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
PluginManager(plugins_dir).enable(plugin_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return JSONResponse({"ok": True})
@router.post("/{plugin_name}/disable")
async def api_disable_plugin(vault: str, plugin_name: str, current_user=Depends(require_admin)):
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
plugins_dir = _vault_plugins_dir(vault)
try:
PluginManager(plugins_dir).disable(plugin_name)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return JSONResponse({"ok": True})
# ── Plugin Event Bus ────────────────────────────────────────────────────────
# Lightweight pub/sub for backend events that plugins can subscribe to.
# Used to dispatch onFileCreate, onFileDelete, onVaultMount to enabled plugins.
class _PluginEventBus:
"""In-memory event dispatcher for plugin lifecycle events."""
_listeners: ClassVar[dict[str, list]] = {}
@classmethod
def subscribe(cls, event_type: str, callback) -> None:
cls._listeners.setdefault(event_type, []).append(callback)
@classmethod
async def emit(cls, event_type: str, data: dict) -> None:
for cb in cls._listeners.get(event_type, []):
try:
result = cb(data)
if hasattr(result, "__await__"):
await result
except Exception as e:
logger.warning(f"Plugin event '{event_type}' handler error: {e}")
plugin_events = _PluginEventBus
def emit_file_created(vault: str, path: str, title: str = "") -> None:
"""Called when a file is created via the API."""
import asyncio
try:
loop = asyncio.get_running_loop()
loop.create_task(plugin_events.emit("onFileCreate", {
"vault": vault, "path": path, "title": title,
"timestamp": _time.time(),
}))
except RuntimeError:
pass # No event loop running
def emit_file_deleted(vault: str, path: str) -> None:
"""Called when a file is deleted via the API."""
import asyncio
try:
loop = asyncio.get_running_loop()
loop.create_task(plugin_events.emit("onFileDelete", {
"vault": vault, "path": path,
"timestamp": _time.time(),
}))
except RuntimeError:
pass
def emit_vault_mounted(vault: str, path: str) -> None:
"""Called when a vault is loaded into the index at startup."""
import asyncio
try:
loop = asyncio.get_running_loop()
loop.create_task(plugin_events.emit("onVaultMount", {
"vault": vault, "path": path,
"timestamp": _time.time(),
}))
except RuntimeError:
pass
+285
View File
@@ -0,0 +1,285 @@
# backend/push.py
# Push Notifications — Web Push API with VAPID authentication
# ROADMAP #67
import base64
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from backend.auth.middleware import require_auth
logger = logging.getLogger("obsigate.push")
router = APIRouter(prefix="/api/push", tags=["push"])
# VAPID keys storage
VAPID_KEYS_FILE = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / "vapid_keys.json"
PUSH_SUBSCRIPTIONS_FILE = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / "push_subscriptions.json"
# In-memory cache
_vapid_keys: dict[str, str] = {}
_push_subscriptions: list[dict[str, Any]] = []
def load_vapid_keys() -> dict[str, str]:
"""Load VAPID keys from file or generate new ones."""
global _vapid_keys
if _vapid_keys:
return _vapid_keys
try:
if VAPID_KEYS_FILE.exists():
with open(VAPID_KEYS_FILE, "r") as f:
_vapid_keys = json.load(f)
else:
# Generate new VAPID keys
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
# Convert to base64url for Web Push
public_numbers = public_key.public_numbers()
def int_to_base64url(n: int) -> str:
byte_len = (n.bit_length() + 7) // 8
return base64.urlsafe_b64encode(n.to_bytes(byte_len, 'big')).decode('utf-8').rstrip('=')
_vapid_keys = {
"private_key": private_pem,
"public_key": public_pem,
"public_key_base64url": int_to_base64url(public_numbers.x) + "." + int_to_base64url(public_numbers.y)
}
VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(VAPID_KEYS_FILE, "w") as f:
json.dump(_vapid_keys, f)
except Exception as e:
logger.error(f"Failed to load/generate VAPID keys: {e}")
# Fallback: return empty (will use demo keys if needed)
_vapid_keys = {}
return _vapid_keys
def get_vapid_public_key() -> str:
"""Get VAPID public key in base64url format for client."""
keys = load_vapid_keys()
return keys.get("public_key_base64url", "")
def load_push_subscriptions() -> list[dict[str, Any]]:
"""Load push subscriptions from file."""
global _push_subscriptions
if _push_subscriptions:
return _push_subscriptions
try:
if PUSH_SUBSCRIPTIONS_FILE.exists():
with open(PUSH_SUBSCRIPTIONS_FILE, "r") as f:
_push_subscriptions = json.load(f)
except Exception as e:
logger.error(f"Failed to load push subscriptions: {e}")
_push_subscriptions = []
return _push_subscriptions
def save_push_subscriptions() -> None:
"""Save push subscriptions to file."""
try:
PUSH_SUBSCRIPTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(PUSH_SUBSCRIPTIONS_FILE, "w") as f:
json.dump(_push_subscriptions, f, indent=2)
except Exception as e:
logger.error(f"Failed to save push subscriptions: {e}")
# ── Models ──────────────────────────────────────────────────────────────────
class PushSubscription(BaseModel):
"""Push subscription from client (matches Push API)."""
endpoint: str
keys: dict[str, str] # { p256dh, auth }
class SubscribeRequest(BaseModel):
"""Request to subscribe to push notifications."""
subscription: PushSubscription
vault: str = Field(description="Vault name this subscription is for")
class SubscribeResponse(BaseModel):
"""Response to subscription request."""
success: bool
subscription_id: str | None = None
class VapidPublicKeyResponse(BaseModel):
"""VAPID public key for client."""
public_key: str
class PushPayload(BaseModel):
"""Payload for sending a push notification."""
vault: str
title: str
body: str
data: dict[str, Any] = Field(default_factory=dict)
tag: str = "obsigate-notification"
# ── Endpoints ───────────────────────────────────────────────────────────────
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
async def get_vapid_public_key_endpoint():
"""Get VAPID public key for client subscription."""
public_key = get_vapid_public_key()
if not public_key:
# Return a demo key if generation failed (for testing)
return {"public_key": "demo-key-for-testing"}
return {"public_key": public_key}
@router.post("/subscribe", response_model=SubscribeResponse)
async def subscribe_push(
request: SubscribeRequest,
current_user=Depends(require_auth)
):
"""Subscribe to push notifications for a vault."""
username = current_user.get("username", "unknown")
# Check if user has access to this vault
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
if "*" not in user_vaults and request.vault not in user_vaults:
raise HTTPException(status_code=403, detail="No access to this vault")
# Check if subscription already exists
for sub in _push_subscriptions:
if sub["endpoint"] == request.subscription.endpoint and sub["username"] == username:
return SubscribeResponse(success=True, subscription_id=sub.get("id"))
# Add new subscription
sub_id = base64.urlsafe_b64encode(os.urandom(16)).decode('utf-8').rstrip('=')
subscription = {
"id": sub_id,
"endpoint": request.subscription.endpoint,
"keys": request.subscription.keys,
"vault": request.vault,
"username": username,
"created_at": datetime.now(timezone.utc).isoformat()
}
_push_subscriptions.append(subscription)
save_push_subscriptions()
logger.info(f"Push subscription added for user={username}, vault={request.vault}")
return SubscribeResponse(success=True, subscription_id=sub_id)
@router.delete("/subscribe")
async def unsubscribe_push(
endpoint: str,
current_user=Depends(require_auth)
):
"""Unsubscribe from push notifications."""
username = current_user.get("username", "unknown")
global _push_subscriptions
original_len = len(_push_subscriptions)
_push_subscriptions = [
s for s in _push_subscriptions
if not (s["endpoint"] == endpoint and s["username"] == username)
]
if len(_push_subscriptions) < original_len:
save_push_subscriptions()
return {"success": True, "message": "Unsubscribed"}
return {"success": False, "message": "Subscription not found"}
@router.get("/subscriptions")
async def list_subscriptions(current_user=Depends(require_auth)):
"""List current user's push subscriptions."""
username = current_user.get("username", "unknown")
user_subs = [
{
"id": s["id"],
"vault": s["vault"],
"created_at": s["created_at"],
"endpoint": s["endpoint"][:50] + "..." # Truncate for privacy
}
for s in _push_subscriptions if s["username"] == username
]
return {"subscriptions": user_subs}
# ── Internal: Send push notification ────────────────────────────────────────
async def send_push_notification(vault: str, title: str, body: str, data: dict | None = None, tag: str = "obsigate-notification") -> int:
"""Send push notification to all subscribers of a vault. Returns count of sent notifications."""
subscriptions = [s for s in _push_subscriptions if s["vault"] == vault]
if not subscriptions:
return 0
payload = {
"title": title,
"body": body,
"data": data or {},
"tag": tag
}
# Import here to avoid circular dependency
from pywebpush import WebPushException, webpush
keys = load_vapid_keys()
vapid_private_key = keys.get("private_key")
vapid_claims = {
"sub": "mailto:[email protected]"
}
sent = 0
for sub in subscriptions:
try:
webpush(
subscription_info={
"endpoint": sub["endpoint"],
"keys": sub["keys"]
},
data=json.dumps(payload),
vapid_private_key=vapid_private_key,
vapid_claims=vapid_claims
)
sent += 1
except WebPushException as e:
logger.warning(f"Push failed for subscription {sub['id']}: {e}")
# If subscription expired/invalid, remove it
if e.response and e.response.status_code in (404, 410):
_push_subscriptions.remove(sub)
except Exception as e:
logger.error(f"Push error for {sub['id']}: {e}")
if sent < len(subscriptions):
save_push_subscriptions()
return sent
+5
View File
@@ -0,0 +1,5 @@
# Additional dev/test-only dependencies for the test suite.
# These are NOT required for production runtime.
# Install with: pip install -r requirements-test.txt
reportlab>=4.0 # PDF fixture generation for test_pdf.py
+4
View File
@@ -13,3 +13,7 @@ snowballstemmer>=2.2.0
weasyprint>=60.0
httpx>=0.27.0
pypdf>=4.0
pyotp>=2.10.0
webauthn==2.6.0
psutil>=5.9
pywebpush>=2.3.0
+513
View File
@@ -0,0 +1,513 @@
"""Additional Pydantic response models for the ObsiGate REST API.
These models enrich the auto-generated OpenAPI 3.1 schema (#72). They are
intentionally permissive (``extra="allow"``) so that adding a new field to an
existing endpoint never breaks response validation — the model documents the
stable, public shape while still accepting internal additions.
Models whose endpoints are already typed in :mod:`backend.main` (e.g.
``FileContentResponse``) live there; this module only holds the ones used to
fill the gaps.
"""
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# ---------------------------------------------------------------------------
# Generic helpers
# ---------------------------------------------------------------------------
class StatusResponse(BaseModel):
"""Generic ``{"status": "..."}`` acknowledgement."""
model_config = ConfigDict(extra="allow")
status: str = Field(default="ok", description="Operation status")
class ErrorResponse(BaseModel):
"""Standard FastAPI error envelope."""
detail: str | list[dict[str, Any]] | None = Field(
default=None,
description="Human-readable error message, or a list of validation errors",
examples=["Vault 'Notes' not found"],
)
# ---------------------------------------------------------------------------
# Recent files & bookmarks
# ---------------------------------------------------------------------------
class RecentFileItem(BaseModel):
"""A recently opened or recently modified file."""
path: str = Field(description="Relative path within the vault")
title: str = Field(description="File title")
vault: str = Field(description="Vault name")
mtime: float | int | str | None = Field(default=None, description="Modification timestamp")
mtime_human: str | None = Field(default=None, description="Human-readable modification time")
mtime_iso: str | None = Field(default=None, description="ISO-8601 modification time")
size_bytes: int | None = Field(default=None, description="File size in bytes")
tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags")
preview: str | None = Field(default=None, description="Short content preview")
bookmarked: bool | None = Field(default=None, description="Whether the file is bookmarked")
class RecentResponse(BaseModel):
"""Response for ``GET /api/recent``."""
files: list[RecentFileItem]
total: int = Field(description="Total number of files in the selected mode")
limit: int = Field(description="Applied limit")
mode: str = Field(description="'opened' or 'modified'")
model_config = ConfigDict(
json_schema_extra={
"example": {
"files": [
{
"path": "notes/Accueil.md",
"title": "Accueil",
"vault": "TestVault",
"mtime": 1750000000.0,
"mtime_human": "il y a 2 h",
"size_bytes": 1024,
"tags": ["#accueil"],
"preview": "# Bienvenue…",
"bookmarked": False,
}
],
"total": 1,
"limit": 20,
"mode": "opened",
}
}
)
class BookmarkFileItem(BaseModel):
"""A bookmarked file."""
path: str = Field(description="Relative path within the vault")
title: str = Field(description="File title")
vault: str = Field(description="Vault name")
mtime: float | int | str | None = Field(default=None, description="Bookmark timestamp")
mtime_human: str | None = Field(default=None, description="Human-readable bookmark time")
size_bytes: int | None = Field(default=None, description="File size in bytes")
tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags")
bookmarked: bool | None = Field(default=True, description="Always true for this endpoint")
class BookmarksResponse(BaseModel):
"""Response for ``GET /api/bookmarks``."""
files: list[BookmarkFileItem]
total: int = Field(description="Number of bookmarked files")
class BookmarkToggleResponse(BaseModel):
"""Response for ``POST /api/bookmarks/toggle``."""
bookmarked: bool = Field(description="New bookmark state")
class SavedSearch(BaseModel):
"""A persisted search definition."""
model_config = ConfigDict(extra="allow")
id: str = Field(description="Unique identifier (millisecond timestamp)")
query: str = Field(description="Search query text")
vault: str = Field(default="all", description="Vault filter")
case_sensitive: bool = Field(default=False)
whole_word: bool = Field(default=False)
regex: bool = Field(default=False)
include_paths: str = Field(default="")
exclude_paths: str = Field(default="")
created_at: float = Field(description="Creation timestamp (epoch seconds)")
# ---------------------------------------------------------------------------
# Backups & diffs
# ---------------------------------------------------------------------------
class BackupsResponse(BaseModel):
"""Response for ``GET /api/file/{vault}/backups``."""
vault: str
path: str
backups: list[dict[str, Any]] = Field(description="Backups, newest first")
class BacklinksResponse(BaseModel):
"""Response for ``GET /api/file/{vault}/backlinks``."""
vault: str
path: str
backlinks: list[dict[str, Any]] = Field(description="Files linking to the target")
total: int
class BackupsListResponse(BaseModel):
"""Response for ``GET /api/backups``."""
backups: list[dict[str, Any]] = Field(description="All backups across vaults")
total: int
total_size_bytes: int
class BackupsDeletedResponse(BaseModel):
"""Response for backup deletion / purge endpoints."""
deleted: int = Field(description="Number of backup files deleted")
class BackupContentResponse(BaseModel):
"""Response for ``GET /api/backups/content``."""
content: str = Field(description="Backup file content (truncated to 100 KB)")
name: str = Field(description="Backup file name")
size: int = Field(description="Returned content size in characters")
class BackupsCompressResponse(BaseModel):
"""Response for ``POST /api/backups/compress``."""
compressed: int = Field(description="Number of backups processed")
saved_bytes: int = Field(description="Bytes saved by gzip compression")
dry_run: bool
class BackupsAutoResponse(BaseModel):
"""Response for ``POST /api/backups/auto``."""
backed_up: int = Field(description="Number of files backed up")
since_hours: int | float = Field(description="Look-back window in hours")
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
class PdfInfoResponse(BaseModel):
"""Response for ``GET /api/file/{vault}/pdf/info``."""
vault: str
path: str
pages: int = Field(description="Page count")
title: str = Field(description="PDF title (metadata or filename)")
author: str = Field(default="", description="PDF author")
size_bytes: int = Field(description="File size in bytes")
model_config = ConfigDict(
json_schema_extra={
"example": {
"vault": "TestVault",
"path": "docs/rapport.pdf",
"pages": 12,
"title": "Rapport annuel",
"author": "ObsiGate",
"size_bytes": 524288,
}
}
)
# ---------------------------------------------------------------------------
# Search & replace
# ---------------------------------------------------------------------------
class ReplaceMatch(BaseModel):
"""A single file affected by a find/replace operation."""
model_config = ConfigDict(extra="allow")
vault: str
path: str
title: str | None = None
match_count: int | None = Field(default=None, description="Occurrences found (dry run)")
replacements: int | None = Field(default=None, description="Occurrences replaced")
preview: list[str] = Field(default_factory=list, description="Context snippets (dry run)")
class ReplaceResponse(BaseModel):
"""Response for ``POST /api/search/replace``."""
model_config = ConfigDict(extra="allow")
matches: list[ReplaceMatch] = Field(default_factory=list, description="Dry-run matches")
replaced: list[ReplaceMatch] = Field(default_factory=list, description="Applied replacements")
total_matches: int | None = Field(default=None, description="Total matches (dry run)")
total_replacements: int = Field(description="Total replacements performed or previewed")
dry_run: bool | None = Field(default=None, description="True when no file was written")
# ---------------------------------------------------------------------------
# Vaults, attachments & settings
# ---------------------------------------------------------------------------
class VaultStatsResponse(BaseModel):
"""Response for ``POST /api/vaults/add`` and ``GET /api/index/reload/{vault}``."""
model_config = ConfigDict(extra="allow")
status: str = Field(default="ok")
vault: str
stats: dict[str, Any] = Field(default_factory=dict, description="Index statistics for the vault")
class VaultActionResponse(BaseModel):
"""Response for ``DELETE /api/vaults/{vault}``."""
status: str = Field(default="ok")
vault: str
class VaultStatusEntry(BaseModel):
"""Per-vault status entry."""
file_count: int
tag_count: int
path: str = ""
watching: bool = False
class VaultsStatusResponse(BaseModel):
"""Response for ``GET /api/vaults/status``."""
vaults: dict[str, VaultStatusEntry]
watcher_active: bool
sse_clients: int
class AttachmentRescanResponse(BaseModel):
"""Response for ``POST /api/attachments/rescan/{vault}``."""
status: str = Field(default="ok")
vault: str
attachment_count: int
class AttachmentStatsResponse(BaseModel):
"""Response for ``GET /api/attachments/stats``."""
vaults: dict[str, Any] = Field(description="Vault name → attachment statistics")
class VaultSettingsResponse(BaseModel):
"""Response for the per-vault display settings endpoints."""
model_config = ConfigDict(extra="allow")
hideHiddenFiles: bool = Field(default=False, description="Hide dotfiles in the tree")
class AllVaultSettingsResponse(BaseModel):
"""Response for ``GET /api/vaults/settings/all``."""
model_config = ConfigDict(extra="allow")
class VaultFileEntry(BaseModel):
"""A file entry returned by the vault home listing."""
model_config = ConfigDict(extra="allow")
name: str
path: str
vault: str
size: int = 0
modified: float = 0
modified_iso: str | None = None
extension: str = ""
rel_dir: str | None = None
class VaultFilesResponse(BaseModel):
"""Response for ``GET /api/vault/{vault}/files``."""
vault: str
directory: str = ""
recursive: bool = True
count: int
files: list[VaultFileEntry]
# ---------------------------------------------------------------------------
# Configuration, AI keys & diagnostics
# ---------------------------------------------------------------------------
class AppConfigResponse(BaseModel):
"""Application configuration (``GET/POST /api/config``)."""
model_config = ConfigDict(extra="allow")
class AIKeysResponse(BaseModel):
"""Masked AI provider keys (``GET /api/config/ai-keys``)."""
model_config = ConfigDict(extra="allow")
class AIKeyDeleteResponse(BaseModel):
"""Response for ``DELETE /api/config/ai-keys/{provider}``."""
status: str = Field(default="deleted")
key: str = Field(description="Deleted environment variable name")
class AITestResponse(BaseModel):
"""Response for ``POST /api/config/ai-keys/test`` (provider → status)."""
model_config = ConfigDict(extra="allow")
class AIModelsResponse(BaseModel):
"""Response for ``GET /api/config/ai-models``."""
model_config = ConfigDict(extra="allow")
models: list[str] = Field(default_factory=list, description="Available model identifiers")
source: str = Field(default="fallback", description="'live', 'fallback' or 'validation'")
count: int | None = Field(default=None, description="Number of live models")
error: str | None = Field(default=None, description="Provider/network error, if any")
note: str | None = Field(default=None, description="Explanatory note when using fallback")
class DiagnosticsResponse(BaseModel):
"""Response for ``GET /api/diagnostics``."""
model_config = ConfigDict(extra="allow")
index: dict[str, Any] = Field(default_factory=dict)
inverted_index: dict[str, Any] = Field(default_factory=dict)
config: dict[str, Any] = Field(default_factory=dict)
class DashboardVaultStat(BaseModel):
"""Per-vault dashboard statistics."""
name: str
file_count: int
tag_count: int
total_size_bytes: int
class DashboardResponse(BaseModel):
"""Response for ``GET /api/dashboard``."""
vaults: list[DashboardVaultStat]
total_files: int
total_tags: int
total_size_bytes: int
# ---------------------------------------------------------------------------
# Webhooks, sharing & conflicts
# ---------------------------------------------------------------------------
class WebhookModel(BaseModel):
"""A configured webhook."""
model_config = ConfigDict(extra="allow")
id: str = Field(description="Webhook UUID")
name: str
url: str = Field(description="Target HTTP(S) URL")
events: list[str] = Field(default_factory=list, description="Subscribed event types")
secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret")
enabled: bool = True
created_at: str | None = None
last_fired_at: str | None = None
class ShareModel(BaseModel):
"""A public document share."""
model_config = ConfigDict(extra="allow")
id: str
token: str = Field(description="Opaque share token")
vault: str
path: str
url: str | None = Field(default=None, description="Relative public URL (/s/{token})")
created_by: str | None = None
created_at: str | None = None
expires_at: str | None = None
access_count: int = 0
last_accessed: str | None = None
class ConflictEntry(BaseModel):
"""A Syncthing sync-conflict file."""
model_config = ConfigDict(extra="allow")
vault: str
path: str
original_path: str | None = None
class ConflictsResponse(BaseModel):
"""Response for ``GET /api/conflicts``."""
conflicts: list[ConflictEntry]
total: int
class ConflictResolveResponse(BaseModel):
"""Response for ``POST /api/conflicts/resolve``."""
status: str = Field(default="resolved")
action: str | None = Field(default=None, description="'keep_local' or 'keep_conflict'")
# ---------------------------------------------------------------------------
# AI status & BooksLM context
# ---------------------------------------------------------------------------
class AIProviderStatus(BaseModel):
"""Availability of a single AI provider."""
available: bool
model: str | None = Field(default=None, description="Default model when the provider is configured")
class AIAutocompleteStatus(BaseModel):
"""Status of the local Ollama autocomplete backend."""
available: bool = False
server_ok: bool = False
model_loaded: bool = False
model: str = ""
error: str | None = None
class AIStatusResponse(BaseModel):
"""Response for ``GET /api/ai/status``."""
configured: bool = Field(description="True when at least one provider has an API key")
default_provider: str
providers: dict[str, AIProviderStatus]
autocomplete: AIAutocompleteStatus
class BooksLMContextFile(BaseModel):
"""A single document included in the BooksLM context."""
path: str
title: str
content: str
type: str = Field(default="markdown", description="'markdown' (and future types)")
class BooksLMContextResponse(BaseModel):
"""Response for ``POST /api/ai/bookslm/context``."""
files: list[BooksLMContextFile]
total_chars: int
file_count: int
directory_tree: str = ""
max_total_chars: int = Field(default=200000, description="Configured context character limit")
max_files: int = Field(default=200, description="Configured file-count limit")
scope: str = Field(default="directory", description="Context scope: 'directory', 'documents' or 'general'")
+64 -28
View File
@@ -1,13 +1,15 @@
"""
Version management for ObsiGate.
Uses git describe to generate a SemVer-compatible version string.
Format: MAJOR.MINOR.PATCH[-commits-since-tag-gHASH]
The canonical version is the numeric SemVer of the LATEST release tag
(MAJOR.MINOR.PATCH). get_version() always returns a clean "x.y.z" string so
the UI never shows "-dev", "0.0.0-dev" or a "-N-gHASH" suffix — the same
number appears in the header badge, the About modal and the API health.
Examples:
v1.7.0 (exact tag)
v1.7.0-3-gabc1234 (3 commits after v1.7.0)
v1.7.0-dev (fallback when Git unavailable)
tag v2.0.0 -> "2.0.0"
HEAD 31 commits after -> "2.0.0" (release version, no dev clutter)
no git / no VERSION -> "0.0.0"
"""
from __future__ import annotations
@@ -15,13 +17,14 @@ import subprocess
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent # ObsiGate repo root
_VERSION_FILE = Path(__file__).resolve().parent / "VERSION"
def get_version() -> str:
"""Get the current version from Git tags."""
def _run_git(args: list[str]) -> str:
"""Run a git command in the repo root; return stdout (stripped) or ''."""
try:
result = subprocess.run(
["git", "describe", "--tags", "--dirty=-dirty"],
["git", *args],
cwd=str(_ROOT),
capture_output=True,
text=True,
@@ -29,35 +32,68 @@ def get_version() -> str:
check=False,
)
if result.returncode == 0:
tag = result.stdout.strip()
# git describe returns v1.7.0 or v1.7.0-3-gabc1234
# Remove leading 'v' if present
tag = tag.removeprefix("v")
return tag
return result.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
pass
return ""
# Fallback: try reading from a VERSION file next to this module
version_file = Path(__file__).parent / "VERSION"
if version_file.exists():
return version_file.read_text().strip()
return "0.0.0-dev"
def _clean_base(raw: str) -> str:
"""Reduce a version string to its numeric MAJOR.MINOR.PATCH base.
Handles "v2.0.0", "2.0.0-31-gabc1234", "2.0.0-dev", "0.0.0-dev".
Returns "" if no numeric triplet can be parsed.
"""
s = (raw or "").strip().lstrip("vV")
base = s.split("-")[0] # strip -N-gHASH / -dev / -dirty suffixes
parts = base.split(".")
nums = []
for p in parts[:3]:
if not p.isdigit():
break
nums.append(str(int(p)))
if len(nums) != 3:
return ""
return ".".join(nums)
def get_git_describe() -> str:
"""Full `git describe` string (e.g. "2.0.0-31-gabc1234") or '' if no git."""
return _run_git(["describe", "--tags", "--dirty=-dirty"]).lstrip("v")
def get_git_commit() -> str:
"""Short HEAD commit hash (e.g. "abc1234") or '' if unavailable."""
return _run_git(["rev-parse", "--short", "HEAD"])
def get_version() -> str:
"""Return the clean release version x.y.z (latest tag) — never a -suffix.
Priority: latest git tag -> backend/VERSION file -> "0.0.0".
"""
# 1) Latest tag from git (works even with commits beyond the tag)
tag = _run_git(["describe", "--tags", "--abbrev=0"])
base = _clean_base(tag)
if base:
return base
# 2) backend/VERSION file (baked at build time by build.sh / CI / Docker)
if _VERSION_FILE.exists():
base = _clean_base(_VERSION_FILE.read_text(encoding="utf-8"))
if base:
return base
# 3) Nothing available
return "0.0.0"
def get_version_tuple() -> tuple[int, int, int]:
"""Return (major, minor, patch) tuple for programmatic use."""
raw = get_version()
# Strip -suffix for parsing
base = raw.split("-")[0]
try:
parts = base.split(".")
major = int(parts[0]) if len(parts) > 0 else 0
minor = int(parts[1]) if len(parts) > 1 else 0
patch = int(parts[2]) if len(parts) > 2 else 0
return (major, minor, patch)
except (ValueError, IndexError):
return (0, 0, 0)
base = _clean_base(raw) or "0.0.0"
major, minor, patch = (int(p) for p in base.split("."))
return (major, minor, patch)
if __name__ == "__main__":
+66 -5
View File
@@ -1,7 +1,8 @@
import asyncio
import logging
import os
import time
from collections.abc import Callable
from collections.abc import Callable, Iterable
from pathlib import Path
from watchdog.events import FileSystemEventHandler
@@ -12,6 +13,54 @@ from backend.indexer import IGNORED_DIRS, SUPPORTED_EXTENSIONS
logger = logging.getLogger("obsigate.watcher")
# Filesystem types where inotify does NOT fire for changes made by other
# clients (the edit happens on another machine — e.g. Obsidian on Windows
# writing to an NFS/SMB export). For those mounts watchdog's native
# Observer silently misses everything and polling is the only option.
NETWORK_FSTYPES = {
"nfs", "nfs4", "cifs", "smbfs", "smb2", "smb3",
"fuse", "fuse.sshfs", "glusterfs", "9p", "virtiofs", "lustre",
}
def find_mount_fstype(resolved_path: str, mount_lines: Iterable[str]) -> str:
"""Return the fstype of the longest /proc/mounts entry covering the path.
Pure function (mount_lines are text lines) so it can be unit-tested
without /proc. Returns "" when no mount matches.
"""
best_point = ""
best_fstype = ""
for line in mount_lines:
parts = line.split()
if len(parts) < 3:
continue
mount_point, fstype = parts[1], parts[2]
# /proc/mounts escapes special chars in octal
mount_point = mount_point.replace("\\040", " ").replace("\\011", "\t")
root = mount_point.rstrip("/") or "/"
if (resolved_path == root or resolved_path.startswith(root + "/")) and len(mount_point) > len(best_point):
best_point = mount_point
best_fstype = fstype
return best_fstype
def is_network_mount(path: str) -> bool:
"""True if *path* sits on a network/fuse filesystem (Linux only).
inotify events are local to the client that made the write, so vaults
shared over NFS/SMB never see Obsidian's edits. On those mounts we
must fall back to PollingObserver, which detects changes by stat().
"""
if os.name != "posix":
return False
try:
with open("/proc/mounts", "r", encoding="utf-8") as fh:
fstype = find_mount_fstype(os.path.realpath(path), fh)
except OSError:
return False
return fstype in NETWORK_FSTYPES
class VaultEventHandler(FileSystemEventHandler):
"""Gestionnaire d'événements filesystem pour une vault Obsidian.
@@ -114,7 +163,12 @@ class VaultWatcher:
vault_path: str,
loop: asyncio.AbstractEventLoop,
):
"""Créer et démarrer un observer pour une vault."""
"""Créer et démarrer un observer pour une vault.
Le mode (natif inotify vs polling) est choisi par vault :
les mounts réseau (NFS/SMB/fuse) sont surveillés en polling car
inotify ne voit pas les écritures faites depuis d'autres clients.
"""
path = Path(vault_path)
if not path.exists():
logger.warning(f"Vault '{vault_name}' path not found: {vault_path}")
@@ -122,16 +176,23 @@ class VaultWatcher:
handler = VaultEventHandler(vault_name, self.event_queue, loop)
ObserverClass = PollingObserver if self.use_polling else Observer
polling = self.use_polling or is_network_mount(vault_path)
if polling and not self.use_polling:
logger.info(
f"Vault '{vault_name}' is on a network mount — "
"using polling watcher (inotify cannot see remote edits)"
)
ObserverClass = PollingObserver if polling else Observer
try:
observer = ObserverClass(
timeout=self.polling_interval if self.use_polling else 1
timeout=self.polling_interval if polling else 1
)
observer.schedule(handler, str(path), recursive=True)
observer.daemon = True
observer.start()
self.observers[vault_name] = observer
mode = "polling" if self.use_polling else "native"
mode = "polling" if polling else "native"
logger.info(f"Watching ({mode}): {vault_name} -> {vault_path}")
except Exception as e:
logger.error(f"Failed to start watcher for '{vault_name}': {e}")
+2
View File
@@ -2644,7 +2644,9 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tempfile",
"tokio",
"windows",
]
[[package]]
+14
View File
@@ -26,6 +26,20 @@ log = "0.4"
env_logger = "0.11"
chrono = "0.4"
[dev-dependencies]
tempfile = "3"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Variant",
"Win32_UI_Shell",
"Win32_UI_Shell_Common",
"Win32_UI_Shell_PropertiesSystem",
] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -2,6 +2,9 @@
"identifier": "default",
"description": "Default capabilities for ObsiGate Desktop",
"windows": ["main"],
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"permissions": [
"core:default",
"shell:allow-open",
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","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","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env pwsh
# ObsiGate Desktop — Windows code signing (scaffolding).
#
# Signe le binaire et l'installateur MSI/NSIS après un `cargo tauri build`.
# Aucun certificat n'est embarqué : les identifiants sont lus depuis
# l'environnement (jamais commités). Le script est un no-op explicite si
# l'env n'est pas configuré — le build reste donc fonctionnel sans signature.
#
# Variables d'environnement :
# OBSIGATE_SIGN_CERT_PFX chemin du .pfx (requis)
# OBSIGATE_SIGN_CERT_PASSWORD mot de passe du .pfx (optionnel)
# OBSIGATE_SIGN_TIMESTAMP_URL URL du serveur d'horodatage RFC3161
# (défaut : http://timestamp.digicert.com)
# OBSIGATE_SIGN_MODE "signtool" (Windows) ou "osslsigncode" (Linux)
#
# Usage :
# ./scripts/sign-windows.ps1 # après le build, signe les artefacts
# OBSIGATE_SIGN_CERT_PFX=cert.pfx ./scripts/sign-windows.ps1
$ErrorActionPreference = 'Stop'
$pfx = $env:OBSIGATE_SIGN_CERT_PFX
if (-not $pfx) {
Write-Host "⚠ Aucun certificat fourni (OBSIGATE_SIGN_CERT_PFX). Signature ignorée — build non signé."
exit 0
}
$password = $env:OBSIGATE_SIGN_CERT_PASSWORD
$tsUrl = if ($env:OBSIGATE_SIGN_TIMESTAMP_URL) { $env:OBSIGATE_SIGN_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
$mode = if ($env:OBSIGATE_SIGN_MODE) { $env:OBSIGATE_SIGN_MODE } else { 'signtool' }
$targets = @(
'target/release/obsigate-desktop.exe',
'target/release/bundle/msi/*.msi',
'target/release/bundle/nsis/*-setup.exe'
) | ForEach-Object { Get-Item $_ -ErrorAction SilentlyContinue } | Where-Object { $_ }
if ($targets.Count -eq 0) {
Write-Host "⚠ Aucun artefact à signer trouvé (lancer d'abord `cargo tauri build`)."
exit 0
}
foreach ($t in $targets) {
Write-Host "✍ Signature de $($t.FullName) (mode: $mode)"
if ($mode -eq 'osslsigncode') {
$args = @('sign', '-pkcs12', $pfx, '-h', 'sha256')
if ($password) { $args += @('-pass', $password) }
$args += @('-ts', $tsUrl, '-in', $t.FullName, '-out', $t.FullName)
& osslsigncode @args
if ($LASTEXITCODE -ne 0) { throw "osslsigncode a échoué pour $($t.Name)" }
} else {
$args = @('sign', '/fd', 'SHA256', '/f', $pfx, '/tr', $tsUrl, '/td', 'SHA256')
if ($password) { $args += @('/p', $password) }
$args += $t.FullName
& signtool @args
if ($LASTEXITCODE -ne 0) { throw "signtool a échoué pour $($t.Name)" }
}
}
Write-Host "✅ $($targets.Count) artefact(s) signé(s)."
+178
View File
@@ -0,0 +1,178 @@
//! Windows Taskbar Jump List — recent vaults.
//!
//! Populates the taskbar jump list with the configured vaults so the user can
//! jump straight into a vault from the Windows taskbar / Start menu.
//!
//! Best-effort: every step is fallible and logged, never panics, and a failure
//! simply leaves the jump list at its OS-managed default state.
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;
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(())
}
/// 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).
fn build_shell_link(name: &str, arg: &str) -> windows::core::Result<IShellLinkW> {
let link: IShellLinkW =
unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)? };
let exe = std::env::current_exe()
.map_err(|e| windows::core::Error::from(std::io::Error::other(e)))?;
let exe_wide = exe.as_os_str().encode_wide().chain(std::iter::once(0)).collect::<Vec<u16>>();
unsafe {
link.SetPath(PCWSTR(exe_wide.as_ptr()))?;
if !arg.is_empty() {
link.SetArguments(&HSTRING::from(arg))?;
}
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);
Ok(link)
}
/// Populate the jump list with the given (name, arg) vault entries.
pub fn update_jumplist(vaults: &[(String, String)]) {
// COM must be initialised on this thread.
let _ = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
let result = (|| -> windows::core::Result<()> {
unsafe {
SetCurrentProcessExplicitAppUserModelID(PCWSTR(wide_null_terminated(APP_ID).as_ptr()))?;
}
let list: ICustomDestinationList =
unsafe { CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)? };
let mut min_slots = 0u32;
unsafe {
list.BeginList::<windows::core::IUnknown>(&mut min_slots)?;
}
// "Vaults" category
let collection: IObjectCollection = unsafe {
CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?
};
for (name, arg) in vaults {
match build_shell_link(name, arg) {
Ok(link) => unsafe {
let _ = collection.AddObject(&link);
},
Err(e) => warn!("jumplist: failed to build link for {}: {}", name, e),
}
}
unsafe {
let array: windows::Win32::UI::Shell::Common::IObjectArray = collection.cast()?;
list.AppendCategory(&HSTRING::from("Vaults"), &array)?;
list.CommitList()?;
}
Ok(())
})();
match result {
Ok(()) => info!("jumplist updated with {} vaults", vaults.len()),
Err(e) => warn!("jumplist update failed (non-fatal): {}", e),
}
}
}
#[cfg(not(target_os = "windows"))]
mod imp {
use super::*;
/// No-op on non-Windows platforms.
pub fn update_jumplist(_vaults: &[(String, String)]) {
info!("jumplist is Windows-only — skipped");
}
}
/// Public entry point: update the Windows jump list from the configured vaults.
/// Each entry maps a vault name to the CLI argument the app accepts to open it.
pub fn update_jumplist(vaults: &[(String, String)]) {
imp::update_jumplist(vaults);
}
/// Build the (name, arg) pairs for the jump list from vault paths.
/// Pure helper so the mapping is unit-testable on every platform.
pub fn build_vault_args(vaults: &[crate::VaultConfig]) -> Vec<(String, String)> {
vaults
.iter()
.map(|v| (v.name.clone(), format!("--vault={}", v.path)))
.collect()
}
#[cfg(test)]
mod tests {
use super::build_vault_args;
use crate::VaultConfig;
fn v(name: &str, path: &str) -> VaultConfig {
VaultConfig { name: name.to_string(), path: path.to_string() }
}
#[test]
fn test_build_vault_args_empty() {
assert!(build_vault_args(&[]).is_empty());
}
#[test]
fn test_build_vault_args_maps_name_and_path() {
let vaults = vec![v("Perso", "C:\\vaults\\perso"), v("IT", "/home/bruno/IT")];
let args = build_vault_args(&vaults);
assert_eq!(args.len(), 2);
assert_eq!(args[0], ("Perso".to_string(), "--vault=C:\\vaults\\perso".to_string()));
assert_eq!(args[1], ("IT".to_string(), "--vault=/home/bruno/IT".to_string()));
}
#[test]
fn test_build_vault_args_preserves_order() {
let vaults = vec![v("a", "/a"), v("b", "/b"), v("c", "/c")];
let args = build_vault_args(&vaults);
assert_eq!(args.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), vec!["a", "b", "c"]);
}
}
+245 -7
View File
@@ -5,15 +5,46 @@
#![windows_subsystem = "windows"]
mod jumplist;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::Mutex;
use std::time::Duration;
// ── Backend port (ROADMAP #77 B: auto-increment if 17890 is busy) ────
const DEFAULT_BACKEND_PORT: u16 = 17890;
const PORT_SCAN_ATTEMPTS: u16 = 10;
static BACKEND_PORT: AtomicU16 = AtomicU16::new(DEFAULT_BACKEND_PORT);
fn backend_url() -> String {
format!("http://127.0.0.1:{}", BACKEND_PORT.load(Ordering::Relaxed))
}
/// First free TCP port starting at DEFAULT_BACKEND_PORT.
fn pick_free_port() -> u16 {
for offset in 0..PORT_SCAN_ATTEMPTS {
let port = DEFAULT_BACKEND_PORT + offset;
if TcpListener::bind(("127.0.0.1", port)).is_ok() {
if offset > 0 {
info!("Port {} busy — using {} instead", DEFAULT_BACKEND_PORT, port);
}
return port;
}
}
warn!("No free port in {}..{}, falling back to default",
DEFAULT_BACKEND_PORT, DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS - 1);
DEFAULT_BACKEND_PORT
}
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use tauri::Manager;
@@ -172,12 +203,15 @@ fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
cmd.env(format!("DIR_{}_PATH", n), &dir.path);
}
let port = pick_free_port();
BACKEND_PORT.store(port, Ordering::Relaxed);
let child = cmd
.args([
"-m", "uvicorn",
"backend.main:app",
"--host", "127.0.0.1",
"--port", "17890",
"--port", &port.to_string(),
"--log-level", "info",
])
.stdout(Stdio::from(log.try_clone().map_err(|e| format!("{}", e))?))
@@ -191,10 +225,10 @@ fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
async fn wait_for_backend() -> Result<(), String> {
let client = reqwest::Client::new();
let url = "http://127.0.0.1:17890/api/health";
let url = format!("{}/api/health", backend_url());
for i in 0..30 {
match client.get(url).timeout(Duration::from_secs(2)).send().await {
match client.get(&url).timeout(Duration::from_secs(2)).send().await {
Ok(resp) if resp.status().is_success() => {
info!("Backend ready (attempt {})", i + 1);
return Ok(());
@@ -235,7 +269,7 @@ fn kill_backend(child: &mut Child) {
#[tauri::command]
fn get_backend_url() -> String {
"http://127.0.0.1:17890".to_string()
backend_url()
}
#[tauri::command]
@@ -390,7 +424,7 @@ async fn check_backend_health(state: tauri::State<'_, BackendProcess>) -> Result
}
// Health check HTTP
match reqwest::get("http://127.0.0.1:17890/api/health").await {
match reqwest::get(format!("{}/api/health", backend_url())).await {
Ok(resp) if resp.status().is_success() => Ok("healthy".to_string()),
_ => Ok("unhealthy".to_string()),
}
@@ -547,6 +581,10 @@ fn main() {
// Build native menu bar (File / Edit / Help)
let _ = build_native_menu(app);
// Windows taskbar jump list — recent vaults (best-effort).
let jumplist_vaults = jumplist::build_vault_args(&config.vaults);
jumplist::update_jumplist(&jumplist_vaults);
// Restore window position/size
if let Some(window) = app.get_webview_window("main") {
if let (Some(x), Some(y)) = (config.window_x, config.window_y) {
@@ -570,7 +608,7 @@ fn main() {
let _ = window.eval(
r#"if (window.__setBootStatus) window.__setBootStatus("Backend prêt — chargement de l'interface…")"#,
);
match window.eval("window.location.href = 'http://127.0.0.1:17890'") {
match window.eval(&format!("window.location.href = '{}'", backend_url())) {
Ok(_) => info!("Redirect to backend OK"),
Err(e) => error!("Redirect eval failed: {}", e),
}
@@ -580,8 +618,9 @@ fn main() {
error!("Backend failed: {}", e);
if let Some(window) = handle.get_webview_window("main") {
let _ = window.eval(&format!(
r#"document.body.innerHTML = '<div style="padding:40px;text-align:center;font-family:sans-serif"><h2 style="color:#c0392b">ObsiGate n\'a pas pu démarrer</h2><p style="color:#666">{}</p><p><button onclick="location.reload()" style="padding:10px 20px;font-size:16px;cursor:pointer;background:#7C3AED;color:white;border:none;border-radius:6px">Réessayer</button></p><p style="margin-top:20px;font-size:12px;color:#999">Backend: http://127.0.0.1:17890 | Logs: {}</p></div>'"#,
r#"document.body.innerHTML = '<div style="padding:40px;text-align:center;font-family:sans-serif"><h2 style="color:#c0392b">ObsiGate n\'a pas pu démarrer</h2><p style="color:#666">{}</p><p><button onclick="location.reload()" style="padding:10px 20px;font-size:16px;cursor:pointer;background:#7C3AED;color:white;border:none;border-radius:6px">Réessayer</button></p><p style="margin-top:20px;font-size:12px;color:#999">Backend: {} | Logs: {}</p></div>'"#,
e.replace('\'', "\\'").replace('"', "\\\""),
backend_url(),
backend_log_path().display().to_string().replace('\\', "\\\\"),
));
}
@@ -832,3 +871,202 @@ fn build_tray_menu(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Mutex;
use tempfile::TempDir;
/// Serializes the `pick_free_port` tests: they bind real TCP ports, so
/// running them in parallel makes them clobber each other's port.
static PORT_TEST_LOCK: Mutex<()> = Mutex::new(());
/// Helper: write config to a temp dir and read it back
fn roundtrip_config(config: &AppConfig) -> AppConfig {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.json");
let json = serde_json::to_string_pretty(config).unwrap();
fs::write(&path, &json).unwrap();
let raw = fs::read_to_string(&path).unwrap();
serde_json::from_str(&raw).unwrap()
}
#[test]
fn test_default_config() {
let c = AppConfig::default();
assert!(c.vault_path.is_none());
assert!(c.vaults.is_empty());
assert!(c.dirs.is_empty());
assert_eq!(c.window_width, Some(1200.0));
assert_eq!(c.window_height, Some(800.0));
}
#[test]
fn test_config_roundtrip() {
let c = AppConfig {
vault_path: Some("/test/vault".into()),
vaults: vec![
VaultConfig { name: "Personal".into(), path: "/v/personal".into() },
VaultConfig { name: "Work".into(), path: "/v/work".into() },
],
dirs: vec![DirConfig { name: "home".into(), path: "/home".into() }],
window_x: Some(100.0),
window_y: Some(200.0),
window_width: Some(1400.0),
window_height: Some(900.0),
};
let loaded = roundtrip_config(&c);
assert_eq!(loaded.vault_path, Some("/test/vault".into()));
assert_eq!(loaded.vaults.len(), 2);
assert_eq!(loaded.vaults[0].name, "Personal");
assert_eq!(loaded.vaults[1].path, "/v/work");
assert_eq!(loaded.dirs.len(), 1);
assert_eq!(loaded.window_x, Some(100.0));
}
#[test]
fn test_config_empty_json() {
// serde requires vaults/dirs fields — missing fields use unwrap_or_default
let c: AppConfig = serde_json::from_str("{}").unwrap_or_default();
assert!(c.vault_path.is_none());
assert!(c.vaults.is_empty());
}
#[test]
fn test_config_partial_json() {
// serde requires all fields — partial JSON uses unwrap_or_default
let c: AppConfig = serde_json::from_str(
r#"{"vaults":[{"name":"t","path":"/t"}]}"#
).unwrap_or_default();
// Will fall to default since dirs is missing
assert!(c.vaults.is_empty() || c.vaults.len() == 1);
}
#[test]
fn test_config_corrupted_json_fallback() {
let result: Result<AppConfig, _> = serde_json::from_str("not json");
assert!(result.is_err());
// Caller should use unwrap_or_default()
let c = result.unwrap_or_default();
assert!(c.vaults.is_empty());
}
#[test]
fn test_vault_dedup() {
let mut c = AppConfig::default();
c.vaults.retain(|v| v.name != "test");
c.vaults.push(VaultConfig { name: "test".into(), path: "/first".into() });
c.vaults.retain(|v| v.name != "test");
c.vaults.push(VaultConfig { name: "test".into(), path: "/second".into() });
assert_eq!(c.vaults.len(), 1);
assert_eq!(c.vaults[0].path, "/second");
}
#[test]
fn test_dir_remove() {
let mut c = AppConfig::default();
c.dirs.push(DirConfig { name: "a".into(), path: "/a".into() });
c.dirs.push(DirConfig { name: "b".into(), path: "/b".into() });
c.dirs.retain(|d| d.name != "a");
assert_eq!(c.dirs.len(), 1);
assert_eq!(c.dirs[0].name, "b");
}
#[test]
fn test_backend_url() {
let port = BACKEND_PORT.load(Ordering::Relaxed);
assert_eq!(get_backend_url(), format!("http://127.0.0.1:{}", port));
}
#[test]
fn test_pick_free_port_is_free() {
let _guard = PORT_TEST_LOCK.lock().unwrap();
let port = pick_free_port();
assert!((DEFAULT_BACKEND_PORT..DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS).contains(&port));
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok());
}
#[test]
fn test_pick_free_port_skips_busy_port() {
let _guard = PORT_TEST_LOCK.lock().unwrap();
let blocker = match TcpListener::bind(("127.0.0.1", DEFAULT_BACKEND_PORT)) {
Ok(l) => l,
Err(_) => return, // default port already busy on this machine — skip
};
let port = pick_free_port();
assert_ne!(port, DEFAULT_BACKEND_PORT);
assert!(port < DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS);
drop(blocker);
}
#[test]
fn test_backend_url_reflects_port() {
let saved = BACKEND_PORT.load(Ordering::Relaxed);
BACKEND_PORT.store(17893, Ordering::Relaxed);
assert_eq!(backend_url(), "http://127.0.0.1:17893");
BACKEND_PORT.store(saved, Ordering::Relaxed);
}
#[test]
fn test_about_msg_contains_branding() {
assert!(ABOUT_MSG.contains("ObsiGate Desktop"));
assert!(ABOUT_MSG.contains("Bruno Charest"));
assert!(ABOUT_MSG.contains("Tauri"));
}
#[test]
fn test_app_data_dir_nonempty() {
let dir = app_data_dir();
assert!(!dir.to_string_lossy().is_empty());
}
#[test]
fn test_logs_dir_under_app_data() {
let logs = logs_dir();
assert!(logs.starts_with(app_data_dir()));
assert_eq!(logs.file_name().unwrap(), "logs");
}
#[test]
fn test_backend_log_path() {
let p = backend_log_path();
assert_eq!(p.file_name().unwrap(), "backend.log");
}
#[test]
fn test_json_format_keys() {
let c = AppConfig::default();
let json = serde_json::to_string(&c).unwrap();
assert!(json.contains("\"vaults\""));
assert!(json.contains("\"dirs\""));
assert!(json.contains("\"window_width\""));
}
#[test]
fn test_backend_health_check_url() {
// Verify the health check targets the correct endpoint
let url = "http://127.0.0.1:17890/api/health";
assert!(url.contains("17890"));
assert!(url.contains("/api/health"));
}
#[test]
fn test_vault_config_clone() {
let v = VaultConfig { name: "test".into(), path: "/t".into() };
let v2 = v.clone();
assert_eq!(v.name, v2.name);
assert_eq!(v.path, v2.path);
}
#[test]
fn test_dir_config_debug() {
let d = DirConfig { name: "x".into(), path: "/x".into() };
let dbg = format!("{:?}", d);
assert!(dbg.contains("x"));
}
}
+6
View File
@@ -11,6 +11,12 @@ services:
obsigate:
build:
context: .
args:
# VERSION est injecte par build.sh/CI via `git describe`.
# En `docker compose build` direct (sans build.sh), on retombe sur le
# DERNIER TAG (v2.1.0) et non 0.0.0-dev, pour ne jamais afficher "0.0.0"
# dans le header / la boîte À propos. Packagé par backend/VERSION.
VERSION: ${VERSION:-2.1.0}
image: obsigate:latest
container_name: obsigate
user: "1000:1000"
+3 -3
View File
@@ -107,9 +107,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|---|---|---|---|---|---|---|---|---|---|
| *BUG-001* | L'ouverture des fichier PDF ne fonctionne pas et donne l'erreur Internal Server Error | 🔴 ouvert | P1 | fichier PDF | IA | |
https://og.dracodev.net/api/file/Recettes/pdf/stream?path=98_Boite_Outils%2F98.2_Attachments%2FBi%C3%A8re%20blonde%20envoy%C3%A9%20par%20Desja.pdf| — | l'erreur se retrouve dans la section network de la console web et dans l'interface web lors du chargement d'un pdf |
| *BUG-002* | l'ouverture d'un fichier .excalidraw ne fonctionne pas et affiche toujours Loading *Excalidraw…* | 🔴 ouvert | P1 | fichier .excalidaw | IA | | | — | test d'accès réaliser via cloudflare et réseau local démontre que ce problème est au 2 endroits |
| *BUG-001* | L'ouverture des fichier PDF ne fonctionne pas et donne l'erreur Internal Server Error | 🟢 corrigé | P1 | fichier PDF | IA | `backend/main.py` | `GET /api/file/{vault}/pdf/stream?path=…(pdf à nom accentué)` | `backend/main.py` : Content-Disposition encodé RFC 5987 (helper `_content_disposition`) | 500 car nom Unicode brut dans l'en-tête → header invalide. Vérifié: stream 200 / Range 206 + test `tests/test_pdf_stream.py` |
| *BUG-002* | l'ouverture d'un fichier .excalidraw ne fonctionne pas et affiche toujours Loading *Excalidraw…* | 🟢 corrigé | P1 | fichier .excalidraw | IA | `frontend/excalidraw-editor.html` | Ouvrir un fichier `.excalidraw` | `frontend/excalidraw-editor.html` : alias esm.sh supprimé (408 jotai) + React 19 cohérent + prop `excalidrawAPI` | 2 causes: 408 esm.sh sur `?alias` + prop legacy `excalidrawRef` inopérante en 0.18. Vérifié navigateur: Loading masqué + cycle save OK |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -130,6 +129,7 @@ https://og.dracodev.net/api/file/Recettes/pdf/stream?path=98_Boite_Outils%2F98.2
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|---|---|---|---|---|---|
| *(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) |
---
+215
View File
@@ -0,0 +1,215 @@
# Plugin System — ObsiGate #61
## Overview
ObsiGate's plugin system enables extending the application with custom
renderers, search filters, and editor actions — all sandboxed for security.
Plugins run in a Web Worker sandbox and communicate with the main app via
structured `postMessage`. No plugin can access the DOM, localStorage, or
make network requests without explicit permission.
## Quick Start
### Installing a Plugin
1. Go to **Settings > Plugins** in the sidebar
2. Click **Install Plugin**
3. Upload a `.zip` file or a directory containing:
- `plugin.json` — the manifest
- Your main JS file (entry point specified in `main`)
### Creating a Plugin
Use the **View Template** button in Settings > Plugins to get started.
## Manifest (plugin.json)
```json
{
"name": "my-plugin",
"version": "1.0.0",
"description": "A brief description of what this plugin does",
"author": "your-name",
"main": "index.js",
"hooks": {
"onFileRender": "renderFile",
"onSearchFilter": "filterResults",
"onEditorAction": "handleAction"
},
"permissions": ["read_files"],
"min_obsigate_version": "2.1.0",
"license": "MIT",
"homepage": "https://example.com",
"repository": "https://github.com/user/plugin"
}
```
### Required Fields
| Field | Type | Description |
|---------------|--------|-------------------------------------------------|
| `name` | string | Lowercase alphanumeric + hyphens (e.g. `my-plugin`) |
| `version` | string | Semantic version (e.g. `1.0.0`, `1.0.0-beta.1`) |
| `description` | string | Brief description |
| `author` | string | Author name |
| `main` | string | Entry point file (relative to plugin root) |
### Optional Fields
| Field | Type | Default | Description |
|------------------------|--------|-------------|------------------------------------------|
| `hooks` | object | `{}` | Map of hook name → handler function name |
| `permissions` | array | `[]` | Required permissions |
| `min_obsigate_version` | string | `"2.1.0"` | Minimum ObsiGate version |
| `license` | string | `"MIT"` | License identifier |
| `homepage` | string | `null` | Plugin homepage URL |
| `repository` | string | `null` | Source repository URL |
## Available Hooks
| Hook | When it fires | Handler signature |
|-------------------|-----------------------------------|--------------------------------|
| `onFileRender` | Before a file is rendered | `(ctx) → transformed ctx` |
| `onSearchFilter` | During search result filtering | `(results) → filtered results` |
| `onEditorAction` | When editor action is triggered | `(action, state) → result` |
| `onSidebarItem` | Sidebar item is rendered | `(item) → enhanced item` |
| `onFileCreate` | After a file is created | `(file) → void` |
| `onFileDelete` | After a file is deleted | `(file) → void` |
| `onVaultMount` | When a vault is mounted | `(vault) → void` |
## Permissions
Plugins must declare the permissions they need. The sandbox enforces these
restrictions at runtime.
| Permission | Description |
|-----------------------|--------------------------------------------------|
| `read_files` | Read file contents from the vault |
| `write_files` | Write/create files in the vault |
| `read_vault_metadata` | Access vault configuration and metadata |
| `network_request` | Make HTTP requests to external services |
| `ui_notify` | Show toast notifications in the UI |
| `access_clipboard` | Read/write to the system clipboard |
## Security Model
### Sandbox Architecture
```
┌─────────────────────────────────┐
│ Main App (browser context) │
│ │
│ PluginManager │
│ ├── install/uninstall │
│ ├── enable/disable │
│ └── hook dispatch │
│ │ postMessage (C3) │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Web Worker Sandbox │ │
│ │ (no DOM, no localStorage│ │
│ │ no network by default) │ │
│ │ │ │
│ │ Plugin code executes │ │
│ │ here with structured │ │
│ │ message passing only │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
```
### Security Guarantees
- **C1**: Manifest validation (name format, semver, allowed hooks/permissions)
- **C2**: Vault isolation — plugins are scoped to a single vault
- **C3**: Web Worker sandbox — no DOM, no `localStorage`, no `importScripts`
- **C4**: ZIP validation — path traversal, file count, size limits
- **C5**: Directory validation — manifest + entry point presence
- **C6**: Permission enforcement at sandbox boundary
### Limits
- Max 50 plugins per vault
- Max 500 KB per plugin file
- Max 100 files per plugin ZIP
- No dynamic `import()` or `eval()`
## Plugin Storage
Plugins are installed under `<vault>/.obsigate-plugins/<plugin-name>/`:
```
.obsigate-plugins/
my-plugin/
plugin.json # manifest
index.js # entry point
.disabled # marker file (created when disabled)
```
## API Reference
### Backend Endpoints
| Method | Endpoint | Auth | Description |
|--------|---------------------------------|---------|--------------------------|
| GET | `/api/plugins` | require_auth | List installed plugins |
| POST | `/api/plugins/install` | require_admin | Install from ZIP |
| DELETE | `/api/plugins/{name}` | require_admin | Uninstall plugin |
| POST | `/api/plugins/{name}/enable` | require_admin | Enable plugin |
| POST | `/api/plugins/{name}/disable` | require_admin | Disable plugin |
| GET | `/api/plugins/{name}/code/{file}` | require_auth | Get plugin code |
| GET | `/api/plugins/{name}/hooks` | require_auth | Get plugin hooks |
| GET | `/api/plugins/template` | require_auth | Get plugin template |
### Frontend Module (`plugins.js`)
```javascript
import { PluginManager } from './plugins.js';
// Install
await PluginManager.installPlugin(manifest, code);
// Enable/Disable
await PluginManager.enablePlugin('my-plugin');
await PluginManager.disablePlugin('my-plugin');
// Uninstall
await PluginManager.uninstallPlugin('my-plugin');
// Get code for sandbox
const code = await PluginManager.getPluginCode('my-plugin', 'index.js');
// List
const plugins = await PluginManager.getCachedPlugins();
```
## Creating Your First Plugin
1. Create a directory with `plugin.json`:
```json
{
"name": "hello-world",
"version": "1.0.0",
"description": "My first ObsiGate plugin",
"author": "you",
"main": "index.js",
"hooks": {
"onFileRender": "render"
},
"permissions": ["read_files", "ui_notify"]
}
```
2. Create `index.js`:
```javascript
export function render(ctx) {
// Add a custom header to rendered markdown
if (ctx.content && ctx.path.endsWith('.md')) {
ctx.content = `> 📝 Plugin: hello-world\n\n${ctx.content}`;
}
return ctx;
}
```
3. Zip the directory and install via Settings > Plugins > Install Plugin.
+278 -230
View File
@@ -1,6 +1,7 @@
# ObsiGate — Roadmap
> **Version :** 2.0.0-dev | **Dernière mise à jour :** 2026-06-18
> **Version :** 2.1.0-dev | **Dernière mise à jour :** 2026-09-10
> Revue de cohérence roadmap ↔ code : cases cochées selon l'état réel vérifié dans le dépôt (#78 docs H1-H3, #67 push, #68 health, #74/#61 optionnels non retenus).
> Voir aussi [CHANGELOG.md](./CHANGELOG.md), [AUDIT_TECHNIQUE.md](./docs/AUDIT_TECHNIQUE_2026-05-27.md)
---
@@ -119,7 +120,7 @@
---
## 🔵 En cours (P1)
## ✅ Complété (suite — v1.7 → v2.1)
### 58. Tests E2E Playwright ✅ FAIT
- **Effort :** 2-3 jours | **Impact :** 🔴
@@ -146,7 +147,7 @@
## ⚪ Backlog — Priorité 3 (P3)
### 59. Mode hors-ligne PWA complet ✅ FAIT
### 59. Mode hors-ligne PWA complet — ✅ TERMINÉ
- **Effort :** 3-4 jours | **Impact :** 🟡
- **Description :** Service worker avancé avec IndexedDB pour permettre la navigation et la recherche en mode hors-ligne, avec file de synchronisation au retour réseau.
- **Implémentation :** `offline-db.js` (377 lignes) + `offline.js` (209 lignes). IndexedDB 3 stores (files, content, pending). Badge hors-ligne dans le header. Modale résolution de conflits.
@@ -159,17 +160,23 @@
- [x] UI indicateur : badge « Hors-ligne » + compteur de modifications en attente
- [x] Gestion des conflits : détection et résolution manuelle (choix version locale vs serveur)
### 61. Plugins système — Extensions utilisateur
- **Effort :** 4-5 jours | **Impact :** 🟢
### 61. Plugins système — Extensions utilisateur ✅
- **Effort :** 4-5 jours | **Impact :** 🟢 | **Statut :** ✅ Livré (backend + frontend + tests + docs)
- **Description :** Système de plugins permettant aux utilisateurs d'étendre ObsiGate avec des renderers personnalisés, des opérateurs de recherche, et des hooks d'UI. Inspiré du modèle de plugins Obsidian.
- **Sous-tâches :**
- [ ] Spécification du format de plugin : `plugin.json` (name, version, hooks, permissions)
- [ ] API de hooks : `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`
- [ ] Sandbox d'exécution : Web Worker isolé pour le code plugin
- [ ] UI : page « Plugins » dans les paramètres (installer, activer/désactiver, désinstaller)
- [ ] Distribution : dépôt de plugins communautaire (fichier JSON index)
- [ ] Hot-reload : activation/désactivation sans rechargement de page
- [ ] Sécurité : manifest de permissions, validation de signature, CSP restrictif
- [x] Spécification du format de plugin : `plugin.json` (name, version, hooks, permissions)
- [x] API de hooks : `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`, `onFileCreate`, `onFileDelete`, `onVaultMount`
- [x] Sandbox d'exécution : Web Worker isolé pour le code plugin (blob URL, postMessage structuré, CSP sans importScripts)
- [x] UI : page « Plugins » dans les paramètres (installer, activer/désactiver, désinstaller, template, viewer)
- [ ] Distribution : dépôt de plugins communautaire (fichier JSON index) — ⚪ NON RETENU (backlog)
- [x] Hot-reload : activation/désactivation sans rechargement de page (marker `.disabled`)
- [x] Sécurité : manifest de permissions, validation path-traversal, CSP restrictif
- **Livré :**
- Backend `backend/plugins.py` — validation manifest (name regex, semver, hooks/permissions autorisés), stockage par vault `<vault>/.obsigate-plugins/`, lifecycle complet, validation ZIP (path traversal, limite 100 fichiers, 500KB/fichier), 9 endpoints `/api/plugins/*` (admin-gated pour install/uninstall/enable/disable), template API.
- Frontend `frontend/js/plugins.js` — PluginManager, sandbox Web Worker (code via blob URL, protocole postMessage structuré), UI Settings > Plugins, hooks dispatch (`executeHook`/`onFileRender`/`onSearchFilter`/…).
- Tests : `tests/test_plugins.py` (44) + `tests/frontend/plugins.test.mjs` (21) — validation, lifecycle, ZIP/dir sécurité, protocole sandbox, isolation DOM/CSP.
- Docs : `docs/PLUGINS.md`.
- **En backlog (non retenu) :** dépôt communautaire (index JSON), signature de code des plugins.
### 62. Collaboration temps réel — Édition simultanée
- **Effort :** 5-7 jours | **Impact :** 🟢
@@ -190,7 +197,7 @@
- [ ] Gestion des déconnexions : reconnexion automatique, merge state au retour
- [ ] Tests de charge : 5+ utilisateurs simultanés sur le même fichier
### 63. Internationalisation (i18n) — Multilingue
### 63. Internationalisation (i18n) — Multilingue — ✅ TERMINÉ
- **Effort :** 2-3 jours | **Impact :** 🟡 | **Statut :** ✅ Terminé
- **Description :** Support de l'anglais et du français via un système de clés de traduction.
- **Sous-tâches :**
@@ -207,125 +214,133 @@
- [x] Thèmes, palette de commandes, raccourcis, webhooks → EN/FR complet
- [x] Messages système : toasts, statuts, événements → EN/FR complet
### 64. MFA — Authentification multi-facteurs
- **Effort :** 2 jours | **Impact :** 🟡
### 64. MFA — Authentification multi-facteurs — ✅ TERMINÉ (TOTP + WebAuthn + recovery codes)
- **Effort :** 2 jours (réalisé) | **Impact :** 🟡
- **Description :** Ajout d'un second facteur d'authentification obligatoire pour les comptes administrateur. Deux méthodes sont proposées :
- **TOTP** (Time-based One-Time Password) : l'utilisateur scanne un QR code avec son app d'authentification (Google Authenticator, Authy, Bitwarden) qui génère un code à 6 chiffres renouvelé toutes les 30 secondes. Au login, après avoir saisi son mot de passe, l'utilisateur doit entrer le code affiché sur son téléphone. Même si le mot de passe est volé, le compte reste protégé car l'attaquant n'a pas le téléphone.
- **WebAuthn** (clés de sécurité physiques) : l'utilisateur enregistre une clé USB (YubiKey, SoloKey) ou utilise la biométrie de son appareil (empreinte digitale, Face ID, Windows Hello). Au login, le navigateur demande de toucher la clé physique ou de scanner le doigt. C'est le niveau de sécurité le plus élevé — résistant au phishing car la clé vérifie le domaine du site avant de répondre.
- **Codes de secours** : 8 codes à usage unique imprimables, à conserver en lieu sûr, qui permettent de se connecter même si on perd son téléphone ou sa clé. Chaque code ne fonctionne qu'une seule fois.
- **Pourquoi c'est important :** Le vol de mot de passe est la cause #1 de brèches de sécurité. Avec un vault Obsidian contenant des notes personnelles, projets sensibles, secrets et tokens API, l'authentification par simple mot de passe n'est plus suffisante. Le MFA empêche 99.9% des attaques de prise de compte automatisées (source : Microsoft Security).
- **Sous-tâches :**
- [ ] TOTP : génération de secret, QR code, vérification code 6 chiffres
- [ ] WebAuthn : enregistrement de clé, assertion, attestation
- [ ] UI : page « Sécurité du compte » avec activation/désactivation MFA
- [ ] Flow login : mot de passe → challenge TOTP si activé
- [ ] Recovery codes : 8 codes de backup à usage unique
- [ ] Stockage : `totp_secret` + `webauthn_credential_id` dans `users.json`
- [x] TOTP : génération de secret, QR code, vérification code 6 chiffres
- [x] WebAuthn : enregistrement de clé, assertion, attestation (`backend/auth/webauthn_mfa.py`, lib `webauthn==2.6.0`, challenges in-memory TTL 180s à usage unique) — FAIT en 2026-09 (commit ab795ec)
- [x] UI : page « Sécurité du compte » avec activation/désactivation MFA + gestion des clés WebAuthn (liste, ajout, retrait)
- [x] Flow login : mot de passe → challenge TOTP OU WebAuthn selon `mfa_method` retourné par /login
- [x] Recovery codes : 8 codes de backup à usage unique (générés à l'activation, hachés SHA-256)
- [x] Stockage : `mfa_secret` + `webauthn_credentials[]` dans `users.json`
- [x] Tests : `tests/test_mfa.py` (29) + `tests/test_webauthn.py` (10, authentificateur virtuel CBOR/EC P-256)
### 65. Thèmes personnalisés — CSS variables
- **Effort :** 1-2 jours | **Impact :** 🟢
### 65. Thèmes personnalisés — CSS variables — ✅ TERMINÉ
- **Effort :** 1-2 jours (réalisé) | **Impact :** 🟢
- **Description :** Exposition de variables CSS pour permettre aux utilisateurs de créer des thèmes personnalisés. Presets inclus : light, dark, high-contrast, sepia.
- **Sous-tâches :**
- [ ] Audit des variables CSS existantes → liste des 30+ variables
- [ ] Fichier `themes.json` avec presets (light, dark, high-contrast, sepia)
- [ ] UI : sélecteur de thème dans les paramètres (aperçu live)
- [ ] Import/export de thème personnalisé (JSON)
- [ ] Application dynamique sans rechargement (`document.documentElement.style.setProperty`)
- [x] Audit des variables CSS existantes → 40+ variables
- [x] Presets: light, dark, high-contrast, sepia (générés dynamiquement)
- [x] UI : sélecteur de thème dans les paramètres (swatches grid)
- [x] Import/export de thème personnalisé (JSON)
- [x] Application dynamique via document.documentElement.style.setProperty
### 66. Export multi-formats
- **Effort :** 1-2 jours | **Impact :** 🟢
### 66. Export multi-formats — ✅ TERMINÉ
- **Effort :** 1-2 jours (réalisé) | **Impact :** 🟢
- **Description :** Export de notes individuelles ou de vaults entiers en HTML standalone, bundle Markdown (.zip), et ePub pour liseuses.
- **Sous-tâches :**
- [ ] Export HTML standalone : CSS inliné, images en base64, navigation inter-fichiers
- [ ] Export MD bundle : ZIP du vault avec structure préservée
- [ ] Export ePub : conversion markdown → ePub via `markdown` + `ebooklib`
- [ ] UI : bouton « Exporter » dans le viewer (fichier unique) + dans le menu vault (export complet)
- [ ] Endpoints : `GET /api/export/html`, `GET /api/export/md-bundle`, `GET /api/export/epub`
- [x] Export HTML standalone : CSS inliné, images en base64, navigation inter-fichiers
- [x] Export MD bundle : ZIP du vault avec structure préservée
- [x] Export ePub : conversion markdown → ePub (zipfile + mistune, 0 nouvelle dep)
- [x] UI : dropdown Export dans toolbar viewer (HTML / MD bundle / ePub)
- [x] Endpoints : `GET /api/export/html`, `GET /api/export/md-bundle`, `GET /api/export/epub`
### 74. Support complet des documents PDF
- **Effort :** 4-5 jours | **Impact :** 🟡
- **Description :** Prise en charge native des fichiers PDF dans ObsiGate avec parité fonctionnelle complète avec les documents Markdown : apparition dans l'arborescence, indexation full-text, visualisation inline dans le navigateur, recherche TF-IDF, et téléchargement. Actuellement, les PDF sont traités comme des fichiers binaires non supportés (message « Ce fichier est binaire et ne peut pas être affiché » + bouton download).
- **Architecture actuelle :**
- `SUPPORTED_EXTENSIONS` (`backend/indexer.py:56`) : ne contient pas `.pdf` → les PDF sont ignorés par l'indexeur, le file watcher, et l'arborescence
- `api_file_view()` (`backend/main.py:2303`) : UnicodeDecodeError sur lecture → retourne `unsupported: true`
- `frontend/js/viewer.js:377` : si `data.unsupported` → affiche le message binaire + bouton download
- `pdf_export.py` : exporte du MD → PDF (WeasyPrint) — aucun rapport avec la lecture de PDF existants
- Icone PDF déjà présente dans `EXT_ICONS` frontend (`.pdf` → `file-text`) — inutilisée
### 74. Support complet des documents PDF — ✅ TERMINÉ
- **Effort :** 4-5 jours | **Impact :** 🟡 | **Statut :** ✅ COMPLET (2026-09-07 — C3 + Range 206 + config G3 + indexation incrémentale, commit 7042307. Optionnels D2/E4/H2/I2 non retenus)
- **Description :** Prise en charge native des fichiers PDF dans ObsiGate avec parité fonctionnelle complète avec les documents Markdown : apparition dans l'arborescence, indexation full-text, visualisation inline dans le navigateur, recherche TF-IDF, et téléchargement.
- **Implémentation réelle (vérifiée 2026-09-07) :**
- **Bugs corrigés (2026-09) :** `api_pdf_stream` crashait en 500 (`NameError: current_user` jamais injecté) ; l'indexation incrémentale du watcher faisait `read_text()` sur les PDFs (garbage) ; Range/206 et `pdf/info` absents malgré le texte ci-dessous.
- `GET /api/file/{vault}/pdf/info` — métadonnées seules sans transférer le document (C3)
- Stream avec `Accept-Ranges` + 206 Partial Content (single range, suffix-range, 416) (C2)
- `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s via thread-pool) (B4/G3)
- Backend `backend/pdf_reader.py` (existant) — extraction pypdf + pymupdf (fallback), métadonnées, TOC
- `backend/indexer.py` — `.pdf` dans SUPPORTED_EXTENSIONS, extraction dans `index_document()`
- `backend/main.py` — flag `is_pdf: True` retourné par `api_file_view`, endpoint `GET /api/file/{vault}/pdf/stream` avec support Range/206
- `backend/search.py` — filtre `ext:pdf` (déjà implémenté avant cette PR)
- `frontend/js/viewer.js:451-480` — branche `if (data.is_pdf)` + iframe + toolbar + TOC + bouton download
- **Tests :** `tests/test_pdf.py` (26 tests verts) — text/metadata/TOC + indexation scan/incrémentale + filtre ext + stream 200/206/416 + /pdf/info + limite de taille
- **Bug fixé dans cette PR :** `PdfReader` NameError dans `pdf_reader.py` quand pymupdf est installé (la variable `PdfReader` n'était déclarée que dans la branche `except ImportError`)
- `backend/requirements-test.txt` (nouveau) — `reportlab` pour générer des PDFs de test
- **Sous-tâches :**
##### A. Backend — Extraction de texte PDF (1-1.5 jour)
- [ ] **A1. Dépendance** : Ajouter `pymupdf` (PyMuPDF/fitz) à `requirements.txt` — bibliothèque C performante avec extraction texte + métadonnées, déjà compatible avec l'image Docker (libs système GTK/Pango déjà présentes pour WeasyPrint). Alternative légère : `pypdf` (pure Python, pas de deps système) si pymupdf pose problème.
- [ ] **A2. Module `backend/pdf_reader.py`** : Créer un module dédié avec les fonctions :
- [x] **A1. Dépendance** : `pypdf>=4.0` retenu dans requirements (pure Python, simplicité Docker) ; PyMuPDF (`fitz`) utilisé automatiquement en priorité s'il est importable — l'inverse du plan initial, fonctionnellement équivalent.
- [x] **A2. Module `backend/pdf_reader.py`** : Créer un module dédié avec les fonctions :
- `extract_pdf_text(file_path: Path) -> str` : extrait tout le texte du PDF, page par page, avec séparateur `\f` entre pages. Gère les PDF encodés, protégés par mot de passe (retourne erreur explicite), et corrompus.
- `extract_pdf_metadata(file_path: Path) -> dict` : extrait titre, auteur, sujet, nombre de pages, taille.
- `extract_pdf_preview(file_path: Path, max_chars: int = 100000) -> str` : extrait les N premiers caractères pour l'indexation (limité par `SEARCH_CONTENT_LIMIT`).
- [ ] **A3. Fallback pypdf** : Si pymupdf non disponible (exception d'import), fallback automatique sur `pypdf` avec un log warning. Code structuré avec une interface abstraite (`PdfReader` protocol) pour swap transparent.
- [x] **A3. Fallback pypdf** : Si pymupdf non disponible (exception d'import), fallback automatique sur `pypdf` avec un log warning. Code structuré avec une interface abstraite (`PdfReader` protocol) pour swap transparent.
##### B. Backend — Indexation des PDF (1 jour)
- [ ] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.pdf` au set dans `backend/indexer.py:56`. Déclencher un rebuild complet de l'index (incrémental via le file watcher pour les nouveaux PDFs).
- [ ] **B2. Modification de `index_document()`** (`backend/indexer.py:524`) : Dans la fonction d'indexation, détecter l'extension `.pdf` et appeler `extract_pdf_text()` au lieu de `read_text()`. Le texte extrait alimente le pipeline TF-IDF existant — aucun changement nécessaire dans `search.py`.
- [ ] **B3. Métadonnées PDF dans le document info** : Enrichir la structure de retour de `index_document()` avec les champs spécifiques PDF : `page_count`, `pdf_title` (titre extrait des métadonnées, prioritaire sur le nom de fichier), `pdf_author`.
- [ ] **B4. Gestion d'erreur robuste** : PDF corrompu → log warning + skip (ne pas bloquer l'indexation). PDF volumineux (>50 Mo) → log info + extraction tronquée à `SEARCH_CONTENT_LIMIT`. Timeout d'extraction configurable (30s par défaut).
- [x] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.pdf` au set dans `backend/indexer.py:56`. Déclencher un rebuild complet de l'index (incrémental via le file watcher pour les nouveaux PDFs).
- [x] **B2. Lecture PDF dans les DEUX chemins d'indexation** (`_scan_vault` + `_index_single_file_sync`, utilisé par le watcher) : détection `.pdf` → `extract_pdf_text()`. Fix 2026-09 : seul le scan complet gérait les PDFs, l'incrémental indexait du garbage.
- [x] **B3. Métadonnées PDF (adapté)** : titre PDF prioritaire sur le nom de fichier dans l'index ; `pages`/`author` exposés via `api_file_view` + `/pdf/info` (non stockés dans l'entrée d'index).
- [x] **B4. Gestion d'erreur robuste** : PDF corrompu → log warning + skip (ne pas bloquer l'indexation). PDF volumineux (>50 Mo) → log info + extraction tronquée à `SEARCH_CONTENT_LIMIT`. Timeout d'extraction configurable (30s par défaut).
##### C. Backend — API endpoints PDF (0.5 jour)
- [ ] **C1. Modification de `api_file_view()`** (`backend/main.py:2270`) : Avant la tentative de `read_text()`, détecter `.pdf` par extension. Pour les PDF :
- [x] **C1. Modification de `api_file_view()`** (`backend/main.py:2270`) : Avant la tentative de `read_text()`, détecter `.pdf` par extension. Pour les PDF :
- Extraire le texte avec `extract_pdf_text()`
- Extraire les métadonnées (pages, auteur)
- Retourner une réponse structurée : `is_pdf: true`, `page_count`, `pdf_metadata`, `html` (aperçu texte formaté), `raw_length`
- Le champ `html` contient un rendu texte simple (pas de markdown) : texte paginé ou première page formatée
- [ ] **C2. Nouvel endpoint `GET /api/file/{vault}/pdf/stream`** : Sert le fichier PDF brut avec `Content-Type: application/pdf` et `Content-Disposition: inline` pour visualisation dans le navigateur. Supporte le `Range` header (HTTP 206 Partial Content) pour le streaming progressif des gros PDFs — essentiel pour la performance sur des documents volumineux.
- [ ] **C3. Nouvel endpoint `GET /api/file/{vault}/pdf/info`** : Retourne les métadonnées seules (pages, titre, auteur) sans le contenu — permet à l'UI d'afficher les infos avant de charger le PDF lourd.
- [ ] **C4. Endpoint download** : Déjà fonctionnel (`/api/file/{vault}/download`) — aucun changement nécessaire.
- [x] **C2. Nouvel endpoint `GET /api/file/{vault}/pdf/stream`** : Sert le fichier PDF brut avec `Content-Type: application/pdf` et `Content-Disposition: inline` pour visualisation dans le navigateur. Supporte le `Range` header (HTTP 206 Partial Content) pour le streaming progressif des gros PDFs — essentiel pour la performance sur des documents volumineux.
- [x] **C3. Nouvel endpoint `GET /api/file/{vault}/pdf/info`** : Retourne les métadonnées seules (pages, titre, auteur) sans le contenu — permet à l'UI d'afficher les infos avant de charger le PDF lourd.
- [x] **C4. Endpoint download** : Déjà fonctionnel (`/api/file/{vault}/download`) — aucun changement nécessaire.
##### D. Frontend — Arborescence de fichiers (0.5 jour)
- [ ] **D1. Icône et filtre** : L'icône PDF (`file-text` de Lucide) est déjà mappée dans `EXT_ICONS` (`frontend/js/utils.js:129`). Une fois `.pdf` dans `SUPPORTED_EXTENSIONS`, les PDFs apparaissent automatiquement dans l'arborescence via l'API `list_directory`. Aucun changement UI nécessaire.
- [ ] **D2. Distinction visuelle** (optionnel) : Sous-titre léger sous le nom du fichier dans l'arborescence indiquant le nombre de pages (ex: « 12 pages ») pour différencier rapidement les PDF des MD. Donnée disponible via l'API `pdf/info`.
- [ ] **D3. Drag & drop et upload** : Le mécanisme d'upload existant (`POST /api/file/{vault}/upload`) fonctionne déjà pour tout type de fichier. Vérifier que le MIME type `application/pdf` est correctement détecté et que le watcher réindexe automatiquement.
- [x] **D1. Icône et filtre** : L'icône PDF (`file-text` de Lucide) est déjà mappée dans `EXT_ICONS` (`frontend/js/utils.js:129`). Une fois `.pdf` dans `SUPPORTED_EXTENSIONS`, les PDFs apparaissent automatiquement dans l'arborescence via l'API `list_directory`. Aucun changement UI nécessaire.
- [ ] **D2. Distinction visuelle** — ⚪ NON RETENU : Sous-titre léger sous le nom du fichier dans l'arborescence indiquant le nombre de pages (ex: « 12 pages ») pour différencier rapidement les PDF des MD. Donnée disponible via l'API `pdf/info`.
- [x] **D3. Drag & drop et upload** : Le mécanisme d'upload existant (`POST /api/file/{vault}/upload`) fonctionne déjà pour tout type de fichier. Vérifier que le MIME type `application/pdf` est correctement détecté et que le watcher réindexe automatiquement.
##### E. Frontend — Viewer PDF (1 jour)
- [ ] **E1. Rendu inline natif** : Utiliser le visualiseur PDF intégré du navigateur via `<iframe>` pointant sur `/api/file/{vault}/pdf/stream?path=...`. Approche optimale :
- [x] **E1. Rendu inline natif** : Utiliser le visualiseur PDF intégré du navigateur via `<iframe>` pointant sur `/api/file/{vault}/pdf/stream?path=...`. Approche optimale :
- Zéro dépendance JS supplémentaire
- Rendu identique à Chrome/Firefox/Safari natif
- Support natif du zoom, recherche dans le document, navigation par pages, rotation
- L'iframe s'adapte en hauteur (`height: 100%` du content-area)
- [ ] **E2. Détection dans le viewer** : Dans `frontend/js/viewer.js`, fonction `renderFileContent()` — ajouter une branche après la détection `data.unsupported` :
- [x] **E2. Détection dans le viewer** : Dans `frontend/js/viewer.js`, fonction `renderFileContent()` — ajouter une branche après la détection `data.unsupported` :
- Si `data.is_pdf === true` → render l'iframe PDF au lieu du viewer markdown
- Si le navigateur ne supporte pas le rendu PDF inline → fallback sur l'UI « binaire » avec bouton download + bouton « Ouvrir dans un nouvel onglet »
- [ ] **E3. Barre d'outils PDF** : Dans la barre d'outils du viewer (celle qui a déjà les boutons Copier, Source, .md, PDF, Éditer, pop-out), pour les fichiers PDF :
- [x] **E3. Barre d'outils PDF** : Dans la barre d'outils du viewer (celle qui a déjà les boutons Copier, Source, .md, PDF, Éditer, pop-out), pour les fichiers PDF :
- Remplacer « Copier » / « Source » / « Éditer » par des actions spécifiques PDF
- Bouton « Télécharger » (.pdf) — déjà existant, fonctionne
- Bouton « Plein écran » — ouvre le PDF dans un nouvel onglet en plein écran
- Badge « N pages » indiquant le nombre de pages
- Bouton « pop-out » — gardé, ouvre le viewer PDF dans une popup séparée
- [ ] **E4. Thème** : L'iframe PDF est en dehors du DOM applicatif donc pas affecté par le thème dark/light. Ajouter un message discret « Le PDF s'affiche avec le thème de votre navigateur » si `_currentTheme === 'dark'` (les PDFs en fond blanc dans un thème sombre peuvent surprendre).
- [ ] **E5. Responsive** : L'iframe s'adapte à la largeur du content-area. En mode mobile, hauteur ajustée à la viewport. La toolbar mobile existante fonctionne avec les actions PDF.
- [ ] **E4. Thème** — ⚪ NON RETENU : L'iframe PDF est en dehors du DOM applicatif donc pas affecté par le thème dark/light. Ajouter un message discret « Le PDF s'affiche avec le thème de votre navigateur » si `_currentTheme === 'dark'` (les PDFs en fond blanc dans un thème sombre peuvent surprendre).
- [x] **E5. Responsive** : L'iframe s'adapte à la largeur du content-area. En mode mobile, hauteur ajustée à la viewport. La toolbar mobile existante fonctionne avec les actions PDF.
##### F. Frontend — Recherche (0.5 jour)
- [ ] **F1. Résultats de recherche** : Les PDFs apparaissent dans les résultats via le TF-IDF existant (le texte extrait est indexé). Ajouter un badge visuel « PDF » à côté du titre dans les résultats de recherche pour distinguer les PDFs des MD — utiliser l'icône `file-text`.
- [ ] **F2. Snippets de recherche** : Les extraits de contexte montrent le texte extrait du PDF avec surlignage des termes recherchés — fonctionnement identique aux MD via le mécanisme de snippet existant dans `search.py`.
- [ ] **F3. Filtres de recherche avancés** : Ajouter `ext:pdf` comme filtre pour limiter la recherche aux PDFs uniquement (complément aux filtres `created:`, `modified:`, `size:` déjà prévus #34).
- [x] **F1. Résultats de recherche** : Les PDFs apparaissent dans les résultats via le TF-IDF existant (le texte extrait est indexé). Ajouter un badge visuel « PDF » à côté du titre dans les résultats de recherche pour distinguer les PDFs des MD — utiliser l'icône `file-text`.
- [x] **F2. Snippets de recherche** : Les extraits de contexte montrent le texte extrait du PDF avec surlignage des termes recherchés — fonctionnement identique aux MD via le mécanisme de snippet existant dans `search.py`.
- [x] **F3. Filtres de recherche avancés** : Ajouter `ext:pdf` comme filtre pour limiter la recherche aux PDFs uniquement (complément aux filtres `created:`, `modified:`, `size:` déjà prévus #34).
##### G. Docker & Dépendances (0.5 jour)
- [ ] **G1. requirements.txt** : Ajouter `pymupdf>=1.24.0` (sinon `pypdf>=4.0` en fallback).
- [ ] **G2. Dockerfile** : Vérifier que l'image `python:3.11-slim` dispose des libs système nécessaires pour pymupdf. Si besoin, ajouter `libmupdf-dev` ou utiliser `pypdf` (pure Python) pour éviter la complexité. Recommandation : pypdf pour la simplicité Docker, pymupdf en option pour la performance.
- [ ] **G3. Configuration** : Ajouter `OBSIGATE_PDF_MAX_SIZE_MB` (défaut 50) pour limiter la taille des PDFs indexés et `OBSIGATE_PDF_EXTRACT_TIMEOUT` (défaut 30s).
- [x] **G1. requirements.txt** : `pypdf>=4.0` retenu (pymupdf optionnel, utilisé s'il est importable).
- [x] **G2. Dockerfile** : Vérifier que l'image `python:3.11-slim` dispose des libs système nécessaires pour pymupdf. Si besoin, ajouter `libmupdf-dev` ou utiliser `pypdf` (pure Python) pour éviter la complexité. Recommandation : pypdf pour la simplicité Docker, pymupdf en option pour la performance.
- [x] **G3. Configuration** : `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s) — documentés dans `.env.example` et README FR/EN.
##### H. Tests (1 jour)
- [ ] **H1. Tests unitaires backend** :
- [x] **H1. Tests unitaires backend** :
- `test_pdf_reader.py` : extraction texte PDF simple, PDF vide, PDF avec uniquement des images (OCR non requis — retourne chaîne vide), PDF protégé par mot de passe, PDF corrompu, extraction métadonnées
- Fixtures : créer un PDF de test minimal (2 pages, texte simple) via `reportlab` dans les fixtures de test
- `test_pdf_indexing.py` : vérifier qu'un PDF dans un vault est correctement indexé, que le texte est recherchable, que `index_document()` gère l'extension `.pdf`
- `test_pdf_api.py` : endpoint `view` retourne `is_pdf: true`, endpoint `pdf/stream` retourne `application/pdf`, endpoint `pdf/info` retourne les métadonnées
- [ ] **H2. Tests frontend** :
- [ ] **H2. Tests frontend** — ⚪ NON RETENU :
- Test d'intégration : naviguer vers un fichier PDF → l'iframe est rendue
- Test : fichier PDF dans les résultats de recherche
- Test : téléchargement de PDF fonctionnel
- [ ] **H3. CI** : Ajouter la fixture PDF de test dans les artefacts de CI. Les tests PDF sont sautés si pymupdf/pypdf n'est pas disponible.
- [x] **H3. CI** : Ajouter la fixture PDF de test dans les artefacts de CI. Les tests PDF sont sautés si pymupdf/pypdf n'est pas disponible.
##### I. Documentation utilisateur (inclus dans l'effort)
- [ ] **I1.** Mettre à jour README.md : mentionner le support PDF dans les formats supportés
- [ ] **I2.** Ajouter une note dans la FAQ : « Comment visualiser un PDF dans ObsiGate ? »
- [ ] **I3.** Documenter les limitations : pas d'OCR (PDFs scannés non recherchables), pas d'annotation PDF, pas d'édition de PDF
- [x] **I1.** Mettre à jour README.md : mentionner le support PDF dans les formats supportés
- [ ] **I2.** Ajouter une note dans la FAQ : « Comment visualiser un PDF dans ObsiGate ? » — ⚪ NON RETENU (README + guide couvrent déjà le support PDF)
- [x] **I3.** Documenter les limitations : pas d'OCR (PDFs scannés non recherchables), pas d'annotation PDF, pas d'édition de PDF
##### J. Points d'attention / Risques
- **Performance** : Un PDF de 500 pages peut générer beaucoup de texte → `SEARCH_CONTENT_LIMIT` (100 Ko) limite l'indexation au début du document. Pour les PDFs volumineux, envisager une extraction paginée avec `SEARCH_CONTENT_LIMIT` réparti sur les N premières pages.
@@ -336,9 +351,9 @@
---
### 75. Éditeur multi-panneaux (Split View) ✅ Complété
### 75. Éditeur multi-panneaux (Split View) — ✅ TERMINÉ (E2E matriciels inclus)
- **Effort :** 5-7 jours (réalisé) | **Impact :** 🟡
- **Statut :** Fonctionnel — toutes les sous-tâches implémentées (reste tests I2/I3)
- **Statut :** Fonctionnel — 100% implémenté + 37 tests E2E (16 split-view.spec.js + 21 split-view-matrix.spec.js, 2026-09-08)
- **Fichiers clés :** `frontend/js/pane-manager.js` (1082 loc), `frontend/js/viewer.js` (modifié), `frontend/js/ui.js` (modifié), `frontend/js/dashboard.js` (modifié), `frontend/js/palette.js` (modifié), `frontend/style.css` (modifié), `tests/test_pane_manager.py` (22 tests)
- **Description :** Système de panneaux divisés permettant d'afficher plusieurs documents côte à côte ou superposés, à la manière d'un éditeur de code (VS Code). Drag & drop des onglets entre les panneaux, drag-to-split, menu contextuel, raccourcis clavier, palette de commandes.
- **Architecture :**
@@ -397,22 +412,43 @@
##### ✅ I. Tests
- [x] **I1.** 22 tests statiques (structure, CSS, brace balance, régression backend)
- [x] **I4.** 307 tests de régression passent
- [ ] **I2.** Tests d'intégration frontend (JS DOM)
- [ ] **I3.** Tests E2E Playwright (#58)
- [x] **I2.** Tests d'intégration frontend (JSDOM) — `tests/frontend/pane-manager.test.mjs` (9 tests)
- [x] **I3.** Tests E2E Playwright (#58) — 2026-09 : `split-view.spec.js` (16) + `split-view-matrix.spec.js` (21) = 37 tests verts en local + CI
##### Reste à faire (0)
##### Reste à faire
- [x] **C1b.** Sous-menu « Déplacer vers... » dans le menu contextuel des onglets
- [x] **D2.** Drag & drop depuis l'arborescence vers un panneau spécifique
- [x] **E3.** Rendu paresseux (mémoire) — contenu masqué via `display:none` sur panneaux inactifs
- [x] **E4.** Verrouillage éditeur multi-panneau — même fichier ouvert dans 2 panneaux → focus le panneau existant
- [x] **G3.** Bouton reset dans la palette de commandes (🔄 Réinitialiser les panneaux)
- [ ] **I2.** Tests d'intégration frontend (JSDOM ou similaire)
- [ ] **I3.** Tests E2E Playwright
- [x] **I2.** Tests d'intégration frontend (JSDOM) — `tests/frontend/pane-manager.test.mjs` (9 tests, 100% verts)
- [x] **I3.** Tests E2E Playwright — matrice complète ouverture/fermeture couverte (voir split-view-matrix.spec.js)
##### Matrice de couverture ouverture/fermeture (validée 2026-09-08, 37/37 verts)
| Cas | Surface testée | Spec |
|---|---|---|
| Ouverture fichier → onglet | clic arbre, double-clic preview vs persistant | split-view |
| Split droit | raccourci Ctrl+Alt+\ · bouton ⊞→ · menu contextuel · palette · drag-to-split bord droit | les 5 |
| Split bas | raccourci Ctrl+Alt+Shift+\ · bouton ⊞↓ · menu contextuel · drag-to-split bord bas | les 4 |
| Déplacer onglet | drag cross-pane · flyout « Déplacer vers... » · drag depuis arbre vers panneau | les 3 |
| Navigation | Ctrl+Alt+←/→ (horizontal) · Ctrl+Alt+↑/↓ (vertical) · palette suivant/précédent | les 3 |
| Fermeture onglet | bouton X · double-clic · clic molette · Ctrl+W (single) | les 4 |
| Fermeture menu ctx | Fermer · Fermer les autres · Fermer à droite · Fermer tout → dashboard | les 4 |
| Fermeture panneau | Ctrl+Alt+W (clavier) · menu « Fermer le panneau » · Ctrl+Shift+Alt+W (autres) · auto-collapse panneau vidé (C4) · fermeture pane 0 → voisin survit · onglets du panneau fermé perdus (documenté) | les 5 |
| Persistance | layout restauré au reload · reset palette vide localStorage | les 2 |
| Edge | plafond 4 panneaux (PANE_MAX) · pas de doublon à la réouverture (E4) | les 2 |
Non couvert par E2E (hors périmètre ouverture/fermeture) : poignée de redimensionnement drag/dblclick-reset (A3) — logique vérifiée par les 22 tests statiques + JSDOM.
---
### 76. BooksLM — Console AI contextuelle par répertoire (style NotebookLM)
- **Effort :** 5-6 jours | **Impact :** 🟡
### 76. BooksLM — Console AI contextuelle par répertoire (style NotebookLM) — ✅ TERMINÉ
> **Extension (2026-09-11) :** l'assistant AI est désormais **multi-contexte** — Répertoire
> (mode historique), Documents (documents ouverts, via le bouton flottant) et Général (aucun
> document ouvert : aide sur l'app + création de fichiers/dossiers par cartes d'action). Header
> refondu (sélecteur fournisseur/modèle sur une ligne dédiée), `Entrée` envoie / `Ctrl+Entrée`
> insère un saut de ligne. Voir CHANGELOG.
- **Effort :** 5-6 jours (réalisé) | **Impact :** 🟡
- **Description :** Console de chat AI contextuelle accessible via le menu contextuel des répertoires dans l'arborescence. Au clic sur « BooksLM », un panneau de chat s'ouvre à droite du viewer et indexe automatiquement toutes les ressources markdown (et PDF via #74) du répertoire courant et de ses sous-répertoires récursivement comme contexte pour un assistant AI. L'assistant peut répondre à des questions, résumer, synthétiser, et croiser l'information à travers tous les documents du scope — exactement comme NotebookLM de Google, mais pour n'importe quel répertoire de votre vault Obsidian.
- **Fonctionnement général :**
- L'utilisateur fait un clic-droit sur un répertoire dans l'arborescence → option « BooksLM »
@@ -423,65 +459,65 @@
- L'historique de chat est optionnellement sauvegardé (localStorage ou fichier `.books-lm.json` dans le répertoire)
- **Sous-tâches :**
##### A. Backend — Collecte et préparation du contexte (1.5-2 jours)
- [ ] **A1. Nouvel endpoint `POST /api/ai/bookslm/context`** : Reçoit `{vault, directory}` → parcourt récursivement le répertoire → lit tous les fichiers supportés → retourne un objet `{files: [{path, title, content, type: "md"|"pdf"}], total_chars, file_count, directory_tree}`
- [ ] **A2. Limites configurables** : `BOOKSLM_MAX_FILES` (défaut 200), `BOOKSLM_MAX_TOTAL_CHARS` (défaut 200 000), `BOOKSLM_MAX_FILE_CHARS` (défaut 30 000 par fichier). Les fichiers au-delà sont tronqués avec un message `[... continue dans le fichier]`.
- [ ] **A3. Filtrage intelligent** : Ignorer les fichiers cachés (`.` préfixe), les dossiers `_attachments/`, les fichiers binaires non-supportés. Respecter `.gitignore` ou `.obsigate-ignore` si présent.
- [ ] **A4. Streaming du contexte** : Pour les très gros répertoires, l'endpoint supporte le streaming SSE pour informer l'UI de la progression (« Indexation de 45/127 fichiers... »).
##### A. Backend — Collecte et préparation du contexte (1.5-2 jours) — ✅ livré (backend/bookslm.py, bookslm_routes.py)
- [x] **A1. Nouvel endpoint `POST /api/ai/bookslm/context`** : Reçoit `{vault, directory}` → parcourt récursivement le répertoire → lit tous les fichiers supportés → retourne un objet `{files: [{path, title, content, type: "md"|"pdf"}], total_chars, file_count, directory_tree}`
- [x] **A2. Limites configurables** : `BOOKSLM_MAX_FILES` (défaut 200), `BOOKSLM_MAX_TOTAL_CHARS` (défaut 200 000), `BOOKSLM_MAX_FILE_CHARS` (défaut 30 000 par fichier). Les fichiers au-delà sont tronqués avec un message `[... continue dans le fichier]`.
- [x] **A3. Filtrage intelligent** : Ignorer les fichiers cachés (`.` préfixe), les dossiers `_attachments/`, les fichiers binaires non-supportés. Respecter `.gitignore` ou `.obsigate-ignore` si présent.
- [ ] **A4. Streaming du contexte (non retenu — collecte rapide, indicateur simple côté UI)** : Pour les très gros répertoires, l'endpoint supporte le streaming SSE pour informer l'UI de la progression (« Indexation de 45/127 fichiers... »).
##### B. Backend — Endpoint chat BooksLM (1 jour)
- [ ] **B1. Endpoint `POST /api/ai/bookslm/chat`** : Reçoit `{vault, directory, message, conversation_history: [{role, content}]}` → construit le contexte système à partir des fichiers du répertoire → appelle le provider AI configuré → stream la réponse via SSE.
- [ ] **B2. Prompt système** : Template par défaut optimisé : « Tu es un assistant de recherche qui aide à comprendre et analyser les documents d'un répertoire. Voici le contenu de tous les documents disponibles. Réponds en te basant UNIQUEMENT sur ces documents. Cite tes sources avec le nom du fichier. Si l'information n'est pas dans les documents, dis-le clairement. »
- [ ] **B3. Mode « Sources »** : Chaque réponse inclut les fichiers référencés (détectés via mention de titre ou contenu). L'UI affiche des badges de source cliquables.
- [ ] **B4. Mise en cache du contexte** : Le contexte du répertoire est caché en mémoire (hash du contenu) pour éviter de re-parser tous les fichiers à chaque message. Invalidé si un fichier est modifié (watcher).
- [ ] **B5. Provider** : Utilise la même abstraction provider que l'AI Editor (#27) — DeepSeek, OpenRouter, Gemini. Ajouter `BOOKSLM_DEFAULT_MODEL` dans `.env` (défaut : `DEEPSEEK_MODEL`).
##### B. Backend — Endpoint chat BooksLM (1 jour) — ✅ livré
- [x] **B1. Endpoint `POST /api/ai/bookslm/chat`** : Reçoit `{vault, directory, message, conversation_history: [{role, content}]}` → construit le contexte système à partir des fichiers du répertoire → appelle le provider AI configuré → stream la réponse via SSE.
- [x] **B2. Prompt système** : Template par défaut optimisé : « Tu es un assistant de recherche qui aide à comprendre et analyser les documents d'un répertoire. Voici le contenu de tous les documents disponibles. Réponds en te basant UNIQUEMENT sur ces documents. Cite tes sources avec le nom du fichier. Si l'information n'est pas dans les documents, dis-le clairement. »
- [x] **B3. Mode « Sources »** : Chaque réponse inclut les fichiers référencés (détectés via mention de titre ou contenu). L'UI affiche des badges de source cliquables.
- [x] **B4. Mise en cache du contexte** : Le contexte du répertoire est caché en mémoire (hash du contenu) pour éviter de re-parser tous les fichiers à chaque message. Invalidé si un fichier est modifié (watcher).
- [x] **B5. Provider** : Utilise la même abstraction provider que l'AI Editor (#27) — DeepSeek, OpenRouter, Gemini. Ajouter `BOOKSLM_DEFAULT_MODEL` dans `.env` (défaut : `DEEPSEEK_MODEL`).
##### C. Frontend — Panneau de chat BooksLM (2 jours)
- [ ] **C1. Module `frontend/js/bookslm.js`** : Nouveau module ES avec la classe `BooksLM` :
##### C. Frontend — Panneau de chat BooksLM (2 jours) — ✅ livré (frontend/js/bookslm.js)
- [x] **C1. Module `frontend/js/bookslm.js`** : Nouveau module ES avec la classe `BooksLM` :
- Gère l'état : `_isOpen`, `_currentDirectory`, `_messages[]`, `_contextFiles[]`, `_isLoading`
- Crée le DOM du panneau : conteneur latéral `.bookslm-panel` (450px, redimensionnable via poignée)
- Header : titre « BooksLM », nom du répertoire courant, bouton fermer, bouton « Nouvelle conversation »
- Zone de messages : scrollable, bulles utilisateur (droite) et assistant (gauche) avec Markdown rendu
- Zone d'entrée : `textarea` avec Ctrl+Enter pour envoyer, bouton envoyer
- Barre d'état : nombre de fichiers indexés, nombre total de caractères
- [ ] **C2. Intégration au menu contextuel** : Dans `frontend/js/context-menu.js`, ajouter l'option « 🧠 BooksLM » pour les nœuds de type `directory` dans l'arborescence. Visible seulement si le vault est accessible.
- [ ] **C3. Rendu Markdown dans le chat** : Utiliser le renderer Markdown existant (ou un sous-ensemble simplifié) pour afficher les réponses de l'AI avec support du **gras**, *italique*, `code`, listes, et tableaux.
- [ ] **C4. Streaming des réponses** : Connexion SSE pour afficher la réponse de l'AI token par token (effet « typing » naturel).
- [ ] **C5. Badges de sources** : Après chaque réponse, afficher les fichiers sources mentionnés sous forme de badges cliquables qui ouvrent le fichier dans le viewer principal.
- [ ] **C6. Mode plein écran** : Bouton pour basculer en mode plein écran (cache la sidebar, le panneau prend tout l'espace). Utile pour les sessions de recherche intense.
- [x] **C2. Intégration au menu contextuel** : Dans `frontend/js/context-menu.js`, ajouter l'option « 🧠 BooksLM » pour les nœuds de type `directory` dans l'arborescence. Visible seulement si le vault est accessible.
- [x] **C3. Rendu Markdown dans le chat** : Utiliser le renderer Markdown existant (ou un sous-ensemble simplifié) pour afficher les réponses de l'AI avec support du **gras**, *italique*, `code`, listes, et tableaux.
- [x] **C4. Streaming des réponses** : Connexion SSE pour afficher la réponse de l'AI token par token (effet « typing » naturel).
- [x] **C5. Badges de sources** : Après chaque réponse, afficher les fichiers sources mentionnés sous forme de badges cliquables qui ouvrent le fichier dans le viewer principal.
- [x] **C6. Mode plein écran** : Bouton pour basculer en mode plein écran (cache la sidebar, le panneau prend tout l'espace). Utile pour les sessions de recherche intense.
##### D. Frontend — Actions et UX (0.5-1 jour)
- [ ] **D1. Copier la réponse** : Bouton copie sur chaque message assistant.
- [ ] **D2. Régénérer** : Bouton pour régénérer la dernière réponse (utile si la réponse est hors-sujet).
- [ ] **D3. Exporter la conversation** : Bouton pour exporter l'historique en Markdown → sauvegarder comme note dans le répertoire courant.
- [ ] **D4. Historique des conversations** : Stockage dans `localStorage` par clé `bookslm-history-{vault}-{directory}`. Liste déroulante dans le header pour charger une conversation précédente.
- [ ] **D5. Indicateur de contexte** : Barre de progression montrant l'utilisation du contexte (% de la limite `BOOKSLM_MAX_TOTAL_CHARS`). Si le répertoire est trop gros, suggérer de réduire le scope.
- [ ] **D6. Suggestions de questions** : Après l'indexation, afficher 3 questions suggérées basées sur les titres et métadonnées des fichiers (« Résume ce répertoire », « Quels sont les thèmes principaux ? », « Y a-t-il des contradictions entre ces documents ? »).
##### D. Frontend — Actions et UX (0.5-1 jour) — ✅ livré (D5 partiel)
- [x] **D1. Copier la réponse** : Bouton copie sur chaque message assistant.
- [x] **D2. Régénérer** : Bouton pour régénérer la dernière réponse (utile si la réponse est hors-sujet).
- [x] **D3. Exporter la conversation** : Bouton pour exporter l'historique en Markdown → sauvegarder comme note dans le répertoire courant.
- [x] **D4. Historique des conversations** : Stockage dans `localStorage` par clé `bookslm-history-{vault}-{directory}`. Liste déroulante dans le header pour charger une conversation précédente.
- [x] **D5. Indicateur de contexte (barre de progression %) — non retenu, compteur fichiers/caractères affiché)** : Barre de progression montrant l'utilisation du contexte (% de la limite `BOOKSLM_MAX_TOTAL_CHARS`). Si le répertoire est trop gros, suggérer de réduire le scope.
- [x] **D6. Suggestions de questions** : Après l'indexation, afficher 3 questions suggérées basées sur les titres et métadonnées des fichiers (« Résume ce répertoire », « Quels sont les thèmes principaux ? », « Y a-t-il des contradictions entre ces documents ? »).
##### E. CSS & Design (0.5 jour)
- [ ] **E1. Panneau latéral** : Animation slide-in depuis la droite (300ms ease-out). Ombre portée pour séparation visuelle.
- [ ] **E2. Poignée de redimensionnement** : Similaire à `.sidebar-resize-handle`, curseur `col-resize`, largeur min 350px, max 800px. Persistance dans localStorage.
- [ ] **E3. Bulles de chat** : Style cohérent avec le thème actuel. Messages utilisateur avec accent-color, messages assistant avec fond `var(--surface2)`.
- [ ] **E4. Responsive** : Sur mobile (<768px), le panneau passe en plein écran (pas de split view). Navigation par swipe pour revenir au viewer.
- [ ] **E5. Thème sombre/clair** : Toutes les variables CSS utilisent les customs properties existantes → compatibilité automatique.
##### E. CSS & Design (0.5 jour) — ✅ livré
- [x] **E1. Panneau latéral** : Animation slide-in depuis la droite (300ms ease-out). Ombre portée pour séparation visuelle.
- [x] **E2. Poignée de redimensionnement** : Similaire à `.sidebar-resize-handle`, curseur `col-resize`, largeur min 350px, max 800px. Persistance dans localStorage.
- [x] **E3. Bulles de chat** : Style cohérent avec le thème actuel. Messages utilisateur avec accent-color, messages assistant avec fond `var(--surface2)`.
- [x] **E4. Responsive** : Sur mobile (<768px), le panneau passe en plein écran (pas de split view). Navigation par swipe pour revenir au viewer.
- [x] **E5. Thème sombre/clair** : Toutes les variables CSS utilisent les customs properties existantes → compatibilité automatique.
##### F. Intégration et compatibilité (0.5 jour)
- [ ] **F1. Compatibilité Split View (#75)** : Si le split view est actif, BooksLM s'ouvre en remplacement du panneau le plus à droite (ou en 3e colonne). Le panneau BooksLM est traité comme un type spécial de pane dans le PaneManager.
- [ ] **F2. Compatibilité AI Editor (#26-29)** : BooksLM utilise le même système de provider AI. Les clés API configurées pour l'AI Editor fonctionnent pour BooksLM.
- [ ] **F3. Compatibilité PDF (#74)** : Si le support PDF est implémenté, les PDFs dans le répertoire sont inclus dans le contexte (texte extrait).
- [ ] **F4. Palette de commandes (#31)** : Ajouter les commandes « BooksLM: Ouvrir pour le répertoire courant » et « BooksLM: Nouvelle conversation ».
##### F. Intégration et compatibilité (0.5 jour) — ✅ livré (F1 adapté : panneau dédié, pas un pane du PaneManager)
- [ ] **F1. Compatibilité Split View (#75) (adapté : panneau latéral indépendant, cohabite avec le split view)** : Si le split view est actif, BooksLM s'ouvre en remplacement du panneau le plus à droite (ou en 3e colonne). Le panneau BooksLM est traité comme un type spécial de pane dans le PaneManager.
- [x] **F2. Compatibilité AI Editor (#26-29)** : BooksLM utilise le même système de provider AI. Les clés API configurées pour l'AI Editor fonctionnent pour BooksLM.
- [x] **F3. Compatibilité PDF (#74)** : Si le support PDF est implémenté, les PDFs dans le répertoire sont inclus dans le contexte (texte extrait).
- [x] **F4. Palette de commandes (#31)** : Ajouter les commandes « BooksLM: Ouvrir pour le répertoire courant » et « BooksLM: Nouvelle conversation ».
##### G. Tests (1 jour)
- [ ] **G1. Tests unitaires backend** :
##### G. Tests (1 jour) — ✅ G1 livré (28 tests), G2/G3 non retenus
- [x] **G1. Tests unitaires backend** :
- `test_bookslm_context.py` : collecte récursive, respect des limites, filtrage fichiers cachés, streaming SSE
- `test_bookslm_chat.py` : construction du prompt, caching du contexte, invalidation après modification
- [ ] **G2. Tests d'intégration frontend** :
- [ ] **G2. Tests d'intégration frontend (non retenus)** :
- Ouverture du panneau BooksLM depuis le menu contextuel
- Envoi d'un message et affichage de la réponse
- Badges de sources cliquables
- Export de conversation
- Redimensionnement du panneau
- [ ] **G3. Tests E2E (Playwright, #58)** :
- [ ] **G3. Tests E2E (Playwright) (non retenus à ce jour)** :
- Test : clic-droit sur répertoire → BooksLM → panneau visible
- Test : chat fonctionnel → message envoyé → réponse reçue
- Test : fermeture et réouverture → historique restauré
@@ -498,7 +534,7 @@
### 78. Éditeur Excalidraw — Ouverture et édition de fichiers .excalidraw
- **Effort :** 3-4 jours | **Impact :** 🟡 | **Statut :** ⚪ Prévu
- **Effort :** 3-4 jours | **Impact :** 🟡 | **Statut :** ✅ TERMINÉ (2026-09-10 — éditeur iframe complet, détection, création, autosave, support `.excalidraw.md`, B5 extraction texte pour la recherche, C8 création via menu contextuel, F3 E2E `tests/e2e/excalidraw.spec.js`, doc H1-H3. F2 non retenu. BUG-002 corrigé)
- **Description :** Prise en charge native des fichiers `.excalidraw` dans ObsiGate avec un éditeur visuel complet intégré. L'utilisateur peut ouvrir un fichier `.excalidraw` depuis l'arborescence et obtenir l'éditeur de diagrammes Excalidraw directement dans ObsiGate — dessiner, modifier, sauvegarder, comme dans l'app Excalidraw standalone, mais intégré au flux de travail du vault Obsidian.
@@ -532,8 +568,8 @@
- **Sous-tâches :**
##### A. Fichier `frontend/excalidraw-editor.html` — Éditeur autonome (1.5 jour)
- [ ] **A1. Structure HTML** : Page minimale avec un `<div id="excalidraw-container">` en plein écran. Pas de header ObsiGate — tout l'espace est pour le canvas.
- [ ] **A2. Import Excalidraw** :
- [x] **A1. Structure HTML** : Page minimale avec un `<div id="excalidraw-container">` en plein écran. Pas de header ObsiGate — tout l'espace est pour le canvas.
- [x] **A2. Import Excalidraw** :
```html
<script type="module">
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/excalidraw@0.18.0";
@@ -541,8 +577,8 @@
</script>
```
Version épinglée (`@0.18.0`) pour la stabilité. Mise à jour manuelle testée.
- [ ] **A3. Configuration du chemin d'assets** : Définir `window.EXCALIDRAW_ASSET_PATH` pour pointer vers le CDN des fonts/polices d'Excalidraw (nécessaire pour le rendu des polices handwriting).
- [ ] **A4. Initialisation React** : Excalidraw nécessite React + ReactDOM. Les importer depuis esm.sh également :
- [x] **A3. Configuration du chemin d'assets** : Définir `window.EXCALIDRAW_ASSET_PATH` pour pointer vers le CDN des fonts/polices d'Excalidraw (nécessaire pour le rendu des polices handwriting).
- [x] **A4. Initialisation React** (import map esm.sh → React 18.3.1 épinglé) : Excalidraw nécessite React + ReactDOM. Les importer depuis esm.sh également :
```html
<script type="module">
import React from "https://esm.sh/react@18";
@@ -551,37 +587,37 @@
window.ReactDOM = ReactDOM;
</script>
```
- [ ] **A5. Rendu du composant** : Monter `<ExcalidrawLib.Excalidraw>` dans le conteneur avec les `initialData` reçues. Configurer les callbacks `onChange` pour détecter les modifications.
- [ ] **A6. Barre d'outils minimaliste** (dans l'iframe, superposée en haut à droite) :
- [x] **A5. Rendu du composant** : Monter `<ExcalidrawLib.Excalidraw>` dans le conteneur avec les `initialData` reçues. Configurer les callbacks `onChange` pour détecter les modifications.
- [x] **A6. Barre d'outils minimaliste** (dans l'iframe, superposée en haut à droite) :
- Bouton « 💾 Sauvegarder » → envoie les données au parent
- Badge « Modifié » (disparaît après sauvegarde)
- Indicateur de thème 🌙/☀️
- Optionnel : bouton « Export PNG » et « Export SVG » (natif Excalidraw)
- [ ] **A7. Communication postMessage** :
- [x] **A7. Communication postMessage** :
- Réception : écouter `message` → si `type === "init"`, charger `data.elements` + `data.appState` + `data.files` dans l'état Excalidraw. Si `type === "theme"`, basculer `theme` (dark/light).
- Émission : `postMessage({type: "save", data: {elements, appState, files}}, "*")` quand l'utilisateur sauvegarde.
- Émission : `postMessage({type: "ready"}, "*")` au chargement pour signaler que l'iframe est prête.
- Émission : `postMessage({type: "modified", dirty: true/false}, "*")` pour l'indicateur de modification.
- [ ] **A8. Gestion des erreurs** : Si les données sont invalides (JSON corrompu, pas un fichier Excalidraw), afficher un message d'erreur stylisé dans l'iframe.
- [x] **A8. Gestion des erreurs** : Si les données sont invalides (JSON corrompu, pas un fichier Excalidraw), afficher un message d'erreur stylisé dans l'iframe.
##### B. Backend — Détection et API (0.5 jour)
- [ ] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés.
- [ ] **B2. Icône** : Ajouter `.excalidraw` dans `EXT_ICONS` (`frontend/js/utils.js`) → icône `pen-tool` ou `edit-3` (Lucide).
- [ ] **B3. Détection dans `api_file_view()`** : Dans `backend/main.py`, pour les fichiers `.excalidraw` :
- [x] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés.
- [x] **B2. Icône** : Ajouter `.excalidraw` dans `EXT_ICONS` (`frontend/js/utils.js`) → icône `pen-tool` ou `edit-3` (Lucide).
- [x] **B3. Détection dans `api_file_view()`** : Dans `backend/main.py`, pour les fichiers `.excalidraw` :
- Lire le JSON
- Vérifier `data.get("type") === "excalidraw"`
- Retourner `is_excalidraw: true` + les données parsées (`elements`, `appState`, `files`)
- Si le JSON est invalide ou n'est pas un fichier Excalidraw valide → fallback sur le viewer JSON standard
- [ ] **B4. Endpoint de sauvegarde** : Le endpoint existant `PUT /api/file/{vault}` fonctionne déjà pour écrire du contenu. L'iframe envoie le JSON modifié via postMessage → le parent appelle l'API existante. Aucun nouvel endpoint nécessaire.
- [ ] **B5. Indexation du contenu texte** : Extraire le texte des éléments Excalidraw (`element.text` pour les éléments de type `text`) pour l'indexation TF-IDF. Permet de rechercher du texte présent dans les diagrammes.
- [ ] **B6. Contenu initial pour nouveaux fichiers** : Définir le squelette JSON minimum pour un fichier `.excalidraw` vide :
- [x] **B4. Endpoint de sauvegarde** : Le endpoint existant `PUT /api/file/{vault}` fonctionne déjà pour écrire du contenu. L'iframe envoie le JSON modifié via postMessage → le parent appelle l'API existante. Aucun nouvel endpoint nécessaire.
- [x] **B5. Indexation du contenu texte** (FAIT 2026-09) : `extract_excalidraw_indexable()` extrait `element.text` des éléments (JSON pur et `.excalidraw.md` compressé) → recherche TF-IDF fonctionnelle.
- [x] **B6. Contenu initial pour nouveaux fichiers** : Définir le squelette JSON minimum pour un fichier `.excalidraw` vide :
```json
{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}
```
Ce squelette est retourné par le backend quand on crée un fichier `.excalidraw` (utilisé par `POST /api/file/{vault}`).
##### C. Frontend — Intégration dans le viewer (1 jour)
- [ ] **C1. Module `frontend/js/excalidraw-viewer.js`** (nouveau) : Fonction `renderExcalidraw(container, data, vault, path)` :
- [x] **C1. Module `frontend/js/excalidraw-viewer.js`** (nouveau) : Fonction `renderExcalidraw(container, data, vault, path)` :
- Crée une `<iframe>` avec `src="/frontend/excalidraw-editor.html"` et `sandbox="allow-scripts allow-same-origin"`
- Stocke une référence à l'iframe pour la communication
- Attend le message `ready` de l'iframe
@@ -589,46 +625,46 @@
- Écoute les messages `save` → appelle `saveFile(vault, path, JSON.stringify(data))` via l'API existante
- Écoute les messages `modified` → met à jour l'indicateur dans la barre d'onglets
- Gère le thème : écoute `themeChanged` → envoie `postMessage({type: "theme", theme})` à l'iframe
- [ ] **C2. Dispatch dans `viewer.js`** : Dans `renderFileContent()` ou `renderFile()` :
- [x] **C2. Dispatch dans `viewer.js`** : Dans `renderFileContent()` ou `renderFile()` :
- Après la détection `data.is_json`, ajouter une branche : si `data.is_excalidraw === true` → appeler `renderExcalidraw(container, data, vaultName, filePath)`
- Ne PAS passer par le viewer markdown standard
- [ ] **C3. Barre d'outils contextuelle** : Dans la toolbar du viewer (celle avec Copier/Source/Éditer/PDF/pop-out) :
- [x] **C3. Barre d'outils contextuelle** : Dans la toolbar du viewer (celle avec Copier/Source/Éditer/PDF/pop-out) :
- Pour les fichiers `.excalidraw` : remplacer « Éditer (Forge) » par « Ouvrir dans Excalidraw.com » (lien externe, nouvel onglet)
- Garder « Télécharger » (.excalidraw) et « pop-out »
- Badge « Excalidraw » avec icône `pen-tool`
- [ ] **C4. Auto-save** : Débounce 2 secondes après la dernière modification dans l'iframe → sauvegarde automatique silencieuse (comme l'éditeur markdown #29). L'iframe émet `modified` → le parent démarre un timer → au bout de 2s sans nouvelle modification → `postMessage({type: "requestSave"})` → l'iframe répond avec `save` → le parent écrit via l'API.
- [ ] **C5. Raccourci Ctrl+S** : L'iframe intercepte Ctrl+S → envoie `save` au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »).
- [ ] **C6. Compatibilité Split View (#75)** : L'iframe s'affiche dans le content-area du panneau actif. Le `PaneTabManager` gère le cache : quand on switch d'onglet, l'état de l'iframe est préservé (elle reste dans le DOM, juste masquée). Plusieurs iframes Excalidraw peuvent coexister dans différents panneaux.
- [ ] **C7. Création via la modale « Nouveau fichier »** : Dans `frontend/js/ui.js`, fonction `showCreateFileModal()` :
- [x] **C4. Auto-save** : Débounce 2 secondes après la dernière modification dans l'iframe → sauvegarde automatique silencieuse (comme l'éditeur markdown #29). L'iframe émet `modified` → le parent démarre un timer → au bout de 2s sans nouvelle modification → `postMessage({type: "requestSave"})` → l'iframe répond avec `save` → le parent écrit via l'API.
- [x] **C5. Raccourci Ctrl+S** : L'iframe intercepte Ctrl+S → envoie `save` au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »).
- [x] **C6. Compatibilité Split View (#75)** : L'iframe s'affiche dans le content-area du panneau actif. Le `PaneTabManager` gère le cache : quand on switch d'onglet, l'état de l'iframe est préservé (elle reste dans le DOM, juste masquée). Plusieurs iframes Excalidraw peuvent coexister dans différents panneaux.
- [x] **C7. Création via la modale « Nouveau fichier »** : Dans `frontend/js/ui.js`, fonction `showCreateFileModal()` :
- Ajouter `<option value=".excalidraw">Excalidraw (.excalidraw)</option>` dans le `<select id="file-ext-select">` (après `.json`)
- Quand l'extension `.excalidraw` est sélectionnée, le backend crée le fichier avec le squelette JSON minimum (B6)
- Après création → `openFile(vault, path)` → le viewer détecte `is_excalidraw: true` → l'iframe s'ouvre avec le canvas vierge
- Fonctionne aussi via la palette de commandes `Ctrl+Alt+Space` → « Nouveau fichier » (action `create-file` existante)
- [ ] **C8. Création via le menu contextuel de l'arborescence** : Dans `frontend/js/context-menu.js`, ajouter une option « 🎨 Nouveau diagramme Excalidraw » dans le menu contextuel des répertoires → ouvre directement la modale avec `.excalidraw` pré-sélectionné.
- [x] **C8. Création via le menu contextuel** (FAIT 2026-09 — `frontend/js/ui.js` `_createExcalidraw()`, option « Nouveau diagramme Excalidraw » sur les répertoires, E2E couvert) : ouvre la modale avec `.excalidraw` pré-sélectionné.
##### D. CSS & Design (0.5 jour)
- [ ] **D1. Styles de l'iframe dans ObsiGate** : L'iframe occupe 100% du content-area (`width: 100%; height: 100%; border: none;`). Aucun padding ni marge.
- [ ] **D2. Thème dark/light** : L'iframe reçoit le thème courant → Excalidraw applique son thème interne (`theme="dark"` ou `theme="light"`). Les couleurs sont cohérentes avec ObsiGate grâce à la palette d'Excalidraw.
- [ ] **D3. Écran de chargement** : Pendant le chargement de l'iframe (React + Excalidraw ~2 Mo), afficher un spinner « Chargement de l'éditeur Excalidraw... » dans le content-area. L'iframe envoie `ready` → le spinner disparaît.
- [ ] **D4. Responsive** : L'iframe s'adapte à la largeur du panneau. En mode mobile (<768px), l'éditeur Excalidraw est utilisable (UI tactile native).
- [x] **D1. Styles de l'iframe dans ObsiGate** : L'iframe occupe 100% du content-area (`width: 100%; height: 100%; border: none;`). Aucun padding ni marge.
- [x] **D2. Thème dark/light** : L'iframe reçoit le thème courant → Excalidraw applique son thème interne (`theme="dark"` ou `theme="light"`). Les couleurs sont cohérentes avec ObsiGate grâce à la palette d'Excalidraw.
- [x] **D3. Écran de chargement** : Pendant le chargement de l'iframe (React + Excalidraw ~2 Mo), afficher un spinner « Chargement de l'éditeur Excalidraw... » dans le content-area. L'iframe envoie `ready` → le spinner disparaît.
- [x] **D4. Responsive** : L'iframe s'adapte à la largeur du panneau. En mode mobile (<768px), l'éditeur Excalidraw est utilisable (UI tactile native).
##### E. Gestion des conflits et edge cases (0.5 jour)
- [ ] **E1. Fichier modifié à l'extérieur** : Si le fichier est modifié par Syncthing/watcher pendant l'édition → détecter via le watcher → afficher un bandeau « Ce fichier a été modifié à l'extérieur. Recharger ? » avec boutons [Recharger] [Ignorer].
- [ ] **E2. Plusieurs onglets** : Deux onglets sur le même fichier `.excalidraw` → le second détecte que le fichier est déjà ouvert → focus l'onglet existant (comportement existant du `TabManager` #E4).
- [ ] **E3. Fichier vide ou nouveau** : Couvert par C7/C8 — la création d'un `.excalidraw` produit un canvas vierge avec le squelette JSON minimum (B6). L'iframe gère nativement le cas `elements: []`.
- [ ] **E4. Fichier corrompu** : Si le JSON ne contient pas `type: "excalidraw"` ou est invalide → fallback sur le viewer JSON standard avec un message « Ce fichier .excalidraw semble corrompu ».
- [ ] **E5. Pop-out** : Le bouton pop-out fonctionne — il ouvre l'éditeur dans une popup séparée avec sa propre iframe. Utile pour éditer sur un deuxième écran.
- [ ] **E6. Annulation (Ctrl+Z)** : Natif dans Excalidraw — l'historique d'annulation est géré par l'état interne de l'iframe. Pas besoin d'interaction avec le parent.
- [x] **E1. Fichier modifié à l'extérieur** : Si le fichier est modifié par Syncthing/watcher pendant l'édition → détecter via le watcher → afficher un bandeau « Ce fichier a été modifié à l'extérieur. Recharger ? » avec boutons [Recharger] [Ignorer].
- [x] **E2. Plusieurs onglets** : Deux onglets sur le même fichier `.excalidraw` → le second détecte que le fichier est déjà ouvert → focus l'onglet existant (comportement existant du `TabManager` #E4).
- [x] **E3. Fichier vide ou nouveau** : Couvert par C7/C8 — la création d'un `.excalidraw` produit un canvas vierge avec le squelette JSON minimum (B6). L'iframe gère nativement le cas `elements: []`.
- [x] **E4. Fichier corrompu** (fallback viewer JSON + tests `test_invalid_json_excalidraw_fallback`/`test_json_without_excalidraw_type`) : Si le JSON ne contient pas `type: "excalidraw"` ou est invalide → fallback sur le viewer JSON standard avec un message « Ce fichier .excalidraw semble corrompu ».
- [x] **E5. Pop-out** : Le bouton pop-out fonctionne — il ouvre l'éditeur dans une popup séparée avec sa propre iframe. Utile pour éditer sur un deuxième écran.
- [x] **E6. Annulation (Ctrl+Z)** : Natif dans Excalidraw — l'historique d'annulation est géré par l'état interne de l'iframe. Pas besoin d'interaction avec le parent.
##### F. Tests (0.5 jour)
- [ ] **F1. Tests backend** :
##### F. Tests (0.5 jour) — F1 ✅ (6 tests test_excalidraw.py)
- [x] **F1. Tests backend** :
- `test_excalidraw_detection.py` : fichier `.excalidraw` valide → `is_excalidraw: true`, JSON invalide → fallback JSON, fichier sans `type: excalidraw` → fallback
- `test_excalidraw_search.py` : texte extrait des éléments → recherchable via TF-IDF
- [ ] **F2. Tests frontend** :
- [ ] **F2. Tests frontend** — ⚪ NON RETENU :
- Chargement de l'iframe avec des données de test
- Communication postMessage (init → ready → save)
- Changement de thème propagé à l'iframe
- [ ] **F3. Tests E2E (Playwright, #58)** :
- [x] **F3. Tests E2E (Playwright)** (FAIT 2026-09 — `tests/e2e/excalidraw.spec.js`) :
- Ouvrir un fichier `.excalidraw` → l'iframe se charge → le canvas Excalidraw est visible
- Dessiner un rectangle → sauvegarder → recharger → le rectangle est toujours là
- Basculer thème sombre → l'iframe passe en dark mode
@@ -643,9 +679,9 @@
- **Pas d'édition collaborative** : Cette implémentation est mono-utilisateur. La collaboration temps réel (#62) pourra être étendue aux fichiers `.excalidraw` ultérieurement via le même mécanisme Yjs.
##### H. Documentation utilisateur
- [ ] **H1.** Mettre à jour README : ajouter `.excalidraw` dans les formats supportés
- [ ] **H2.** Ajouter dans le guide d'utilisation (Quick Help) : section « Diagrammes Excalidraw »
- [ ] **H3.** Note : « Les fichiers .excalidraw créés avec le plugin Obsidian Excalidraw sont compatibles »
- [x] **H1.** Mettre à jour README : ajouter `.excalidraw` dans les formats supportés (FR + EN)
- [x] **H2.** Ajouter dans le guide d'utilisation (Quick Help) : section « Diagrammes Excalidraw » (i18n FR/EN)
- [x] **H3.** Note : « Les fichiers .excalidraw créés avec le plugin Obsidian Excalidraw sont compatibles »
---
@@ -718,32 +754,38 @@
- [ ] UI : toggle « Recherche sémantique » dans la barre de recherche
- [ ] UI : score de similarité dans les résultats
### 71. Tableau de bord administrateur
- **Effort :** 2 jours | **Impact :** 🟢
### 71. Tableau de bord administrateur — Backend ✅, Frontend ✅
- **Effort :** 2 jours | **Impact :** 🟢 | **Statut :** ✅ Terminé (2026-08, commits 46be24f / 88ab8db / 01453bc)
- **Description :** Une page web dédiée accessible uniquement aux administrateurs qui centralise tout le monitoring et la gestion du serveur ObsiGate en un seul endroit. Un cockpit de pilotage pour le sysadmin.
- **Widgets temps réel** (rafraîchis via SSE) :
- **CPU / RAM / Disque** : jauges visuelles avec seuils d'alerte (vert < 70%, orange < 90%, rouge > 90%). Permet de voir en un coup d'œil si le serveur est en surcharge.
- **Requêtes par minute** : graphique sparkline des dernières 24h. Permet de détecter les pics d'activité anormaux (attaques, bots, bug qui spam l'API).
- **Utilisateurs actifs** : nombre de sessions connectées en ce moment, compteur de recherches en cours.
- **Gestion des utilisateurs** :
- Tableau triable/filtrable de tous les comptes (nom, rôle, date de création, dernière connexion, nombre de vaults).
- Création, édition, suppression d'utilisateurs. Attribution de rôles (admin/user/readonly).
- Réinitialisation de mot de passe administrateur.
- **Logs d'audit visuels** :
- Tableau chronologique des 500 dernières actions : qui a fait quoi, quand, depuis quelle IP.
- Filtres par utilisateur, type d'action (login, création fichier, suppression, modification settings), plage de dates.
- Export CSV pour analyse externe.
- **Statistiques backups** : graphique d'évolution du nombre et de la taille des backups par vault. Détection automatique des vaults sans backup récent.
- **Pourquoi c'est important :** Actuellement, administrer ObsiGate nécessite de se connecter en SSH au serveur et de lire des fichiers JSON. Le dashboard rend toutes ces opérations accessibles depuis l'interface web, avec des visuels qui permettent de diagnostiquer un problème en 10 secondes au lieu de 10 minutes de CLI.
- **Sous-tâches :**
- [ ] Widgets temps réel : CPU, mémoire, espace disque, requêtes/min (rafraîchissement SSE)
- [ ] Gestion utilisateurs : tableau triable, création/édition/suppression, filtre par rôle
- [ ] Logs d'audit : visualisation des 500 dernières entrées, filtre par utilisateur/action/date
- [ ] Backup stats : graphique d'évolution (taille totale, nombre par vault, âge moyen)
- [ ] Protection : accès restreint au rôle `admin` uniquement
- **Implémentation réelle (vérifiée) :**
- **Backend `backend/admin.py`** (nouveau, 261 lignes) — 4 endpoints admin-gated (`require_admin`) :
- `GET /api/admin/stats` — CPU/RAM/Disk/Uptime via psutil
- `GET /api/admin/audit` — 500 dernières entrées d'audit avec filtres `user`/`action`/`limit`/`offset`
- `GET /api/admin/backup-stats` — compte + taille + age par vault
- `GET /api/admin/stream` — Server-Sent Events qui push les stats toutes les 5s
- `backend/main.py` — routeur monté + middleware gzip bypass pour `/api/admin/stream`
- `backend/requirements.txt` — ajout `psutil>=5.9`
- **Tests :** `tests/test_admin.py` (13 tests, 100% verts) — couvrent auth + filtres + format SSE
- **Complété (2026-08) :**
- `frontend/admin.html` (472 l.) + `frontend/js/admin.js` (544 l.) — dashboard standalone avec navigation par sections sticky + thèmes
- Lien « Admin » dans le user menu (`#admin-menu-row`, gating `role === "admin"`)
- Widgets temps réel via EventSource `/api/admin/stream` + snapshot `/api/admin/stats`
- CRUD users + fix routing `/admin.html` + fix scroll
### 72. API publique documentée — OpenAPI 3.1
- **Effort :** 1-2 jours | **Impact :** 🟢
### 72. API publique documentée — OpenAPI 3.1 ✅ TERMINÉ
- **Effort :** 1-2 jours (réalisé) | **Impact :** 🟢 | **Statut :** ✅ Livré (2026-09-11)
- **Implémentation réelle :**
- `backend/openapi_docs.py` — 18 tags documentés + assignation automatique par préfixe de route
(`tag_for_path`, `canonical_tag`), enrichissement du schéma (`enrich_openapi_schema` :
sécurité `bearerAuth`/`cookieAuth`, erreurs 401/403/404/422/500, exemples requête/réponse,
serveur, `externalDocs`), page `/api` autonome (`render_api_landing`).
- `backend/schemas.py` — `response_model` Pydantic pour ~35 endpoints qui n'en avaient pas.
- `backend/main.py` — `FastAPI(...)` enrichi (description Markdown, contact, licence, tags) +
override `app.openapi` ; routes `/api` et `/api/`.
- `backend/ai_routes.py` / `backend/bookslm_routes.py` — `response_model` (AI status, BooksLM
context) + documentation SSE.
- Frontend — entrée « API » du menu d'options (i18n FR/EN) ouvrant `/docs`.
- Tests — `tests/test_openapi.py` (58 tests) + `tests/frontend/ai.test.mjs` (7 tests).
- **Description :** Une page de documentation interactive et auto-générée de toutes les API REST d'ObsiGate, accessible via un bouton dans l'interface. L'équivalent d'un manuel technique mais qui se teste en direct.
- **OpenAPI 3.1** : c'est le format standard mondial pour décrire une API REST. Un seul fichier JSON/YAML contient la description de tous les endpoints, leurs paramètres, les formats de réponse, les codes d'erreur, et les modèles de données. Ce standard est supporté par des centaines d'outils.
- **Swagger UI** : une interface web qui lit le fichier OpenAPI et génère automatiquement une documentation interactive. L'utilisateur voit chaque endpoint, peut remplir les paramètres dans un formulaire, cliquer « Execute » et voir la réponse réelle de l'API en direct. Parfait pour les développeurs qui veulent intégrer ObsiGate à leurs scripts ou comprendre comment fonctionne l'API.
@@ -756,12 +798,12 @@
- **Auto-génération** : FastAPI génère déjà partiellement le schéma OpenAPI. Le travail consiste à compléter les docstrings manquantes, ajouter `response_model` sur les endpoints qui n'en ont pas, et enrichir avec des exemples.
- **Pourquoi c'est important :** Une API sans documentation est comme un logiciel sans interface — techniquement fonctionnel mais inutilisable. Avec une doc OpenAPI, ObsiGate devient intégrable dans n'importe quel écosystème. Un développeur peut en 5 minutes comprendre comment uploader un fichier, chercher dans un vault, ou récupérer le contenu d'une note — et écrire un script qui automatise ses workflows.
- **Sous-tâches :**
- [ ] Audit des endpoints existants → compléter les docstrings manquants
- [ ] Ajout de `response_model` sur tous les endpoints (40+ actuellement, ~15 sans modèle)
- [ ] Exemples dans les schémas : `examples=[...]` pour les endpoints clés
- [ ] Tagging des endpoints par catégorie (Files, Vaults, Search, Auth, AI, Backups)
- [ ] Serveur mock : `prism` ou `openapi-generator` pour tests sans backend
- [ ] Page de documentation intégrée : lien dans le menu header (« API »)
- [x] Audit des endpoints existants → compléter les docstrings manquants
- [x] Ajout de `response_model` sur tous les endpoints (40+ actuellement, ~15 sans modèle)
- [x] Exemples dans les schémas : `examples=[...]` pour les endpoints clés
- [x] Tagging des endpoints par catégorie (Files, Vaults, Search, Auth, AI, Backups)
- [ ] Serveur mock : `prism` ou `openapi-generator` pour tests sans backend — ⚪ NON RETENU (le schéma 3.1 est validé par `tests/test_openapi.py`)
- [x] Page de documentation intégrée : lien dans le menu header (« API »)
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
- **Effort :** 6-8 jours | **Impact :** 🟢
@@ -805,19 +847,19 @@
- **Sous-tâches :**
##### A. Initialisation du projet Tauri (1 jour)
- [ ] Installer Rust + toolchain Tauri : `cargo install tauri-cli`
- [ ] Initialiser `tauri init` dans `/desktop/` avec config Windows/Linux
- [ ] Configurer `tauri.conf.json` : fenêtre 1200×800, sans cadre, titre "ObsiGate"
- [ ] Configurer le build : cibles `.msi`/`.nsis` (Windows), `.deb`/`.AppImage` (Linux)
- [ ] Ajouter les icônes desktop (`.ico` Windows, `.png` Linux) dans `desktop/icons/`
##### A. Initialisation du projet Tauri (1 jour) — ✅ livré (vérifié 2026-09)
- [x] Toolchain : tauri-cli 2.11.4 / rustc 1.94.1
- [x] Projet Tauri v2 dans `desktop/` (Cargo.toml, build.rs)
- [x] `tauri.conf.json` : fenêtre 1200×800 (min 800×600), titre "ObsiGate"
- [x] Build : cibles `.msi`/`.nsis` (Windows), `.deb`/`.AppImage` (Linux)
- [x] Icônes desktop dans `desktop/icons/` (.ico, .icns, .png)
##### B. Intégration du backend Python (2-3 jours)
- [ ] Bundle Python : créer un dossier `python-embed/` avec `python3.11-embed` + `site-packages/` (requirements.txt gelés)
- [ ] Script `sidecar.py` : lance uvicorn sur `localhost:17890`, log dans `%APPDATA%/ObsiGate/logs/`
- [ ] Code Rust `main.rs` : spawn le sidecar comme processus fils, health check (boucle `GET /api/health` avec timeout 10s), kill propre au `SIGTERM`
- [ ] Menu tray : icône dans la barre des tâches avec options « Ouvrir ObsiGate », « Quitter »
- [ ] Gestion du port : détecter si 17890 est déjà utilisé → incrémenter (17891, 17892...)
##### B. Intégration du backend Python (2-3 jours) — ✅ livré (variante : uvicorn spawné directement, pas de sidecar.py)
- [x] Bundle Python : `desktop/python-embed/` (python3.11-embed + site-packages, validé par validate-structure.sh)
- [x] Lancement backend : `spawn_backend()` Rust lance `python-embed -m uvicorn backend.main:app` (équivalent sidecar), logs dans `%APPDATA%/ObsiGate/logs/backend.log`
- [x] `main.rs` : spawn processus fils, health check (`GET /api/health`, 30 essais × 2s), kill propre (SIGTERM→wait→kill)
- [x] Menu tray (voir section C ✅)
- [x] Gestion du port : `pick_free_port()` scan 17890..17899 si occupé (commit c066b2c, 3 tests Rust)
##### C. Fonctionnalités desktop natives (2-3 jours) — ✅ COMPLÉTÉ
- [x] **Sélecteur de dossier** : `pick_vault_folder` via `tauri_plugin_dialog` → ajoute le vault dans config.json
@@ -832,28 +874,32 @@
- [x] **Pas de terminal visible** : `#![windows_subsystem = "windows"]` + `CREATE_NO_WINDOW` sur le processus Python
- [x] **Persistance fenêtre** : position/taille sauvegardée dans `config.json`
##### D. Build et distribution (2 jours)
##### D. Build et distribution (2 jours) — ✅ COMPLÉTÉ
- [x] **CI/CD automatisé** : workflow Gitea Actions `.gitea/workflows/desktop-build.yml` — build Windows + Linux à chaque push sur `main` (si `desktop/` modifié), upload des artefacts `.msi`/`.AppImage`/`.deb` en release
- [ ] **Build Windows** : `tauri build --target x86_64-pc-windows-msvc` → `.msi` + `.exe` installer
- [ ] **Build Linux** : `tauri build --target x86_64-unknown-linux-gnu` → `.deb`, `.rpm`, `.AppImage`
- [x] **Auto-update** : `tauri-plugin-updater` → vérifie `https://git.dracodev.net/api/v1/repos/Projets/ObsiGate/releases/latest`
- [x] **Build Windows local** : `cargo build --release` vérifié (rustc 1.94.1) — `cargo tauri build --bundles msi` prêt
- [x] **Build Linux local** : workflow CI couvre `.deb`, `.rpm`, `.AppImage`
- [x] **Auto-update** : `tauri-plugin-updater` configuré → vérifie `https://git.dracodev.net/api/v1/repos/Projets/ObsiGate/releases/latest`
- [ ] **Signature de code** : configurer le certificat (optionnel mais recommandé pour Windows)
- [ ] **Page de release** : intégrer le build desktop dans les releases Gitea + README d'installation
- [x] **Page de release** : README desktop existe (`desktop/README.md`)
##### E. Expérience utilisateur (1 jour)
##### E. Expérience utilisateur (1 jour) — ✅ COMPLÉTÉ
- [x] Écran de chargement pendant le démarrage du backend (« ObsiGate démarre... » avec spinner) — splash inline `#boot-splash` dans `index.html`, retiré quand `app.js` signale le boot ; statut mis à jour depuis Rust
- [ ] Gestion des erreurs : backend crash → message explicite + bouton « Redémarrer »
- [ ] Sauvegarde des préférences desktop (taille fenêtre, position, dernier vault)
- [ ] Première expérience : wizard « Choisissez votre vault » au premier lancement
- [ ] Icône dans le menu Démarrer / dock Linux avec jumplist (vaults récents)
- [x] Gestion des erreurs : backend crash → `showBackendCrashBanner()` appelé par le monitor loop toutes les 5s
- [x] Sauvegarde des préférences desktop : position/taille fenêtre sauvées dans `%APPDATA%/ObsiGate/config.json` au close + restauration au startup
- [x] Première expérience : config par défaut auto-créée au premier lancement (vault `~/voute_obsidian`, dir `~USERPROFILE`)
- [ ] Wizard interactif « Choisissez votre vault » au premier lancement (optionnel — config auto suffisante)
- [ ] Jumplist vaults récents dans le menu Démarrer (optionnel)
##### F. Tests (1 jour)
- [ ] Test : installation → premier lancement → wizard vault → ouverture fichier
- [ ] Test : tray icon → réduire dans la barre → restaurer
- [ ] Test : notifications natives → fichier modifié → popup OS
- [ ] Test : association `.md` → double-clic → ouvre dans ObsiGate
- [ ] Test : auto-update → nouvelle version dispo → téléchargement → installation
- [ ] Test : cleanup → désinstallation propre (pas de fichiers résiduels)
##### F. Tests (1 jour) — ✅ COMPLÉTÉ
- [x] 16 tests Rust unitaires : config roundtrip, JSON parsing (empty/partial/corrupted), vault dedup, dir remove, backend URL, paths, branding, edge cases
- [x] Build debug + release vérifié (rustc 1.94.1, tauri-cli 2.11.4)
- [x] CI desktop workflow existant (desktop-build.yml)
- [ ] Test E2E : installation → premier lancement → wizard vault → ouverture fichier (manuel)
- [ ] Test E2E : tray icon → réduire → restaurer (manuel)
- [ ] Test E2E : notifications natives → fichier modifié → popup OS (manuel)
- [ ] Test E2E : association `.md` → double-clic → ouvre dans ObsiGate (manuel)
- [ ] Test E2E : auto-update → nouvelle version → install (manuel)
- [ ] Test E2E : désinstallation propre (manuel)
- **Prérequis techniques :**
- Rust ≥ 1.75 (stable) — installé via `rustup`
@@ -868,12 +914,13 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #57 | ~65 jours |
| 🔵 P1 | ✅ #58 (Playwright E2E) | Terminé |
| 🔵 P2 | 🔨 #77 (Tauri Desktop) | 8-12 jours |
| ⚪ P3 | #59, #61-66, #74, #75, #76, #78 (11 items) | 35-46 jours |
| ⚪ P4 | #67 → #73 (7 items) | 18-23 jours |
| **Total restant** | **20 items** | **63-84 jours** |
| ✅ Complété | #1 → #59, #63-68, #71, #72 (OpenAPI 3.1), #74, #75, #76, #78 (58 E2E, 59 offline, 63 i18n, 64 MFA TOTP+WebAuthn, 65 thèmes, 66 export, 67 push, 68 health, 71 admin, 72 OpenAPI, 74 PDF, 75 split view, 76 BooksLM, 78 Excalidraw) | ~82 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours |
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) | ~5-7 jours |
| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours |
| **Total restant** | **5 items + finitions** | **~17-25 jours** |
#75 : E2E matriciels 37/37 verts (split-view.spec.js + split-view-matrix.spec.js) — 2026-09-08.
---
@@ -882,4 +929,5 @@
- 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.
- Le mode hors-ligne (#59) et l'i18n (#63) sont les P3 ayant le meilleur rapport effort/valeur.
- #59 (hors-ligne), #63 (i18n), #64 (MFA), #65-66, #67-68, #71, #72 (OpenAPI), #74-76, #78 sont livrés — les P3 restants à plus fort rapport effort/valeur : #62 (collaboration).
- #78 est terminé (B5 recherche texte, C8 menu contextuel, F3 E2E, doc H1-H3) ; F2 non retenu. BUG-002 (loading infini Excalidraw) corrigé par les commits a4ea322/185d603.
+472
View File
@@ -0,0 +1,472 @@
<!doctype html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="admin.page_title">ObsiGate — Administration</title>
<meta name="robots" content="noindex" />
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
<!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS -->
<script>
(function () {
var root = document.documentElement;
try {
var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
root.setAttribute("data-theme", mode);
var light = mode === "light" || mode === "sepia";
root.style.setProperty("--bg-primary", light ? "#ffffff" : "#0f1117");
root.style.setProperty("--text-primary", light ? "#1a1a1a" : "#e6edf3");
} catch (e) { root.setAttribute("data-theme", "dark"); }
})();
</script>
<link rel="stylesheet" href="/static/style.css" />
<style>
/* ── Admin page — local styles (kept inline to limit impact on /static/style.css) ── */
:root {
--admin-card-bg: var(--bg-secondary, #161b22);
--admin-card-border: var(--border, #21262d);
--admin-success: var(--success, #3fb950);
--admin-warning: var(--warning, #f59e0b);
--admin-danger: var(--danger, #ff7b72);
}
body.admin-page {
margin: 0;
min-height: 100vh;
overflow: auto; /* override SPA style.css: body{overflow:hidden} */
height: auto; /* override SPA style.css: body{height:100vh} */
background: var(--bg-primary, #0f1117);
color: var(--text-primary, #e6edf3);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
/* ── Sticky section nav ── */
.admin-subnav {
position: sticky;
top: 0;
z-index: 50;
display: flex;
gap: 6px;
flex-wrap: nowrap;
overflow-x: auto;
padding: 8px 24px;
background: var(--bg-secondary, #161b22);
border-bottom: 1px solid var(--border, #21262d);
backdrop-filter: blur(4px);
scrollbar-width: thin;
}
.admin-subnav::-webkit-scrollbar { height: 6px; }
.admin-subnav a {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary, #8b949e);
text-decoration: none;
border: 1px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.admin-subnav a:hover {
background: var(--bg-hover, #1f2430);
color: var(--text-primary, #e6edf3);
}
.admin-subnav a.active {
background: var(--accent-bg, #1f2a3a);
color: var(--accent-text, #79c0ff);
border-color: var(--accent, #58a6ff);
}
.admin-section { scroll-margin-top: 64px; }
@media (max-width: 600px) {
.admin-subnav { padding: 8px 12px; }
.admin-subnav a { font-size: 0.8rem; padding: 6px 10px; }
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
background: var(--bg-secondary, #161b22);
border-bottom: 1px solid var(--admin-card-border);
}
.admin-header-left { display: flex; align-items: center; gap: 14px; }
.admin-header h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--text-primary, #e6edf3);
}
.admin-header .admin-subtitle {
font-size: 0.85rem;
color: var(--text-secondary, #8b949e);
}
.admin-header-right { display: flex; align-items: center; gap: 12px; }
#admin-role-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
background: var(--accent-bg, #1f2a3a);
color: var(--accent-text, #79c0ff);
border: 1px solid var(--accent, #58a6ff);
}
#admin-role-badge[data-role="admin"] {
background: var(--success-bg, #1a3d1f);
color: var(--success, #3fb950);
border-color: var(--success, #3fb950);
}
.admin-back-btn {
padding: 6px 14px;
background: var(--bg-hover, #1f2430);
color: var(--text-primary, #e6edf3);
border: 1px solid var(--admin-card-border);
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
text-decoration: none;
transition: background 0.15s;
}
.admin-back-btn:hover { background: var(--bg-sidebar, #13161d); }
.admin-main {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
display: grid;
gap: 24px;
}
.admin-section {
background: var(--admin-card-bg);
border: 1px solid var(--admin-card-border);
border-radius: 10px;
padding: 20px;
}
.admin-section h2 {
font-size: 1.05rem;
font-weight: 600;
margin: 0 0 16px 0;
color: var(--text-primary, #e6edf3);
display: flex;
align-items: center;
gap: 8px;
}
.admin-section h2::before {
content: "";
display: inline-block;
width: 4px;
height: 18px;
background: var(--accent, #58a6ff);
border-radius: 2px;
}
/* ── Stats grid ── */
#admin-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.admin-stat-card {
background: var(--bg-hover, #1f2430);
border: 1px solid var(--admin-card-border);
border-radius: 8px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-stat-label {
font-size: 0.75rem;
color: var(--text-secondary, #8b949e);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.admin-stat-value {
font-size: 1.6rem;
font-weight: 700;
color: var(--text-primary, #e6edf3);
}
.admin-stat-unit {
font-size: 0.85rem;
font-weight: 400;
color: var(--text-secondary, #8b949e);
}
.admin-stat-bar {
height: 6px;
background: var(--bg-primary, #0f1117);
border-radius: 3px;
overflow: hidden;
}
.admin-stat-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease, background 0.4s ease;
}
/* ── Tables ── */
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.admin-table th, .admin-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--admin-card-border);
}
.admin-table th {
background: var(--bg-primary, #0f1117);
color: var(--text-secondary, #8b949e);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.admin-table tbody tr:hover {
background: var(--bg-hover, #1f2430);
}
.admin-table code {
background: var(--bg-primary, #0f1117);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
color: var(--accent-text, #79c0ff);
}
/* ── Filters ── */
.admin-filters {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.admin-filters input, .admin-filters select {
padding: 6px 12px;
background: var(--bg-primary, #0f1117);
color: var(--text-primary, #e6edf3);
border: 1px solid var(--admin-card-border);
border-radius: 6px;
font-size: 0.85rem;
}
.admin-filters input:focus, .admin-filters select:focus {
outline: none;
border-color: var(--accent, #58a6ff);
}
.admin-btn {
padding: 6px 14px;
background: var(--accent, #58a6ff);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
font-weight: 600;
transition: opacity 0.15s;
}
.admin-btn:hover { opacity: 0.85; }
.admin-btn-secondary {
background: var(--bg-hover, #1f2430);
color: var(--text-primary, #e6edf3);
border: 1px solid var(--admin-card-border);
}
/* ── Backups ── */
#admin-backups-summary {
display: flex;
gap: 20px;
margin-bottom: 14px;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--text-secondary, #8b949e);
}
#admin-backups-summary > div {
padding: 8px 14px;
background: var(--bg-primary, #0f1117);
border-radius: 6px;
border: 1px solid var(--admin-card-border);
}
.admin-backup-bar {
height: 8px;
background: var(--bg-primary, #0f1117);
border-radius: 4px;
overflow: hidden;
min-width: 120px;
}
.admin-backup-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent, #58a6ff), var(--accent-text, #79c0ff));
transition: width 0.4s ease;
}
/* ── Users management ── */
#section-users .admin-users-hint {
color: var(--text-secondary, #8b949e);
font-size: 0.85rem;
margin-bottom: 14px;
}
/* ── Footer ── */
.admin-footer {
max-width: 1400px;
margin: 0 auto;
padding: 16px 24px;
font-size: 0.75rem;
color: var(--text-muted, #484f58);
display: flex;
justify-content: space-between;
border-top: 1px solid var(--admin-card-border);
}
#admin-stats-timestamp { color: var(--text-secondary, #8b949e); }
/* ── Forbidden screen ── */
#admin-forbidden {
display: none;
max-width: 500px;
margin: 100px auto;
padding: 40px;
text-align: center;
background: var(--admin-card-bg);
border: 1px solid var(--admin-danger);
border-radius: 10px;
}
#admin-forbidden h2 { color: var(--admin-danger); margin-top: 0; }
/* ── Loading state ── */
.admin-loading {
padding: 40px;
text-align: center;
color: var(--text-secondary, #8b949e);
font-size: 0.9rem;
}
</style>
</head>
<body class="admin-page">
<header class="admin-header">
<div class="admin-header-left">
<h1>ObsiGate — Admin</h1>
<span class="admin-subtitle" data-i18n="admin.header_subtitle">Tableau de bord administrateur</span>
</div>
<div class="admin-header-right">
<span id="admin-role-badge">…</span>
<a href="/" class="admin-back-btn" data-i18n="admin.back_btn">← Retour</a>
</div>
</header>
<nav class="admin-subnav" id="admin-subnav" aria-label="Sections admin">
<a href="#section-stats" class="active" data-section="stats">📊 <span data-i18n="admin.stats_title">Statistiques</span></a>
<a href="#section-audit" data-section="audit">🕒 <span data-i18n="admin.audit_title">Logs d'audit</span></a>
<a href="#section-backups" data-section="backups">💾 <span data-i18n="admin.backups_title">Backups</span></a>
<a href="#section-users" data-section="users">👥 <span data-i18n="admin.users_title">Utilisateurs</span></a>
</nav>
<main id="admin-main" class="admin-main">
<!-- Loading state shown until init() resolves -->
<div id="admin-loading" class="admin-loading" data-i18n="common.loading">Chargement…</div>
<!-- Section 1 — Stats temps réel -->
<section class="admin-section" id="section-stats">
<h2 data-i18n="admin.stats_title">Statistiques temps réel</h2>
<div id="admin-stats-grid" class="admin-loading">…</div>
</section>
<!-- Section 2 — Logs d'audit -->
<section class="admin-section" id="section-audit">
<h2 data-i18n="admin.audit_title">Logs d'audit</h2>
<div class="admin-filters">
<input type="text" id="admin-audit-user" data-i18n-placeholder="admin.audit_filter_user" placeholder="Filtrer par utilisateur" />
<select id="admin-audit-action">
<option value="" data-i18n="admin.audit_all_actions">Toutes les actions</option>
</select>
<button id="admin-audit-refresh" class="admin-btn admin-btn-secondary" data-i18n="admin.audit_refresh">Rafraîchir</button>
</div>
<div id="admin-audit-empty" class="admin-loading" style="display:none;" data-i18n="admin.audit_empty">Aucun log disponible</div>
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>User</th>
<th>Vault</th>
<th>IP</th>
</tr>
</thead>
<tbody id="admin-audit-tbody"></tbody>
</table>
</section>
<!-- Section 3 — Statistiques backups -->
<section class="admin-section" id="section-backups">
<h2 data-i18n="admin.backups_title">Statistiques backups</h2>
<div id="admin-backups-summary"></div>
<div id="admin-backups-empty" class="admin-loading" style="display:none;" data-i18n="admin.backups_vault_empty">Aucun backup</div>
<table class="admin-table">
<thead>
<tr>
<th data-i18n="admin.backups_per_vault">Par vault</th>
<th>Count</th>
<th>Size</th>
<th>Distribution</th>
</tr>
</thead>
<tbody id="admin-backups-tbody"></tbody>
</table>
</section>
<!-- Section 4 — Gestion utilisateurs -->
<section class="admin-section" id="section-users">
<h2 data-i18n="admin.users_title">Gestion utilisateurs</h2>
<p class="admin-users-hint" data-i18n="admin.users_manage_hint">Ouvrir la modale de gestion des utilisateurs (CRUD complet)</p>
<button id="admin-users-manage-btn" class="admin-btn" data-i18n="admin.users_manage_btn">Gérer les utilisateurs</button>
</section>
</main>
<div id="admin-forbidden">
<h2 data-i18n="admin.forbidden_title">Accès refusé</h2>
<p data-i18n="admin.forbidden_msg">Vous devez être administrateur pour accéder à cette page.</p>
<a href="/" class="admin-back-btn" data-i18n="admin.back_btn">← Retour</a>
</div>
<footer class="admin-footer">
<span>ObsiGate Admin — ROADMAP #71</span>
<span id="admin-stats-timestamp">—</span>
</footer>
<script type="module">
// Theme: applique le thème/mode sauvegardé dans l'app principale
// (même origine => localStorage partagé). Fait AVANT le rendu pour
// éviter que la page admin n'utilise les vars CSS par défaut.
import { initThemes } from "/static/js/themes.js";
// We import admin.js which provides init() — but the i18n module
// is global (window.t), so we can use t() right away to translate
// static DOM. admin.js will then call init() after DOMContentLoaded.
import { initI18n } from "/static/js/i18n.js";
import * as Admin from "/static/js/admin.js";
initThemes();
await initI18n();
// Re-apply DOM translations now that locale strings are loaded
document.querySelectorAll("[data-i18n]").forEach((el) => {
el.textContent = window.t(el.getAttribute("data-i18n"));
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
el.setAttribute("placeholder", window.t(el.getAttribute("data-i18n-placeholder")));
});
// Remove loading hint now that i18n is ready
const loading = document.getElementById("admin-loading");
if (loading) loading.style.display = "none";
if (typeof Admin.init === "function") {
Admin.init();
} else {
console.error("admin.js did not export init()");
}
</script>
</body>
</html>
+47 -32
View File
@@ -92,26 +92,20 @@
<button id="btn-export-svg" title="Export SVG">📐 SVG</button>
</div>
<!-- Import map: force ALL modules (Excalidraw + sub-deps) to use React 18.3.1 -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]",
"react-dom": "https://esm.sh/[email protected]",
"react-dom/": "https://esm.sh/[email protected]/"
}
}
</script>
<!-- Single module script -->
<!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query.
The alias gets propagated by esm.sh to every transitive dependency; for
range-version deps (e.g. jotai@>=2.9.2) esm.sh then tries to rebuild the
range + alias variant and times out (HTTP 408) → the whole import fails →
the diagram stays stuck on "Loading Excalidraw…". Dropping the alias lets
esm.sh resolve a consistent React 19 (Excalidraw 0.18's default), which
loads reliably. -->
<script type="module">
import React from "react";
import ReactDOM from "react-dom/client";
import { decompressFromBase64 } from "https://esm.sh/[email protected]";
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]?alias=react:[email protected],react-dom:[email protected]";
import React from "https://esm.sh/react@^19.2.0?target=es2022";
import { createRoot } from "https://esm.sh/react-dom@^19.2.0/client?target=es2022";
import { decompressFromBase64, compressToBase64 } from "https://esm.sh/[email protected]";
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]";
window.React = React;
window.ReactDOM = ReactDOM;
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/[email protected]/dist/prod/";
window.ExcalidrawLib = ExcalidrawLib;
@@ -122,6 +116,12 @@
let isDirty = false;
let currentTheme = "light";
let saveTimeout = null;
// Excalidraw fires onChange on initial mount; ignore those so simply
// opening a diagram does not trigger a spurious autosave (which would
// rewrite a .excalidraw.md file as raw JSON and destroy its format).
let ignoreChanges = true;
// Source format: "md" for the Obsidian .excalidraw.md (compressed-json).
let sourceIsMd = false;
// --- DOM refs ---
const loadingEl = document.getElementById("loading");
@@ -159,16 +159,23 @@
const elements = excalidrawAPI.getSceneElements();
const appState = excalidrawAPI.getAppState();
const files = excalidrawAPI.getFiles();
sendToParent({
type: "save",
data: {
type: "excalidraw",
version: 2,
elements: elements,
appState: appState,
files: files,
},
});
const data = {
type: "excalidraw",
version: 2,
elements: elements,
appState: appState,
files: files,
};
// Preserve the Obsidian .excalidraw.md format: ship the compressed
// scene so the parent can rewrite the frontmatter + compressed-json block.
if (sourceIsMd) {
try {
data.compressed = compressToBase64(JSON.stringify(data));
} catch (e) {
console.error("Failed to compress Excalidraw scene:", e);
}
}
sendToParent({ type: "save", data });
markClean();
btnSave.textContent = "💾 Saved!";
setTimeout(() => { btnSave.textContent = "💾 Save"; }, 1200);
@@ -186,22 +193,29 @@
function App({ initialData, theme }) {
const [appState, setAppState] = React.useState(null);
const excalidrawRef = React.useCallback((api) => {
// Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI`
// prop, called with the API object once mounted (NOT the legacy
// `excalidrawRef` name). Without this the loading overlay never hides
// and save/export stay dead.
const onReady = React.useCallback((api) => {
if (api) {
excalidrawAPI = api;
loadingEl.classList.add("hidden");
if (theme === "dark") {
api.updateScene({ appState: { theme: "dark" } });
}
// Stop ignoring changes once the initial mount settles.
setTimeout(() => { ignoreChanges = false; }, 800);
}
}, [theme]);
const onChange = React.useCallback((elements, state, files) => {
if (ignoreChanges) return;
markDirty();
}, []);
return React.createElement(Excalidraw, {
excalidrawRef: excalidrawRef,
excalidrawAPI: onReady,
initialData: initialData,
onChange: onChange,
theme: theme,
@@ -227,6 +241,7 @@
let elements, appState, files;
// Handle compressed .excalidraw.md format
if (msg.data.compressed) {
sourceIsMd = true;
try {
const decompressed = decompressFromBase64(msg.data.compressed);
const parsed = JSON.parse(decompressed);
@@ -247,10 +262,10 @@
setTheme(currentTheme);
try {
if (typeof ReactDOM.createRoot !== "function") {
throw new Error("ReactDOM.createRoot is not available — check react-dom/client import");
if (typeof createRoot !== "function") {
throw new Error("createRoot is not available — check react-dom/client import");
}
ReactDOM.createRoot(document.getElementById("root")).render(
createRoot(document.getElementById("root")).render(
React.createElement(App, {
initialData: initialData,
theme: currentTheme,
+295 -7
View File
@@ -627,6 +627,30 @@
>
</span>
</button>
<button
class="menu-list-row menu-list-button"
id="api-docs-menu-row"
type="button"
role="menuitem"
>
<span
class="menu-list-icon"
aria-hidden="true"
>
<i
data-lucide="code-2"
style="width: 17px; height: 17px"
></i>
</span>
<span class="menu-list-content">
<span class="menu-list-title" data-i18n="header.menu_api"
>API</span
>
<span class="menu-list-subtitle" data-i18n="header.menu_api_desc"
>Documentation OpenAPI</span
>
</span>
</button>
<button
class="menu-list-row menu-list-button"
id="config-open-btn"
@@ -1448,6 +1472,9 @@
<li><a href="#cfg-ai" class="help-nav-link" data-i18n="settings.ai"></a></li>
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
<li><a href="#cfg-webhooks" class="help-nav-link" data-i18n="config.section_webhooks"></a></li>
<li><a href="#cfg-partages-publics" class="help-nav-link" data-i18n="config.section_shares"></a></li>
@@ -2011,6 +2038,90 @@
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-nvidia-key"
>NVIDIA API Key</label
>
<input
type="password"
id="cfg-nvidia-key"
class="config-input"
placeholder="nvapi-..."
autocomplete="off"
/>
<select
id="cfg-nvidia-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-qwencloud-key"
>QwenCloud API Key</label
>
<input
type="password"
id="cfg-qwencloud-key"
class="config-input"
placeholder="sk-..."
autocomplete="off"
/>
<select
id="cfg-qwencloud-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-xiaomi-key"
>Xiaomi API Key</label
>
<input
type="password"
id="cfg-xiaomi-key"
class="config-input"
placeholder="xm-..."
autocomplete="off"
/>
<select
id="cfg-xiaomi-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-mistral-key"
>Mistral API Key</label
>
<input
type="password"
id="cfg-mistral-key"
class="config-input"
placeholder="sk-..."
autocomplete="off"
/>
<select
id="cfg-mistral-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div
class="config-actions-row"
style="margin-top: 16px"
@@ -2042,7 +2153,7 @@
<h2 data-i18n="auto.c14b5603">🎨 Thèmes</h2>
<p class="config-description" data-i18n="auto.caac40b9">
Choisissez un thème visuel. Chaque thème
offre un mode sombre et clair.
offre un mode sombre, clair, contraste élevé et sépia.
</p>
<div class="theme-grid" id="theme-grid">
<div class="config-diag-loading" data-i18n="common.loading">Chargement...</div>
@@ -2080,11 +2191,61 @@
</div>
</section>
<!-- À propos -->
<!-- Sécurité du compte (MFA) -->
<section
class="config-section help-section"
id="cfg-about"
id="cfg-security"
>
<h2 data-i18n="settings.security">🔒 Sécurité du compte</h2>
<p class="config-description" data-i18n="settings.security_desc">
Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.
</p>
<div id="mfa-settings">
<div id="mfa-status" class="mfa-status-section">
<div class="mfa-status-row">
<span class="mfa-status-label" data-i18n="mfa.status_label">Authentification 2FA</span>
<span id="mfa-status-badge" class="mfa-badge mfa-badge-off" data-i18n="mfa.disabled">Désactivée</span>
</div>
<div id="mfa-setup-area"></div>
</div>
</div>
</section>
<!-- Notifications push -->
<section
class="config-section help-section"
id="cfg-push"
>
<h2 data-i18n="config.section_push">🔔 Notifications push</h2>
<p class="config-description">
Recevez des notifications web push
lorsque des fichiers changent dans vos
vaults, même si ObsiGate n'est pas ouvert.
</p>
<div id="push-settings">
<div class="config-diag-loading" data-i18n="common.loading">
Chargement...
</div>
</div>
</section>
<!-- Plugins -->
<section
class="config-section help-section"
id="cfg-plugins"
>
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
<p class="config-description" data-i18n="plugins.description">
Extend ObsiGate with custom renderers, search filters, and editor actions.
</p>
<div id="plugins-settings-container"></div>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
<div
id="config-about"
@@ -2667,6 +2828,11 @@
data-i18n="help.nav_files">📁 Fichiers</a
>
</li>
<li>
<a href="#help-excalidraw" class="help-nav-link"
data-i18n="help.nav_excalidraw">🎨 Excalidraw</a
>
</li>
<li>
<a href="#help-edition" class="help-nav-link"
data-i18n="help.nav_editing">✏️ Édition</a
@@ -2678,7 +2844,7 @@
>
</li>
<li>
<a href="#help-ia" class="help-nav-link"
<a href="#help-ai" class="help-nav-link"
data-i18n="help.nav_ai">🤖 IA Éditeur</a
>
</li>
@@ -2726,6 +2892,11 @@
data-i18n="help.nav_tips">💡 Astuces</a
>
</li>
<li>
<a href="#help-plugins" class="help-nav-link"
data-i18n="help.nav_plugins">🧩 Plugins</a
>
</li>
</ul>
</nav>
<div class="help-content">
@@ -3323,6 +3494,13 @@
<li>
<strong>Web</strong> : .html, .css, .scss
</li>
<li>
<strong>PDF</strong> : .pdf (visualiseur natif, texte indexé)
</li>
<li>
<strong>Excalidraw</strong> : .excalidraw,
.excalidraw.md
</li>
<li>
<strong data-i18n="help.other">Autres</strong> : .txt, .log, .sql,
.csv
@@ -3330,6 +3508,50 @@
</ul>
</section>
<!-- 🎨 Excalidraw -->
<section class="help-section" id="help-excalidraw">
<h2 data-i18n="help.excalidraw_title">🎨 Diagrammes Excalidraw</h2>
<p data-i18n-html="help.excalidraw_intro">
Les fichiers <code>.excalidraw</code> et
<code>.excalidraw.md</code> s'ouvrent dans un éditeur visuel
Excalidraw complet, intégré dans une iframe sandboxée.
</p>
<h3 data-i18n="help.excalidraw_open_title">Ouverture</h3>
<ul>
<li data-i18n-html="help.excalidraw_open">
Cliquez sur un fichier <code>.excalidraw</code> dans
l'arborescence : l'éditeur s'ouvre à la place du viewer markdown.
</li>
<li data-i18n="help.excalidraw_edit">
Dessinez, ajoutez des formes, du texte, des flèches et des
images — l'expérience Excalidraw complète.
</li>
</ul>
<h3 data-i18n="help.excalidraw_save_title">Sauvegarde</h3>
<ul>
<li data-i18n-html="help.excalidraw_save">
Les modifications sont sauvegardées automatiquement (2 s) ou
avec <strong>Ctrl+S</strong>. L'éditeur suit le thème
clair/sombre.
</li>
</ul>
<h3 data-i18n="help.excalidraw_search_title">Recherche</h3>
<ul>
<li data-i18n="help.excalidraw_search">
Le texte des éléments du diagramme est extrait à l'indexation :
il est donc recherchable via la recherche full-text.
</li>
<li data-i18n-html="help.excalidraw_compat">
Les fichiers créés avec le plugin Obsidian Excalidraw (y
compris le format <code>.excalidraw.md</code> compressé) sont
compatibles.
</li>
</ul>
</section>
<!-- ✏️ Édition -->
<section class="help-section" id="help-edition">
<h2 data-i18n="auto.f346076a">✏️ Édition avancée</h2>
@@ -4620,10 +4842,75 @@ curl -X POST https://votre-serveur.com/webhook \
</ul>
</section>
<!-- 🧩 Plugins -->
<section class="help-section" id="help-plugins">
<h2 data-i18n="help.plugins_title">🧩 Plugins</h2>
<p data-i18n="help.plugins_intro">
Les plugins étendent ObsiGate : affichage personnalisé des
fichiers, filtres de recherche, actions d'édition, widgets
et intégrations. Ils s'exécutent dans des Web Workers
sandboxés — isolés de la page pour la sécurité.
</p>
<h3 data-i18n="help.plugins_install">Installation</h3>
<ol>
<li data-i18n="help.plugins_install_1">Ouvrez Réglages → 🧩 Plugins.</li>
<li data-i18n="help.plugins_install_2">Cliquez « Installer un plugin ».</li>
<li data-i18n="help.plugins_install_3">Sélectionnez un fichier <code>.zip</code> contenant le manifest
<code>plugin.json</code> + le code du plugin.</li>
<li data-i18n="help.plugins_install_4">Le manifest est validé (nom, version semver, hooks, permissions)
avant installation.</li>
</ol>
<h3 data-i18n="help.plugins_manage">Gestion</h3>
<ul>
<li data-i18n="help.plugins_manage_1"><strong>Activer / Désactiver</strong> : boutons sur chaque plugin — les
hooks ne tournent que si le plugin est activé.</li>
<li data-i18n="help.plugins_manage_2"><strong>Désinstaller</strong> : supprime définitivement le plugin du vault.</li>
<li data-i18n="help.plugins_manage_3"><strong>Code</strong> : visualisez le code source du plugin installé.</li>
</ul>
<h3 data-i18n="help.plugins_dev">Développer un plugin</h3>
<p data-i18n="help.plugins_dev_intro">
Téléchargez le modèle depuis Réglages → Plugins → « Obtenir le
modèle ». Un plugin se compose d'un manifest
<code>plugin.json</code> et d'un fichier principal (ex.
<code>index.js</code>).
</p>
<h3 data-i18n="help.plugins_hooks">Hooks disponibles</h3>
<ul>
<li><strong data-i18n="help.plugins_hook_render">onFileRender</strong> :
<span data-i18n="help.plugins_hook_render_desc">transforme le contenu affiché d'un fichier.</span></li>
<li><strong data-i18n="help.plugins_hook_search">onSearchFilter</strong> :
<span data-i18n="help.plugins_hook_search_desc">filtre les résultats de recherche.</span></li>
<li><strong data-i18n="help.plugins_hook_editor">onEditorAction</strong> :
<span data-i18n="help.plugins_hook_editor_desc">intercepte les actions de l'éditeur.</span></li>
<li><strong data-i18n="help.plugins_hook_sidebar">onSidebarItem</strong> :
<span data-i18n="help.plugins_hook_sidebar_desc">ajoute des éléments à la barre latérale.</span></li>
<li><strong data-i18n="help.plugins_hook_file">onFileCreate / onFileDelete</strong> :
<span data-i18n="help.plugins_hook_file_desc">réagit à la création / suppression de fichiers.</span></li>
<li><strong data-i18n="help.plugins_hook_vault">onVaultMount</strong> :
<span data-i18n="help.plugins_hook_vault_desc">exécuté au chargement d'un vault.</span></li>
</ul>
<h3 data-i18n="help.plugins_security">Sécurité</h3>
<ul>
<li data-i18n="help.plugins_security_1"><strong>Sandbox Web Worker</strong> : le code plugin tourne isolé, sans
accès direct au DOM.</li>
<li data-i18n="help.plugins_security_2"><strong>Permissions</strong> : chaque hook déclaré doit être satisfait
par les permissions du manifest.</li>
<li data-i18n="help.plugins_security_3"><strong>Empaquetage sécurisé</strong> : le .zip est vérifié contre le
path traversal à l'installation.</li>
<li data-i18n="help.plugins_security_4"><strong>Admin uniquement</strong> : l'installation et la suppression
requièrent le rôle admin.</li>
</ul>
</section>
<div class="help-footer">
<p>
<strong>ObsiGate v1.7.0</strong> - Porte
d'entrée web pour vos vaults Obsidian
<strong>ObsiGate <span id="help-version"></span></strong>
<span data-i18n="help.footer_tagline">- Porte d'entrée web pour vos vaults Obsidian</span>
</p>
<p>
Pour plus d'informations, consultez le
@@ -4634,6 +4921,7 @@ curl -X POST https://votre-serveur.com/webhook \
>.
</p>
</div>
</div>
</div>
</div>
@@ -4845,7 +5133,7 @@ curl -X POST https://votre-serveur.com/webhook \
</div>
<h1 class="about-title" data-i18n="header.logo">ObsiGate</h1>
<p class="about-tagline">Vos notes Obsidian,<br>simplement accessibles.</p>
<span class="about-version">v2.0.0-dev</span>
<span class="about-version" id="about-version">—</span>
</div>
<div class="about-body">
<div class="about-stats">
+544
View File
@@ -0,0 +1,544 @@
// frontend/js/admin.js — Admin Dashboard frontend module (ROADMAP #71)
//
// Standalone ES module loaded by frontend/admin.html.
// Public API:
// init() — DOMContentLoaded entry point. Verifies auth, fires initial loads.
// connectSSE() — Opens EventSource on /api/admin/stream; updates widgets on every "stats" event.
// loadStatsOnce() — Initial GET /api/admin/stats (snapshot before SSE delivers first frame).
// loadAuditLogs(filters) — GET /api/admin/audit with optional {user, action}.
// loadBackupStats() — GET /api/admin/backup-stats.
// renderStatsWidget(stats) — Paint CPU/RAM/Disk/Uptime/Sessions cards.
// renderAuditTable(entries) — Paint audit log table.
// renderBackups(byVault) — Paint per-vault backup bars.
// getAuthHeaders() — Read Bearer token from sessionStorage (same source as auth.js).
// formatBytes(mb) — Human-readable size formatter.
// formatUptime(seconds) — "Xd Yh Zm" formatter.
// severityColor(pct) — "success" | "warning" | "danger" based on thresholds.
import { api, AdminPanel } from "./auth.js";
import { state } from "./state.js";
import { escapeHtml, safeCreateIcons } from "./utils.js";
import { t, getLocale } from "./i18n.js";
// ── Module state ─────────────────────────────────────────────────────────
let _eventSource = null;
let _pollTimer = null;
let _auditFilters = { user: "", action: "" };
// ── Helpers ──────────────────────────────────────────────────────────────
/**
* Read the current Bearer token from sessionStorage (same key auth.js uses).
* Returns an `{Authorization: "Bearer ..."}` object or null.
*/
export function getAuthHeaders() {
try {
const token = sessionStorage.getItem("obsigate_access_token");
if (!token) return null;
return { Authorization: "Bearer " + token };
} catch {
return null;
}
}
/** Format a size in MB as a human-readable string. */
export function formatBytes(mb) {
if (mb == null || isNaN(mb)) return "—";
if (mb < 1) return `${Math.round(mb * 1024)} KB`;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
return `${(mb / 1024).toFixed(2)} GB`;
}
/** Format an uptime in seconds as "Xd Yh Zm" (or "Xh Ym" / "Xm"). */
export function formatUptime(seconds) {
if (seconds == null || isNaN(seconds) || seconds < 0) return "—";
const s = Math.floor(seconds);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
/**
* Map a percentage to a CSS severity token.
* <70 → success (green)
* <90 → warning (orange)
* ≥90 → danger (red)
*/
export function severityColor(pct) {
if (pct == null || isNaN(pct)) return "success";
if (pct >= 90) return "danger";
if (pct >= 70) return "warning";
return "success";
}
/** Format an ISO timestamp with the user's locale. */
function formatDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(
getLocale() === "fr" ? "fr-FR" : "en-US"
);
} catch {
return iso;
}
}
/** Discreet error helper — never crashes the page. */
function _showInlineError(containerId, msg) {
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = `<div class="admin-inline-error" style="color:var(--danger);padding:8px 12px;font-size:0.85rem;">${escapeHtml(msg)}</div>`;
}
// ── Stats widget ─────────────────────────────────────────────────────────
/**
* Paint the 5 stats cards.
* @param {object} stats {cpu_pct, mem_used_mb, mem_total_mb, disk_used_gb, disk_total_gb, uptime_seconds, active_sessions}
*/
export function renderStatsWidget(stats) {
if (!stats) return;
const grid = document.getElementById("admin-stats-grid");
if (!grid) return;
const cpuPct = stats.cpu_pct ?? 0;
const memPct = stats.mem_total_mb > 0 ? (stats.mem_used_mb / stats.mem_total_mb) * 100 : 0;
const diskPct = stats.disk_total_gb > 0 ? (stats.disk_used_gb / stats.disk_total_gb) * 100 : 0;
const cpuSev = severityColor(cpuPct);
const memSev = severityColor(memPct);
const diskSev = severityColor(diskPct);
const memTotal = stats.mem_total_mb ?? 0;
const diskTotal = stats.disk_total_gb ?? 0;
grid.innerHTML = `
<div class="admin-stat-card" data-sev="${cpuSev}">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_cpu"))}</div>
<div class="admin-stat-value">${cpuPct.toFixed(1)}<span class="admin-stat-unit">%</span></div>
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(cpuPct, 100).toFixed(1)}%;background:var(--${cpuSev});"></div></div>
</div>
<div class="admin-stat-card" data-sev="${memSev}">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_memory"))}</div>
<div class="admin-stat-value">${stats.mem_used_mb?.toFixed(0) ?? "—"}<span class="admin-stat-unit"> / ${memTotal.toFixed(0)} MB</span></div>
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(memPct, 100).toFixed(1)}%;background:var(--${memSev});"></div></div>
</div>
<div class="admin-stat-card" data-sev="${diskSev}">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_disk"))}</div>
<div class="admin-stat-value">${stats.disk_used_gb?.toFixed(1) ?? "—"}<span class="admin-stat-unit"> / ${diskTotal.toFixed(1)} GB</span></div>
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(diskPct, 100).toFixed(1)}%;background:var(--${diskSev});"></div></div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_uptime"))}</div>
<div class="admin-stat-value" style="font-size:1.4rem;">${escapeHtml(formatUptime(stats.uptime_seconds))}</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_sessions"))}</div>
<div class="admin-stat-value">${stats.active_sessions ?? 0}</div>
</div>
`;
// Update footer timestamp
const ts = stats.timestamp ? new Date(stats.timestamp) : new Date();
const stamp = document.getElementById("admin-stats-timestamp");
if (stamp) {
stamp.textContent = t("admin.stats_refresh", { time: ts.toLocaleTimeString(getLocale() === "fr" ? "fr-FR" : "en-US") });
}
}
/** Fetch /api/admin/stats and paint. */
export async function loadStatsOnce() {
try {
const data = await api("/api/admin/stats");
renderStatsWidget(data);
} catch (err) {
_showInlineError("admin-stats-grid", err.message || "stats load failed");
}
}
/**
* Open a Server-Sent Events connection on /api/admin/stream.
* The endpoint emits `event: stats\ndata: {...}` every 5 seconds.
*
* The browser's EventSource doesn't support custom headers, so we rely on
* the httpOnly cookie (set by /api/auth/login with samesite=lax) to authenticate.
* `withCredentials: true` ensures the cookie is sent.
*
* If the connection fails to open (4xx/5xx or network), we fall back to
* periodic polling of /api/admin/stats every 5s so the UI keeps updating.
*/
export function connectSSE() {
if (_eventSource) {
try { _eventSource.close(); } catch { /* */ }
_eventSource = null;
}
if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
let sseReady = false;
try {
_eventSource = new EventSource("/api/admin/stream", { withCredentials: true });
_eventSource.addEventListener("stats", (ev) => {
sseReady = true;
try {
const data = JSON.parse(ev.data);
renderStatsWidget(data);
} catch (err) {
console.warn("admin SSE parse error", err);
}
});
_eventSource.onopen = () => {
sseReady = true;
// Stop the polling fallback once SSE works.
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
};
_eventSource.onerror = () => {
// EventSource auto-reconnects. Only start polling fallback if we
// never received the first event yet (e.g. 401/403/network blocked).
if (!sseReady) {
_startPollingFallback();
}
console.warn("admin SSE error (will retry)");
};
} catch (err) {
console.warn("admin SSE init failed", err);
_startPollingFallback();
}
// Kick off a single initial fetch right away so the UI doesn't show
// zeros for 5 seconds waiting for the first SSE event.
loadStatsOnce();
}
function _startPollingFallback() {
if (_pollTimer) return;
console.warn("admin: falling back to polling /api/admin/stats every 5s");
_pollTimer = setInterval(() => {
loadStatsOnce().catch(() => { /* ignore — error already shown */ });
}, 5000);
}
/** Close the SSE connection if any (useful before navigation). */
export function disconnectSSE() {
if (_eventSource) {
try { _eventSource.close(); } catch { /* */ }
_eventSource = null;
}
if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
}
// ── Audit log ────────────────────────────────────────────────────────────
/**
* Render the audit log table.
* @param {Array<object>} entries Array of audit entries (timestamp, action, username, vault, ip, ...).
*/
export function renderAuditTable(entries) {
const tbody = document.getElementById("admin-audit-tbody");
const empty = document.getElementById("admin-audit-empty");
if (!tbody) return;
if (!entries || !entries.length) {
tbody.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
tbody.innerHTML = entries.map((e) => {
const ts = formatDate(e.timestamp);
const action = escapeHtml(e.action || "—");
const user = escapeHtml(e.username || e.user || "—");
const vault = escapeHtml(e.vault || "—");
const ip = escapeHtml(e.ip || "—");
return `<tr>
<td>${escapeHtml(ts)}</td>
<td><code>${action}</code></td>
<td>${user}</td>
<td>${vault}</td>
<td>${ip}</td>
</tr>`;
}).join("");
}
/**
* Fetch /api/admin/audit with optional filters.
* @param {{user?: string, action?: string}} filters
*/
export async function loadAuditLogs(filters = {}) {
if (filters.user !== undefined) _auditFilters.user = filters.user;
if (filters.action !== undefined) _auditFilters.action = filters.action;
const params = new URLSearchParams();
if (_auditFilters.user) params.set("user", _auditFilters.user);
if (_auditFilters.action) params.set("action", _auditFilters.action);
params.set("limit", "200");
const qs = params.toString();
try {
const data = await api(`/api/admin/audit?${qs}`);
renderAuditTable(data.entries || []);
} catch (err) {
_showInlineError("admin-audit-tbody", err.message || "audit load failed");
}
}
/** Populate the action <select> with the values we know about.
* The endpoint doesn't list all possible actions, so we hardcode the
* common ones and let the user pick "All". */
function _populateAuditActions() {
const select = document.getElementById("admin-audit-action");
if (!select) return;
const ACTIONS = [
"file_save", "file_delete", "file_view", "file_rename",
"share_create", "share_revoke", "auth_login", "auth_logout",
"auth_failed", "user_create", "user_update", "user_delete",
"backup_create", "config_update",
];
select.innerHTML =
`<option value="">${escapeHtml(t("admin.audit_all_actions"))}</option>` +
ACTIONS.map((a) => `<option value="${a}">${escapeHtml(a)}</option>`).join("");
}
// ── Backups ─────────────────────────────────────────────────────────────
/**
* Render per-vault backup bars.
* @param {object} byVault { vault: {count, size_mb} }
*/
export function renderBackups(byVault) {
const tbody = document.getElementById("admin-backups-tbody");
const empty = document.getElementById("admin-backups-empty");
if (!tbody) return;
const entries = byVault ? Object.entries(byVault) : [];
if (!entries.length) {
tbody.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
// Largest vault for proportional bars
const maxSize = Math.max(...entries.map(([, v]) => v.size_mb || 0), 1);
tbody.innerHTML = entries
.sort((a, b) => (b[1].size_mb || 0) - (a[1].size_mb || 0))
.map(([vault, data]) => {
const count = data.count || 0;
const size = data.size_mb || 0;
const widthPct = Math.min((size / maxSize) * 100, 100).toFixed(1);
return `<tr>
<td>${escapeHtml(vault)}</td>
<td>${count}</td>
<td>${escapeHtml(formatBytes(size))}</td>
<td>
<div class="admin-backup-bar"><div class="admin-backup-bar-fill" style="width:${widthPct}%;"></div></div>
</td>
</tr>`;
})
.join("");
}
/** Fetch /api/admin/backup-stats and paint. */
export async function loadBackupStats() {
try {
const data = await api("/api/admin/backup-stats");
const summary = document.getElementById("admin-backups-summary");
if (summary) {
summary.innerHTML = `
<div>${escapeHtml(t("admin.backups_total", { count: data.total_backups ?? 0, size: formatBytes(data.total_size_mb) }))}</div>
<div>${escapeHtml(t("admin.backups_oldest", { days: (data.oldest_age_days ?? 0).toFixed(2) }))}</div>
<div>${escapeHtml(t("admin.backups_newest", { days: (data.newest_age_days ?? 0).toFixed(2) }))}</div>
`;
}
renderBackups(data.by_vault || {});
} catch (err) {
_showInlineError("admin-backups-tbody", err.message || "backups load failed");
}
}
// ── User management integration ─────────────────────────────────────────
/** Wire the "Manage users" button to open the existing AdminPanel modal. */
function _wireUserMgmt() {
const btn = document.getElementById("admin-users-manage-btn");
if (!btn) return;
btn.addEventListener("click", () => {
try {
AdminPanel.show();
} catch (err) {
console.error("AdminPanel.show failed", err);
}
});
}
// ── Filters wiring ──────────────────────────────────────────────────────
function _wireAuditFilters() {
const userInput = document.getElementById("admin-audit-user");
const actionSelect = document.getElementById("admin-audit-action");
const refreshBtn = document.getElementById("admin-audit-refresh");
if (refreshBtn) {
refreshBtn.addEventListener("click", () => loadAuditLogs());
}
if (userInput) {
userInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") loadAuditLogs();
});
}
if (actionSelect) {
actionSelect.addEventListener("change", () => loadAuditLogs());
}
}
// ── Section nav (scrollspy) ───────────────────────────────────────────
/**
* Wire the sticky section nav: smooth-scroll on click + highlight the section
* currently in view (IntersectionObserver). Falls back to default anchor jump
* if the observer is unavailable.
*/
function _wireSectionNav() {
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
if (!links.length) return;
const targets = links
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
.filter(Boolean);
// Smooth scroll on click
links.forEach((a) => {
a.addEventListener("click", (e) => {
const href = a.getAttribute("href");
if (!href || !href.startsWith("#")) return;
const t = document.getElementById(href.slice(1));
if (!t) return;
e.preventDefault();
t.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
// Scrollspy: mark the section currently at the top of the viewport
let active = null;
const setActive = (id) => {
if (active === id) return;
active = id;
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
};
if ("IntersectionObserver" in window) {
const io = new IntersectionObserver(
(entries) => {
entries.forEach((en) => {
if (en.isIntersecting) setActive(en.target.id);
});
},
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 }
);
targets.forEach((t) => io.observe(t));
} else {
// Fallback: highlight the first section only (no scroll observation)
links[0] && links[0].classList.add("active");
}
}
// ── Auth gate ────────────────────────────────────────────────────────────
/**
* Verify the current session and admin role.
* Uses /api/auth/me (which returns the current user) and falls back to
* the cached user in sessionStorage if the request fails.
* Redirects to / if not authenticated or not admin.
* Returns the user object on success, null otherwise.
*/
async function _gateAdmin() {
const container = document.getElementById("admin-main");
const forbidden = document.getElementById("admin-forbidden");
// First check whether auth is even enabled (public endpoint).
let authEnabled = false;
try {
const statusRes = await fetch("/api/auth/status", { credentials: "include" });
if (statusRes.ok) {
const status = await statusRes.json();
authEnabled = !!status.auth_enabled;
}
} catch { /* network error — fall through */ }
if (!authEnabled) {
// Server is wide open, treat current visitor as allowed.
return { username: "anonymous", role: "admin" };
}
// Auth is enabled — try to load the current user.
try {
const meRes = await fetch("/api/auth/me", {
credentials: "include",
headers: getAuthHeaders(),
});
if (meRes.status === 401 || meRes.status === 403) {
window.location.href = "/";
return null;
}
if (!meRes.ok) throw new Error("HTTP " + meRes.status);
const user = await meRes.json();
// Cache for later fallback
try {
sessionStorage.setItem("obsigate_user", JSON.stringify(user));
} catch { /* */ }
if (user.role !== "admin") {
if (container) container.style.display = "none";
if (forbidden) forbidden.style.display = "";
return null;
}
return user;
} catch (err) {
// Network error — try to use cached sessionStorage user as fallback
try {
const cached = sessionStorage.getItem("obsigate_user");
if (cached) {
const u = JSON.parse(cached);
if (u.role === "admin") return u;
}
} catch { /* */ }
window.location.href = "/";
return null;
}
}
// ── Init ─────────────────────────────────────────────────────────────────
/**
* Page entry point. Called on DOMContentLoaded by admin.html.
* Verifies auth, populates the action select, wires events, kicks off loads.
*/
export async function init() {
const user = await _gateAdmin();
if (!user) return;
// Show role badge
const badge = document.getElementById("admin-role-badge");
if (badge) {
badge.textContent = user.role || "user";
badge.dataset.role = user.role || "user";
}
_populateAuditActions();
_wireAuditFilters();
_wireUserMgmt();
_wireSectionNav();
// Initial loads (in parallel — none depends on another)
loadStatsOnce();
loadAuditLogs();
loadBackupStats();
// Then start the live SSE stream for stats
connectSSE();
}
+56
View File
@@ -0,0 +1,56 @@
// ObsiGate — Floating Action Button (FAB) for the AI Assistant sidebar.
// Always visible in the bottom-right corner; hides while the assistant panel
// is expanded. Clicking it opens the assistant in the context that matches
// the current application state (open documents, or General when none).
import booksLM from './bookslm.js';
import { t } from './i18n.js';
import { safeCreateIcons } from './utils.js';
let _fab = null;
// The panel is visible when it has `.open` without `.hidden`.
function isBooksLmVisible() {
const panel = document.querySelector('.bookslm-panel');
return !!(panel && panel.classList.contains('open') && !panel.classList.contains('hidden'));
}
function syncVisibility() {
if (!_fab) return;
const visible = isBooksLmVisible();
_fab.hidden = visible;
const label = visible ? t('fab.close') : t('fab.open');
_fab.title = label;
_fab.setAttribute('aria-label', label);
}
async function onClick() {
const panel = document.querySelector('.bookslm-panel');
// Collapsed panel: reveal it instead of reopening.
if (panel && panel.classList.contains('open') && panel.classList.contains('hidden')) {
booksLM._toggleSidebar();
return;
}
// Otherwise open (or re-focus) the assistant for the current context.
await booksLM.openForCurrentContext();
}
export function initAIFab() {
if (_fab) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.id = 'ai-fab';
btn.className = 'ai-fab';
btn.innerHTML = '<i data-lucide="bot" style="width:24px;height:24px"></i>';
document.body.appendChild(btn);
_fab = btn;
btn.addEventListener('click', onClick);
document.addEventListener('bookslm:opened', syncVisibility);
document.addEventListener('bookslm:closed', syncVisibility);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
syncVisibility();
}
export default initAIFab;
+309 -49
View File
@@ -13,7 +13,14 @@ import { t } from './i18n.js';
// ── API call helper ──
async function aiAction(endpoint, text, extra = {}) {
const body = { text, ...extra };
// Inject current provider + model from the per-section picker.
const pickerState = _readPicker();
const body = {
text,
...extra,
...(pickerState.provider ? { provider: pickerState.provider } : {}),
...(pickerState.model ? { model: pickerState.model } : {}),
};
const data = await api(`/api/ai/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -22,6 +29,165 @@ async function aiAction(endpoint, text, extra = {}) {
return data.result;
}
// ── Per-section provider/model picker ───────────────────────────────────────
// State is stored in localStorage so the user choice persists across sections.
const PICKER_STORAGE_KEY = 'obsigate_ai_picker';
function _readPicker() {
try {
return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}');
} catch { return {}; }
}
function _writePicker(state) {
try {
localStorage.setItem(PICKER_STORAGE_KEY, JSON.stringify(state));
} catch { /* */ }
}
/**
* Build a provider+model picker that appears in every section's AI toolbar
* (Forge editor, BooksLM, etc.). The picker reads /api/ai/status to discover
* available providers, then /api/config/ai-models?provider=X to list models.
*/
async function _buildPickerUI() {
const pickerState = _readPicker();
// Fetch configured providers
let availableProviders = {};
try {
const status = await api('/api/ai/status');
availableProviders = status.providers || {};
} catch { /* */ }
const providerNames = Object.keys(availableProviders).filter(
(p) => availableProviders[p]?.available
);
if (!providerNames.length) return null;
const wrap = document.createElement('div');
wrap.className = 'ai-picker';
Object.assign(wrap.style, {
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
marginLeft: '8px',
paddingLeft: '8px',
borderLeft: '1px solid var(--border)',
fontSize: '0.7rem',
color: 'var(--text-muted)',
flexShrink: '0',
zIndex: '1',
});
// Provider select
const providerLabel = document.createElement('span');
providerLabel.textContent = t('ai.provider') + ':';
wrap.appendChild(providerLabel);
const providerSelect = document.createElement('select');
providerSelect.className = 'ai-picker-select';
Object.assign(providerSelect.style, {
fontSize: '0.7rem',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: '4px',
padding: '2px 4px',
cursor: 'pointer',
});
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = t('ai.provider_default');
providerSelect.appendChild(defaultOpt);
providerNames.forEach((p) => {
const opt = document.createElement('option');
opt.value = p;
opt.textContent = p;
if (p === pickerState.provider) opt.selected = true;
providerSelect.appendChild(opt);
});
// Model select (sibling of provider)
const modelSelect = document.createElement('select');
modelSelect.className = 'ai-picker-model';
Object.assign(modelSelect.style, {
fontSize: '0.7rem',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: '4px',
padding: '2px 4px',
cursor: 'pointer',
minWidth: '120px',
maxWidth: '220px',
});
const placeholderOpt = document.createElement('option');
placeholderOpt.value = '';
placeholderOpt.textContent = t('ai.model_default');
modelSelect.appendChild(placeholderOpt);
async function _loadModels(provider) {
modelSelect.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = t('ai.model_loading');
modelSelect.appendChild(ph);
try {
const data = await api(`/api/config/ai-models?provider=${encodeURIComponent(provider)}`);
modelSelect.innerHTML = '';
const def = document.createElement('option');
def.value = '';
def.textContent = t('ai.model_default') + (data.source === 'fallback' ? ` (${t('ai.model_offline')})` : '');
modelSelect.appendChild(def);
(data.models || []).forEach((m) => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
if (m === pickerState.model && provider === pickerState.provider) opt.selected = true;
modelSelect.appendChild(opt);
});
} catch (err) {
modelSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('ai.model_load_error');
modelSelect.appendChild(opt);
}
}
providerSelect.addEventListener('change', () => {
pickerState.provider = providerSelect.value || null;
// Reset model when provider changes
pickerState.model = null;
_writePicker(pickerState);
if (pickerState.provider) {
_loadModels(pickerState.provider);
} else {
modelSelect.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = t('ai.model_default');
modelSelect.appendChild(ph);
}
});
modelSelect.addEventListener('change', () => {
pickerState.model = modelSelect.value || null;
_writePicker(pickerState);
});
// Load models for the initial provider selection
if (pickerState.provider && providerNames.includes(pickerState.provider)) {
_loadModels(pickerState.provider);
}
wrap.appendChild(providerSelect);
wrap.appendChild(modelSelect);
return wrap;
}
// ── Get selected text from CodeMirror ──
function getSelection(editorView) {
if (!editorView) return '';
@@ -80,12 +246,12 @@ function createMenu(items, parentEl) {
const menuLeft = rect.left;
Object.assign(menu.style, {
position: 'fixed',
top: menuTop + 'px',
left: menuLeft + 'px',
background: 'var(--bg-primary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
position: 'fixed',
top: menuTop + 'px',
left: menuLeft + 'px',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: '6px',
padding: '4px 0',
minWidth: '200px',
maxHeight: '60vh',
@@ -172,12 +338,12 @@ function createSubMenu(items, parentEl) {
const menu = document.createElement('div');
menu.className = 'ai-submenu';
Object.assign(menu.style, {
position: 'absolute',
left: '100%',
top: '0',
background: 'var(--bg-primary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
position: 'absolute',
left: '100%',
top: '0',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: '6px',
padding: '4px 0',
minWidth: '180px',
zIndex: '101',
@@ -210,6 +376,76 @@ function createSubMenu(items, parentEl) {
}
// ── Main AI Toolbar ──
let _activeAiBtn = null;
let _aiShortcutBound = false;
/**
* Bind the Ctrl/Cmd+J inline-completion shortcut exactly once, regardless of
* how many times the editor (and therefore the toolbar) is re-created.
* Without this guard every editor open added a new listener, so pressing the
* shortcut fired the AI request N times.
*/
function _bindAiShortcut() {
if (_aiShortcutBound) return;
_aiShortcutBound = true;
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') {
const modal = document.getElementById('editor-modal');
if (!modal || !modal.classList.contains('active')) return;
if (!_activeAiBtn) return;
e.preventDefault();
_activeAiBtn.click();
}
});
}
/**
* Professional replacement for `window.prompt()` for the custom-rewrite
* instruction. Returns a Promise resolving to the entered instruction or
* `null` when cancelled. Escape cancels, Ctrl/Cmd+Enter submits.
*/
function _promptRewriteInstruction() {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'ai-modal-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', t('ai.rewrite_title'));
const modal = document.createElement('div');
modal.className = 'ai-modal';
modal.innerHTML = `
<h3 class="ai-modal-title">${t('ai.custom_rewrite')}</h3>
<p class="ai-modal-hint">${t('ai.rewrite_prompt')}</p>
<textarea class="ai-modal-input" rows="3" placeholder="${t('ai.rewrite_placeholder')}"></textarea>
<div class="ai-modal-actions">
<button type="button" class="ai-modal-btn ai-modal-cancel">${t('button.cancel')}</button>
<button type="button" class="ai-modal-btn primary ai-modal-ok">${t('ai.rewrite')}</button>
</div>`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
const input = modal.querySelector('.ai-modal-input');
const close = (value) => {
overlay.remove();
document.removeEventListener('keydown', onKey, true);
resolve(value);
};
const onKey = (e) => {
if (e.key === 'Escape') { e.preventDefault(); close(null); }
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
close(input.value.trim() || null);
}
};
document.addEventListener('keydown', onKey, true);
modal.querySelector('.ai-modal-cancel').addEventListener('click', () => close(null));
modal.querySelector('.ai-modal-ok').addEventListener('click', () => close(input.value.trim() || null));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); });
input.focus();
});
}
export async function createAIToolbar(container, getEditorView) {
// Check if AI is configured
let aiConfigured = false;
@@ -220,7 +456,7 @@ export async function createAIToolbar(container, getEditorView) {
if (!aiConfigured) {
const hint = document.createElement('div');
hint.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border-color)';
hint.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border)';
hint.textContent = t('ai.not_configured');
container.appendChild(hint);
return;
@@ -233,7 +469,7 @@ export async function createAIToolbar(container, getEditorView) {
alignItems: 'center',
gap: '2px',
padding: '4px 8px',
borderBottom: '1px solid var(--border-color)',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-secondary)',
flexWrap: 'wrap',
position: 'sticky',
@@ -258,6 +494,7 @@ export async function createAIToolbar(container, getEditorView) {
borderRadius: '4px',
});
aiBtn.title = t('ai.inline_completion_title');
aiBtn.setAttribute('aria-label', t('ai.inline_completion_title'));
aiBtn.addEventListener('click', async () => {
const v = ev();
if (!v) return;
@@ -279,22 +516,22 @@ export async function createAIToolbar(container, getEditorView) {
});
// ── Edit menu ──
const editBtn = createDropdownBtn(t('ai.edit'), [
{ label: '🪄 Improve writing', action: () => action('improve'), hint: t('ai.hint_improve') },
{ label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling'), hint: 'Corrige les fautes' },
{ label: '📏 Make shorter', action: () => action('make-shorter'), hint: 'Rend le texte plus concis' },
{ label: '📐 Make longer', action: () => action('make-longer'), hint: t('ai.hint_add_details') },
{ label: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' },
const editBtn = createDropdownBtn(t('ai.toolbar_edit'), [
{ label: '🪄 ' + t('ai.improve'), action: () => action('improve'), hint: t('ai.hint_improve') },
{ label: '🔤 ' + t('ai.fix_spelling'), action: () => action('fix-spelling'), hint: t('ai.fix_spelling_hint') },
{ label: '📏 ' + t('ai.shorter'), action: () => action('make-shorter'), hint: t('ai.shorter_hint') },
{ label: '📐 ' + t('ai.longer'), action: () => action('make-longer'), hint: t('ai.hint_add_details') },
{ label: '📋 ' + t('ai.simplify'), action: () => action('simplify'), hint: t('ai.simplify_hint') },
], t('ai.hint_edit_selection'));
// ── Tone menu ──
const toneBtn = createDropdownBtn('Ton', [
{ label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) },
{ label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) },
], 'Change le ton du texte');
const toneBtn = createDropdownBtn(t('ai.toolbar_tone'), [
{ label: '💼 ' + t('ai.professional'), action: () => action('tone', { tone: 'professional' }) },
{ label: '💬 ' + t('ai.casual'), action: () => action('tone', { tone: 'casual' }) },
], t('ai.tone_hint'));
// ── Translate menu ──
const translateBtn = createDropdownBtn('Traduire', [
const translateBtn = createDropdownBtn(t('ai.toolbar_translate'), [
{ label: '🇬🇧 English', action: () => action('translate', { target_lang: 'English' }) },
{ label: '🇨🇳 Chinese', action: () => action('translate', { target_lang: 'Chinese' }) },
{ label: '🇯🇵 Japanese', action: () => action('translate', { target_lang: 'Japanese' }) },
@@ -304,17 +541,18 @@ export async function createAIToolbar(container, getEditorView) {
], t('ai.hint_translate'));
// ── Generate menu ──
const genBtn = createDropdownBtn(t('ai.generate'), [
{ label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') },
{ label: '📝 Summarize', action: () => action('summarize', {}, 'append') },
{ label: '✏️ Continue writing', action: () => action('continue', {}, 'append') },
const genBtn = createDropdownBtn(t('ai.toolbar_generate'), [
{ label: 'ℹ️ ' + t('ai.explain'), action: () => action('explain', {}, 'append') },
{ label: '📝 ' + t('ai.summarize'), action: () => action('summarize', {}, 'append') },
{ label: '✏️ ' + t('ai.continue'), action: () => action('continue', {}, 'append') },
], t('ai.hint_generate'));
// ── Custom Rewrite ──
const rewriteBtn = document.createElement('button');
rewriteBtn.className = 'ai-toolbar-btn';
rewriteBtn.innerHTML = t('ai.rewrite');
rewriteBtn.innerHTML = t('ai.toolbar_rewrite');
rewriteBtn.title = t('ai.rewrite_title');
rewriteBtn.setAttribute('aria-label', t('ai.rewrite_title'));
Object.assign(rewriteBtn.style, {
fontSize: '0.7rem',
background: 'transparent',
@@ -328,24 +566,31 @@ export async function createAIToolbar(container, getEditorView) {
const v = ev();
if (!v) return;
const text = getSelection(v);
const instruction = prompt(t('ai.rewrite_prompt'), '');
if (!text.trim()) {
showToast(t('ai.select_text'), 'warning');
return;
}
const instruction = await _promptRewriteInstruction();
if (!instruction) return;
rewriteBtn.disabled = true;
try {
const result = await aiAction('rewrite', text, { instruction });
replaceSelection(v, result);
showToast(t('ai.text_rewritten'), 'success');
} catch (e) {
showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error');
} finally {
rewriteBtn.disabled = false;
}
});
// ── Toolbox menu ──
const toolboxBtn = createDropdownBtn(t('ai.toolbox'), [
{ label: '📋 Convert to list', action: () => action('to-list') },
{ label: '📊 Convert to table', action: () => action('to-table') },
{ label: '⚙️ Generate frontmatter', action: () => action('frontmatter', {}, 'before') },
{ label: '🔷 Convert to canvas', action: () => action('to-canvas', {}, 'append') },
], 'Outils de conversion');
const toolboxBtn = createDropdownBtn(t('ai.toolbar_toolbox'), [
{ label: '📋 ' + t('ai.to_list'), action: () => action('to-list') },
{ label: '📊 ' + t('ai.to_table'), action: () => action('to-table') },
{ label: '⚙️ ' + t('ai.frontmatter'), action: () => action('frontmatter', {}, 'before') },
{ label: '🔷 ' + t('ai.to_canvas'), action: () => action('to-canvas', {}, 'append') },
], t('ai.toolbox_hint'));
toolbar.appendChild(aiBtn);
toolbar.appendChild(createSeparator());
@@ -357,6 +602,13 @@ export async function createAIToolbar(container, getEditorView) {
toolbar.appendChild(rewriteBtn);
toolbar.appendChild(toolboxBtn);
// Append the per-section provider/model picker on the right.
const picker = await _buildPickerUI();
if (picker) {
toolbar.appendChild(createSeparator());
toolbar.appendChild(picker);
}
container.insertBefore(toolbar, container.firstChild);
// ── Action helper ──
@@ -404,9 +656,10 @@ export async function createAIToolbar(container, getEditorView) {
el.id = 'ai-processing-indicator';
el.className = 'ai-processing-indicator';
el.innerHTML = '<span class="ai-spinner"></span> AI...';
el.title = 'Traitement AI en cours';
el.title = t('ai.processing');
el.setAttribute('role', 'status');
const header = document.querySelector('#editor-modal .editor-header');
if (header) header.appendChild(el);
(header || toolbar).appendChild(el);
}
el.style.display = 'inline-flex';
} else if (el) {
@@ -414,14 +667,8 @@ export async function createAIToolbar(container, getEditorView) {
}
}
// ── Keyboard shortcut Ctrl+J for inline completion ──
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'j') {
const modal = document.getElementById('editor-modal');
if (!modal || !modal.classList.contains('active')) return;
e.preventDefault();
aiBtn.click();
}
});
_activeAiBtn = aiBtn;
_bindAiShortcut();
return toolbar;
}
@@ -431,6 +678,8 @@ function createDropdownBtn(text, items, tooltip = '') {
btn.className = 'ai-toolbar-btn';
btn.innerHTML = text + ' ▾';
btn.title = tooltip || text;
btn.setAttribute('aria-haspopup', 'true');
btn.setAttribute('aria-expanded', 'false');
Object.assign(btn.style, {
position: 'relative',
fontSize: '0.7rem',
@@ -452,6 +701,17 @@ function createDropdownBtn(text, items, tooltip = '') {
function createSeparator() {
const sep = document.createElement('span');
sep.style.cssText = 'width:1px;height:16px;background:var(--border-color);margin:0 2px';
sep.style.cssText = 'width:1px;height:16px;background:var(--border);margin:0 2px';
return sep;
}
// ── Public exports ────────────────────────────────────────────────────────
// Other modules (e.g. BooksLM) can import the picker helpers to render the
// same per-section provider/model picker in their own toolbar.
export {
_readPicker,
_writePicker,
PICKER_STORAGE_KEY,
_buildPickerUI as buildAIPickerUI,
_promptRewriteInstruction,
};
+24 -14
View File
@@ -5,6 +5,7 @@
import * as UI from './ui.js';
import * as Utils from './utils.js';
import { initI18n, t } from './i18n.js';
import { initAIFab } from './ai-fab.js';
// Wire up AI toolbar toast (avoids circular import in utils.js)
window._obsigateShowToast = UI.showToast;
@@ -19,6 +20,10 @@ import { initMobileToolbar } from './mobile-toolbar.js';
import { initDragDrop } from './dragdrop.js';
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
import PaneManager from './pane-manager.js';
import { initDesktopIntegration, isTauriEnv } from './desktop.js';
import { initPush } from './push.js';
import { initPlugins } from './plugins.js';
import { populateVersions } from './config.js';
// =========================================================================
// Initialization — mirrors the original app.js init() ordering
@@ -29,6 +34,10 @@ async function init() {
// LocalStorage has correct language if user set it previously
await initI18n();
// 1b. Desktop bridge — crash banner + OS theme sync (before theme engine).
// No-op on web (Docker); on desktop it also readies the vault picker.
await initDesktopIntegration();
// 2. Init auth — now t() works inside showApp()/renderUserMenu()
const authOk = await Auth.AuthManager.initAuth();
@@ -61,7 +70,9 @@ async function init() {
UI.TabManager.init();
PaneManager.init();
initCommandPalette();
initMobileToolbar();
initMobileToolbar();
initPush();
initPlugins();
if (authOk) {
Legacy.initSyncStatus();
@@ -99,27 +110,26 @@ async function init() {
initMermaid();
setupFocusMode();
Utils.safeCreateIcons();
initAIFab();
}
document.addEventListener("DOMContentLoaded", () => {
document.addEventListener("DOMContentLoaded", async () => {
try {
init();
await init();
Sync.init();
} finally {
// Signale au splash de démarrage (index.html #boot-splash) que l'app est
// initialisée : le splash est retiré et le squelette stylé révélé.
// Set AFTER init() resolves so every handler (search, tabs, …) is bound.
window.__OBSIGATE_BOOTED = true;
if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady();
}
// Show version in header
fetch('/api/health')
.then(r => r.json())
.then(d => {
const badge = document.getElementById('version-badge');
if (badge && d.version) {
badge.textContent = d.version;
badge.style.display = 'inline-block';
}
})
.catch(() => {});
// Show version in header — always visible, fallback before the async fetch
populateVersions();
fetch('/api/health')
.then(r => r.json())
.then(d => {
if (d && d.version) populateVersions(d.version);
})
.catch(() => {});
});
+748 -31
View File
@@ -139,10 +139,122 @@ const AuthManager = {
throw new Error(err.detail || "Erreur de connexion");
}
const data = await response.json();
// If MFA is required, return the MFA challenge instead of saving token
if (data.mfa_required) {
return data;
}
this.saveToken(data);
return data.user;
},
async verifyMfa(username, code, rememberMe) {
const response = await fetch("/api/auth/mfa/totp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, code, remember_me: rememberMe || false }),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Code invalide");
}
const data = await response.json();
this.saveToken(data);
return data.user;
},
async verifyRecovery(username, recoveryCode) {
const response = await fetch("/api/auth/mfa/recovery", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, recovery_code: recoveryCode }),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Code invalide");
}
const data = await response.json();
this.saveToken(data);
return data.user;
},
// ── WebAuthn (ROADMAP #64) ─────────────────────────────────────────
async webauthnLoginOptions(username) {
const resp = await fetch("/api/auth/mfa/webauthn/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username }),
});
if (!resp.ok) throw new Error((await resp.json()).detail || "WebAuthn indisponible");
return await resp.json();
},
async verifyWebauthn(username, credential, rememberMe) {
const response = await fetch("/api/auth/mfa/webauthn/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, credential, remember_me: rememberMe || false }),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Vérification WebAuthn échouée");
}
const data = await response.json();
this.saveToken(data);
return data.user;
},
async webauthnRegisterOptions() {
return await api("/api/auth/mfa/webauthn/register/options", { method: "POST" });
},
async webauthnRegister(credential, label) {
return await api("/api/auth/mfa/webauthn/register", {
method: "POST",
body: JSON.stringify({ credential, label }),
});
},
async webauthnCredentials() {
return await api("/api/auth/mfa/webauthn/credentials");
},
async webauthnRemove(credentialId, password) {
return await api("/api/auth/mfa/webauthn/credentials/remove", {
method: "POST",
body: JSON.stringify({ credential_id: credentialId, password }),
});
},
// ── MFA Setup API calls ──────────────────────────────────────────
async getMfaStatus() {
const resp = await api("/api/auth/mfa/status");
return resp;
},
async mfaSetup() {
return await api("/api/auth/mfa/totp/setup", { method: "POST" });
},
async mfaEnable(code) {
return await api("/api/auth/mfa/totp/enable", {
method: "POST",
body: JSON.stringify({ code }),
});
},
async mfaDisable(password, code) {
return await api("/api/auth/mfa/totp/disable", {
method: "POST",
body: JSON.stringify({ password, code }),
});
},
async logout() {
try {
const token = this.getToken();
@@ -216,7 +328,7 @@ const AuthManager = {
// Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before
adminRow.onclick = () => {
closeHeaderMenu();
AdminPanel.show();
window.location.href = "/admin.html";
};
} else {
adminRow.classList.add("hidden");
@@ -278,6 +390,343 @@ const AuthManager = {
};
// ---------------------------------------------------------------------------
// Post-login setup (shared between normal login and MFA login)
// ---------------------------------------------------------------------------
async function _onLoginSuccess() {
AuthManager.showApp();
// Re-sync language from server now that we're authenticated
try {
const resp = await fetch('/api/auth/me', { credentials: 'include' });
if (resp.ok) {
const user = await resp.json();
if (user.language && user.language !== getLocale()) {
await setLocale(user.language);
}
}
} catch (e) { /* non-bloquant */ }
// Load app data after successful login
try {
const { loadVaults, loadTags } = await import('./sidebar.js');
await Promise.all([loadVaults(), loadTags()]);
const { IndexUpdateManager } = await import('./sync.js');
IndexUpdateManager.connect();
const { syncFileIndexFromServer } = await import('./offline.js');
syncFileIndexFromServer();
showWelcome();
} catch (err) {
console.error("Failed to load data after login:", err);
}
}
// ---------------------------------------------------------------------------
// MFA Challenge UI (TOTP code input during login)
// ---------------------------------------------------------------------------
// base64url <-> ArrayBuffer helpers for WebAuthn (ROADMAP #64)
function _b64urlToBuf(s) {
const pad = "=".repeat((4 - (s.length % 4)) % 4);
const b = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(b);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
function _bufToB64url(buf) {
const bytes = new Uint8Array(buf);
let s = "";
for (const c of bytes) s += String.fromCharCode(c);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function runWebauthnCeremony(optionsJson) {
const pkOptions = {
challenge: _b64urlToBuf(optionsJson.challenge),
timeout: optionsJson.timeout || 60000,
rpId: optionsJson.rpId,
userVerification: optionsJson.userVerification || "preferred",
allowCredentials: (optionsJson.allowCredentials || []).map((c) => ({
type: c.type || "public-key",
id: _b64urlToBuf(c.id),
transports: c.transports,
})),
};
const cred = await navigator.credentials.get({ publicKey: pkOptions });
if (!cred) throw new Error(t("mfa.webauthn_cancelled"));
return {
id: cred.id,
rawId: _bufToB64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: _bufToB64url(cred.response.clientDataJSON),
authenticatorData: _bufToB64url(cred.response.authenticatorData),
signature: _bufToB64url(cred.response.signature),
userHandle: cred.response.userHandle ? _bufToB64url(cred.response.userHandle) : "",
},
};
}
async function runWebauthnRegistration() {
const resp = await AuthManager.webauthnRegisterOptions();
const o = resp.options;
const pkOptions = {
rp: { id: o.rpId || o.rp.id, name: o.rp.name },
challenge: _b64urlToBuf(o.challenge),
user: {
id: _b64urlToBuf(o.user.id),
name: o.user.name,
displayName: o.user.displayName,
},
pubKeyCredParams: o.pubKeyCredParams,
timeout: o.timeout || 60000,
authenticatorSelection: o.authenticatorSelection || undefined,
attestation: "none",
};
const cred = await navigator.credentials.create({ publicKey: pkOptions });
if (!cred) throw new Error(t("mfa.webauthn_cancelled"));
const credential = {
id: cred.id,
rawId: _bufToB64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: _bufToB64url(cred.response.clientDataJSON),
attestationObject: _bufToB64url(cred.response.attestationObject),
},
};
if (cred.response.getTransports) {
credential.response.transports = Array.from(cred.response.getTransports());
}
return credential;
}
function _startWebauthnLogin(mfaSection, username, rememberMe) {
const btn = mfaSection.querySelector("#mfa-webauthn-btn");
const errorEl = mfaSection.querySelector("#mfa-error");
if (btn) btn.disabled = true;
if (errorEl) errorEl.classList.add("hidden");
(async () => {
try {
const resp = await AuthManager.webauthnLoginOptions(username);
if (resp.mfa_method !== "webauthn" || !resp.options) {
throw new Error(t("mfa.webauthn_no_key"));
}
const credential = await runWebauthnCeremony(resp.options);
await AuthManager.verifyWebauthn(username, credential, rememberMe);
mfaSection.classList.add("hidden");
const loginForm = document.getElementById("login-form");
if (loginForm) loginForm.classList.remove("hidden");
await _onLoginSuccess();
} catch (err) {
if (errorEl) {
errorEl.textContent = err.message || String(err);
errorEl.classList.remove("hidden");
}
} finally {
if (btn) btn.disabled = false;
}
})();
}
function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) {
const loginBox = document.querySelector(".login-box");
if (!loginBox) return;
// Hide the normal login form
const loginForm = document.getElementById("login-form");
if (loginForm) loginForm.classList.add("hidden");
// Create MFA challenge UI
let mfaSection = document.getElementById("mfa-challenge");
if (!mfaSection) {
mfaSection = document.createElement("div");
mfaSection.id = "mfa-challenge";
mfaSection.className = "mfa-challenge";
loginBox.appendChild(mfaSection);
}
// WebAuthn second factor: key prompt instead of TOTP code input
if (mfaMethod === "webauthn") {
mfaSection.innerHTML = `
<div class="mfa-icon">🔑</div>
<h3>${t('mfa.title')}</h3>
<p class="mfa-subtitle">${t('mfa.webauthn_prompt')}</p>
<p class="mfa-error hidden" id="mfa-error"></p>
<button type="button" class="btn-login" id="mfa-webauthn-btn">
<span class="btn-text">${t('mfa.webauthn_btn')}</span>
</button>
<div class="mfa-actions">
<button type="button" class="mfa-link-btn" id="mfa-use-recovery">${t('mfa.use_recovery')}</button>
<button type="button" class="mfa-link-btn" id="mfa-back-login">${t('mfa.back_to_login')}</button>
</div>
`;
mfaSection.classList.remove("hidden");
document.getElementById("mfa-webauthn-btn").addEventListener("click", () => {
_startWebauthnLogin(mfaSection, username, rememberMe);
});
document.getElementById("mfa-use-recovery").addEventListener("click", () => {
showRecoveryChallenge(username, rememberMe, loginForm, mfaSection);
});
document.getElementById("mfa-back-login").addEventListener("click", () => {
mfaSection.classList.add("hidden");
if (loginForm) loginForm.classList.remove("hidden");
});
// Auto-start the ceremony — the browser shows its own dialog
_startWebauthnLogin(mfaSection, username, rememberMe);
return;
}
mfaSection.innerHTML = `
<div class="mfa-icon">🔐</div>
<h3>${t('mfa.title')}</h3>
<p class="mfa-subtitle">${t('mfa.subtitle')}</p>
<form id="mfa-form">
<div class="mfa-code-input-group">
<input type="text" id="mfa-code" class="mfa-code-input" maxlength="6"
pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code"
placeholder="000000" autofocus required>
</div>
<p class="mfa-error hidden" id="mfa-error"></p>
<button type="submit" class="btn-login" id="mfa-verify-btn">
<span class="btn-text">${t('mfa.verify')}</span>
<span class="btn-spinner hidden">⏳</span>
</button>
</form>
<div class="mfa-actions">
<button type="button" class="mfa-link-btn" id="mfa-use-recovery">${t('mfa.use_recovery')}</button>
<button type="button" class="mfa-link-btn" id="mfa-back-login">${t('mfa.back_to_login')}</button>
</div>
`;
mfaSection.classList.remove("hidden");
const codeInput = document.getElementById("mfa-code");
codeInput.focus();
// Auto-submit when 6 digits entered
codeInput.addEventListener("input", () => {
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
if (codeInput.value.length === 6) {
document.getElementById("mfa-verify-btn").click();
}
});
// Handle MFA form submit
document.getElementById("mfa-form").addEventListener("submit", async (e) => {
e.preventDefault();
const code = codeInput.value.trim();
if (code.length !== 6) return;
const btn = document.getElementById("mfa-verify-btn");
const errorEl = document.getElementById("mfa-error");
btn.disabled = true;
btn.querySelector(".btn-spinner").classList.remove("hidden");
btn.querySelector(".btn-text").textContent = t('mfa.verifying');
errorEl.classList.add("hidden");
try {
await AuthManager.verifyMfa(username, code, rememberMe);
mfaSection.classList.add("hidden");
if (loginForm) loginForm.classList.remove("hidden");
await _onLoginSuccess();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove("hidden");
codeInput.value = "";
codeInput.focus();
} finally {
btn.disabled = false;
btn.querySelector(".btn-spinner").classList.add("hidden");
btn.querySelector(".btn-text").textContent = t('mfa.verify');
}
});
// Switch to recovery code input
document.getElementById("mfa-use-recovery").addEventListener("click", () => {
showRecoveryChallenge(username, rememberMe, loginForm, mfaSection);
});
// Back to login
document.getElementById("mfa-back-login").addEventListener("click", () => {
mfaSection.classList.add("hidden");
if (loginForm) loginForm.classList.remove("hidden");
});
}
function showRecoveryChallenge(username, rememberMe, loginForm, mfaSection) {
mfaSection.innerHTML = `
<div class="mfa-icon">🔑</div>
<h3>${t('mfa.recovery_title')}</h3>
<p class="mfa-subtitle">${t('mfa.recovery_subtitle')}</p>
<form id="recovery-form">
<div class="mfa-code-input-group">
<input type="text" id="recovery-code" class="mfa-code-input recovery-input" maxlength="9"
autocomplete="off" placeholder="XXXX-XXXX" autofocus required>
</div>
<p class="mfa-error hidden" id="recovery-error"></p>
<button type="submit" class="btn-login" id="recovery-verify-btn">
<span class="btn-text">${t('mfa.verify')}</span>
<span class="btn-spinner hidden">⏳</span>
</button>
</form>
<div class="mfa-actions">
<button type="button" class="mfa-link-btn" id="recovery-use-totp">${t('mfa.use_totp')}</button>
<button type="button" class="mfa-link-btn" id="recovery-back-login">${t('mfa.back_to_login')}</button>
</div>
`;
const codeInput = document.getElementById("recovery-code");
codeInput.focus();
// Auto-format: insert dash after 4 chars
codeInput.addEventListener("input", () => {
let v = codeInput.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
if (v.length > 4) v = v.slice(0, 4) + "-" + v.slice(4, 8);
codeInput.value = v;
});
document.getElementById("recovery-form").addEventListener("submit", async (e) => {
e.preventDefault();
const code = codeInput.value.trim();
const btn = document.getElementById("recovery-verify-btn");
const errorEl = document.getElementById("recovery-error");
btn.disabled = true;
btn.querySelector(".btn-spinner").classList.remove("hidden");
btn.querySelector(".btn-text").textContent = t('mfa.verifying');
errorEl.classList.add("hidden");
try {
await AuthManager.verifyRecovery(username, code);
mfaSection.classList.add("hidden");
if (loginForm) loginForm.classList.remove("hidden");
await _onLoginSuccess();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove("hidden");
codeInput.value = "";
codeInput.focus();
} finally {
btn.disabled = false;
btn.querySelector(".btn-spinner").classList.add("hidden");
btn.querySelector(".btn-text").textContent = t('mfa.verify');
}
});
document.getElementById("recovery-use-totp").addEventListener("click", () => {
showMfaChallenge(username, rememberMe, null, null);
});
document.getElementById("recovery-back-login").addEventListener("click", () => {
mfaSection.classList.add("hidden");
if (loginForm) loginForm.classList.remove("hidden");
});
}
// ---------------------------------------------------------------------------
// Login form handler
// ---------------------------------------------------------------------------
@@ -300,36 +749,14 @@ function initLoginForm() {
errorEl.classList.add("hidden");
try {
await AuthManager.login(username, password, rememberMe);
AuthManager.showApp();
// Re-sync language from server now that we're authenticated
try {
const resp = await fetch('/api/auth/me', { credentials: 'include' });
if (resp.ok) {
const user = await resp.json();
if (user.language && user.language !== getLocale()) {
await setLocale(user.language);
}
}
} catch (e) { /* non-bloquant */ }
// Load app data after successful login
try {
// Dynamic imports to avoid circular dependency with sidebar.js
const { loadVaults, loadTags } = await import('./sidebar.js');
await Promise.all([loadVaults(), loadTags()]);
// Start SSE sync now that auth cookie is set (dynamic import to avoid circular dep)
const { IndexUpdateManager } = await import('./sync.js');
IndexUpdateManager.connect();
// Sync offline file index now that we're authenticated
const { syncFileIndexFromServer } = await import('./offline.js');
syncFileIndexFromServer();
// Show dashboard
showWelcome();
} catch (err) {
console.error("Failed to load data after login:", err);
const result = await AuthManager.login(username, password, rememberMe);
// Check if MFA is required
if (result && result.mfa_required) {
showMfaChallenge(result.username, rememberMe, btn, errorEl, result.mfa_method);
return;
}
// Normal login success
await _onLoginSuccess();
safeCreateIcons();
} catch (err) {
errorEl.textContent = err.message;
@@ -576,4 +1003,294 @@ const AdminPanel = {
};
export { api, AuthManager, initLoginForm, AdminPanel };
// ---------------------------------------------------------------------------
// MFA Settings — Setup/Disable UI in the settings panel
// ---------------------------------------------------------------------------
async function initMfaSettings() {
const area = document.getElementById("mfa-setup-area");
const badge = document.getElementById("mfa-status-badge");
if (!area || !badge) return;
// Check current MFA status
let mfaEnabled = false;
try {
const status = await AuthManager.getMfaStatus();
mfaEnabled = status.mfa_enabled;
} catch (e) {
// Not logged in or error
return;
}
if (mfaEnabled) {
badge.textContent = t("mfa.enabled");
badge.className = "mfa-badge mfa-badge-on";
area.innerHTML = `
<p class="mfa-info-text" data-i18n="mfa.enabled_desc">${t("mfa.enabled_desc")}</p>
<button class="config-btn-secondary" id="mfa-disable-btn">${t("mfa.disable_btn")}</button>
<div id="mfa-disable-form-area"></div>
`;
document.getElementById("mfa-disable-btn").addEventListener("click", () => {
_showDisableMfaForm();
});
} else {
badge.textContent = t("mfa.disabled");
badge.className = "mfa-badge mfa-badge-off";
area.innerHTML = `
<p class="mfa-info-text" data-i18n="mfa.setup_desc">${t("mfa.setup_desc")}</p>
<button class="config-btn-primary" id="mfa-enable-btn">${t("mfa.enable_btn")}</button>
<div id="mfa-setup-flow-area"></div>
`;
document.getElementById("mfa-enable-btn").addEventListener("click", () => {
_startMfaSetup();
});
}
// WebAuthn security keys section (ROADMAP #64)
_renderWebauthnSection(area);
}
async function _renderWebauthnSection(container) {
if (!container || !window.PublicKeyCredential) return;
let keys = [];
try {
const resp = await AuthManager.webauthnCredentials();
keys = resp.credentials || [];
} catch (e) {
return; // auth disabled or endpoint unreachable — hide section
}
let section = document.getElementById("webauthn-settings");
if (!section) {
section = document.createElement("div");
section.id = "webauthn-settings";
section.className = "webauthn-settings";
container.appendChild(section);
}
const listHtml = keys.length
? `<ul class="webauthn-key-list">${keys.map((k) => `
<li class="webauthn-key-item">
<span class="webauthn-key-label">🔑 ${k.label || "Security key"}</span>
<span class="webauthn-key-meta">${(k.transports || []).join(", ") || "—"}</span>
<button class="config-btn-secondary config-btn-sm webauthn-key-remove"
data-id="${k.credential_id}">${t("mfa.webauthn_remove")}</button>
</li>`).join("")}</ul>`
: `<p class="mfa-info-text">${t("mfa.webauthn_none")}</p>`;
section.innerHTML = `
<h4 class="webauthn-title">${t("mfa.webauthn_title")}</h4>
<p class="mfa-info-text">${t("mfa.webauthn_desc")}</p>
${listHtml}
<div class="mfa-recovery-actions">
<button class="config-btn-primary" id="webauthn-add-btn">${t("mfa.webauthn_add")}</button>
</div>
<p class="mfa-error hidden" id="webauthn-error"></p>
<div id="webauthn-flow-area"></div>
`;
const errEl = section.querySelector("#webauthn-error");
document.getElementById("webauthn-add-btn").addEventListener("click", async () => {
errEl.classList.add("hidden");
try {
const credential = await runWebauthnRegistration();
const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé");
const result = await AuthManager.webauthnRegister(credential, label || "Security key");
if (result.recovery_codes && result.recovery_codes.length) {
_showRecoveryCodes(result.recovery_codes);
} else {
showToast(t("mfa.webauthn_added"), "success");
}
initMfaSettings();
} catch (err) {
errEl.textContent = err.message || String(err);
errEl.classList.remove("hidden");
}
});
section.querySelectorAll(".webauthn-key-remove").forEach((btn) => {
btn.addEventListener("click", async () => {
const password = prompt(t("mfa.webauthn_remove_confirm"));
if (password === null) return;
try {
await AuthManager.webauthnRemove(btn.dataset.id, password);
showToast(t("mfa.webauthn_removed"), "success");
initMfaSettings();
} catch (err) {
errEl.textContent = err.message || String(err);
errEl.classList.remove("hidden");
}
});
});
}
async function _startMfaSetup() {
const flowArea = document.getElementById("mfa-setup-flow-area");
if (!flowArea) return;
try {
const data = await AuthManager.mfaSetup();
flowArea.innerHTML = `
<div class="mfa-setup-card">
<h4>${t("mfa.scan_qr")}</h4>
<div class="mfa-qr-container">
<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code"
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.otpauth_uri)}">
</div>
<details class="mfa-secret-details">
<summary>${t("mfa.manual_entry")}</summary>
<code class="mfa-secret-code">${data.secret}</code>
</details>
<div class="mfa-verify-section">
<label>${t("mfa.enter_code")}</label>
<input type="text" id="mfa-enable-code" class="mfa-code-input" maxlength="6"
pattern="[0-9]{6}" inputmode="numeric" placeholder="000000" autocomplete="one-time-code">
<button class="config-btn-primary" id="mfa-confirm-btn">${t("mfa.confirm_enable")}</button>
<p class="mfa-error hidden" id="mfa-enable-error"></p>
</div>
</div>
`;
const codeInput = document.getElementById("mfa-enable-code");
codeInput.addEventListener("input", () => {
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
});
document.getElementById("mfa-confirm-btn").addEventListener("click", async () => {
const code = codeInput.value.trim();
if (code.length !== 6) return;
const btn = document.getElementById("mfa-confirm-btn");
const errorEl = document.getElementById("mfa-enable-error");
btn.disabled = true;
btn.textContent = t("mfa.verifying");
errorEl.classList.add("hidden");
try {
const result = await AuthManager.mfaEnable(code);
// Show recovery codes
_showRecoveryCodes(result.recovery_codes);
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove("hidden");
codeInput.value = "";
codeInput.focus();
} finally {
btn.disabled = false;
btn.textContent = t("mfa.confirm_enable");
}
});
} catch (err) {
flowArea.innerHTML = `<p class="mfa-error">${err.message}</p>`;
}
}
function _showRecoveryCodes(codes) {
const flowArea = document.getElementById("mfa-setup-flow-area");
const area = document.getElementById("mfa-setup-area");
if (!flowArea) return;
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${c}</code>`).join("\n");
flowArea.innerHTML = `
<div class="mfa-recovery-card">
<h4>🔑 ${t("mfa.recovery_codes_title")}</h4>
<p class="mfa-warning">${t("mfa.recovery_codes_warning")}</p>
<div class="mfa-recovery-list" id="mfa-recovery-list">
${codesHtml}
</div>
<div class="mfa-recovery-actions">
<button class="config-btn-secondary" id="mfa-copy-codes">${t("mfa.copy_codes")}</button>
<button class="config-btn-secondary" id="mfa-download-codes">${t("mfa.download_codes")}</button>
<button class="config-btn-primary" id="mfa-codes-done">${t("mfa.done")}</button>
</div>
</div>
`;
document.getElementById("mfa-copy-codes").addEventListener("click", () => {
navigator.clipboard.writeText(codes.join("\n")).then(() => {
showToast(t("mfa.codes_copied"), "success");
});
});
document.getElementById("mfa-download-codes").addEventListener("click", () => {
const blob = new Blob([codes.join("\n")], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "obsigate-recovery-codes.txt";
a.click();
URL.revokeObjectURL(url);
});
document.getElementById("mfa-codes-done").addEventListener("click", () => {
// Refresh MFA settings display
initMfaSettings();
});
}
function _showDisableMfaForm() {
const formArea = document.getElementById("mfa-disable-form-area");
if (!formArea) return;
formArea.innerHTML = `
<div class="mfa-disable-card">
<h4>${t("mfa.disable_confirm_title")}</h4>
<p>${t("mfa.disable_confirm_desc")}</p>
<div class="form-group">
<label>${t("mfa.password_label")}</label>
<input type="password" id="mfa-disable-password" class="config-input" placeholder="${t('mfa.password_placeholder')}">
</div>
<div class="form-group">
<label>${t("mfa.totp_code_label")}</label>
<input type="text" id="mfa-disable-code" class="mfa-code-input" maxlength="6"
pattern="[0-9]{6}" inputmode="numeric" placeholder="000000">
</div>
<div class="mfa-disable-actions">
<button class="config-btn-secondary" id="mfa-disable-cancel">${t("common.cancel")}</button>
<button class="config-btn-danger" id="mfa-disable-confirm">${t("mfa.disable_confirm_btn")}</button>
</div>
<p class="mfa-error hidden" id="mfa-disable-error"></p>
</div>
`;
document.getElementById("mfa-disable-cancel").addEventListener("click", () => {
formArea.innerHTML = "";
});
document.getElementById("mfa-disable-confirm").addEventListener("click", async () => {
const password = document.getElementById("mfa-disable-password").value;
const code = document.getElementById("mfa-disable-code").value.trim();
const errorEl = document.getElementById("mfa-disable-error");
const btn = document.getElementById("mfa-disable-confirm");
if (!password || code.length !== 6) {
errorEl.textContent = t("mfa.fill_all_fields");
errorEl.classList.remove("hidden");
return;
}
btn.disabled = true;
btn.textContent = t("mfa.verifying");
errorEl.classList.add("hidden");
try {
await AuthManager.mfaDisable(password, code);
showToast(t("mfa.disabled_success"), "success");
initMfaSettings();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove("hidden");
} finally {
btn.disabled = false;
btn.textContent = t("mfa.disable_confirm_btn");
}
});
}
export { api, AuthManager, initLoginForm, AdminPanel, initMfaSettings };
+762
View File
@@ -0,0 +1,762 @@
// ObsiGate — AI Assistant sidebar.
//
// The panel adapts to three contexts:
// • directory — a vault folder (opened from the tree context menu)
// • documents — the file(s) currently open in tabs/panes
// • general — no document open: app help + file/directory creation actions
import { t } from './i18n.js';
import { buildAIPickerUI } from './ai.js';
import { safeCreateIcons } from './utils.js';
import { api, AuthManager } from './auth.js';
import { showToast } from './ui.js';
export const MODE = Object.freeze({
DIRECTORY: 'directory',
DOCUMENTS: 'documents',
GENERAL: 'general',
});
/**
* Gather the files currently open across the singleton TabManager and every
* split-view pane (window globals avoid a circular import with ui.js).
* The active tab of each pane is listed first.
*/
export function collectOpenDocuments() {
const docs = [];
const seen = new Set();
const add = (vault, path) => {
if (!vault || !path) return;
const key = `${vault}\u0000${path}`;
if (seen.has(key)) return;
seen.add(key);
docs.push({ vault, path });
};
const scan = (tm) => {
if (!tm || !Array.isArray(tm._tabs)) return;
const active = tm._tabs.find((x) => x.id === tm._activeTabId);
if (active) add(active.vault, active.path);
for (const tab of tm._tabs) add(tab.vault, tab.path);
};
try {
scan(window.TabManager);
const pm = window.PaneManager;
if (pm && pm.tabManagers) Object.values(pm.tabManagers).forEach(scan);
} catch { /* not ready yet */ }
return docs;
}
export function documentsSignature(documents) {
return (documents || []).map((d) => `${d.vault}/${d.path}`).join('\u0000');
}
class BooksLM {
constructor() {
this._isOpen = false;
this._mode = MODE.GENERAL;
this._vault = null;
this._directory = null;
this._documents = [];
this._effectiveScope = MODE.GENERAL;
this._messages = [];
this._contextFiles = [];
this._isLoading = false;
this._abortCtrl = null;
this._panel = null;
this._isFullscreen = false;
}
// ── Public API ──────────────────────────────────────────────────────
/**
* Open the assistant for a specific context.
* @param {object} opts
* @param {'directory'|'documents'|'general'} opts.mode
* @param {string|null} [opts.vault]
* @param {string} [opts.directory]
* @param {Array<{vault:string,path:string}>} [opts.documents]
*/
async openContext(opts = {}) {
const mode = opts.mode || MODE.GENERAL;
const vault = opts.vault || null;
const directory = opts.directory || '';
const documents = Array.isArray(opts.documents) ? opts.documents : [];
const sameContext =
this._isOpen &&
this._mode === mode &&
this._vault === vault &&
this._directory === directory &&
documentsSignature(this._documents) === documentsSignature(documents);
if (sameContext) {
// Already open for this context: reveal a collapsed panel, else close.
if (this._panel && this._panel.classList.contains('hidden')) {
this._setHidden(false);
document.dispatchEvent(new CustomEvent('bookslm:opened'));
} else {
this.close();
}
return;
}
this._mode = mode;
this._vault = vault;
this._directory = directory;
this._documents = documents;
this._effectiveScope = mode;
this._messages = [];
this._contextFiles = [];
this._isLoading = true;
this._loadHistory();
if (!this._panel) {
this._panel = this._render();
document.body.appendChild(this._panel);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
// An explicit open always reveals the panel, even if it was collapsed
// in a previous session (previously it stayed hidden → "ne s'ouvre plus").
this._setHidden(false);
this._updateHeader();
this._updateStatus();
this._renderMessages();
this._showSuggestions();
requestAnimationFrame(() => {
this._panel.classList.add('open');
document.dispatchEvent(new CustomEvent('bookslm:opened', {
detail: { mode, vault, directory, documents },
}));
});
this._isOpen = true;
await this._loadContext();
this._isLoading = false;
}
/** Backwards-compatible helper: open the assistant on a directory. */
async open(vault, directory) {
return this.openContext({ mode: MODE.DIRECTORY, vault, directory });
}
/**
* Open the assistant using the current application context: the open
* document(s) if any, otherwise the General assistant.
*/
async openForCurrentContext() {
const docs = collectOpenDocuments();
if (docs.length) {
const vault = docs[0].vault;
const scoped = docs.filter((d) => d.vault === vault);
return this.openContext({ mode: MODE.DOCUMENTS, vault, documents: scoped });
}
return this.openContext({ mode: MODE.GENERAL });
}
close() {
if (!this._panel || !this._isOpen) return;
this._panel.classList.remove('open');
this._isOpen = false;
document.dispatchEvent(new CustomEvent('bookslm:closed'));
if (this._abortCtrl) {
this._abortCtrl.abort();
this._abortCtrl = null;
}
}
_setHidden(hidden) {
if (!this._panel) return;
this._panel.classList.toggle('hidden', hidden);
try {
localStorage.setItem('obsigate-bookslm-hidden', hidden ? 'true' : 'false');
} catch { /* */ }
this._updateToggleIcon();
}
/** Collapse/expand while keeping the panel mounted (header toggle button). */
_toggleSidebar() {
if (!this._panel) return;
const isHidden = !this._panel.classList.contains('hidden');
this._setHidden(isHidden);
document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened'));
}
_updateToggleIcon() {
if (!this._panel) return;
const btn = this._panel.querySelector('.bookslm-btn-toggle');
if (!btn) return;
const isHidden = this._panel.classList.contains('hidden');
const icon = btn.querySelector('i[data-lucide]');
if (!icon) return;
icon.setAttribute('data-lucide', isHidden ? 'panel-right-open' : 'panel-right-close');
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
newConversation() {
this._messages = [];
this._saveHistory();
if (this._panel) {
this._renderMessages();
this._showSuggestions();
}
}
exportConversation() {
if (!this._messages.length) return;
const label = this._contextTitle() || 'assistant';
let md = `# ${label}\n\n`;
for (const msg of this._messages) {
const role = msg.role === 'user' ? '**You**' : '**AI**';
md += `### ${role}\n\n${msg.content}\n\n---\n\n`;
}
const blob = new Blob([md], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `obsigate-assistant-${label.replace(/[^\w.-]+/g, '_')}.md`;
a.click();
URL.revokeObjectURL(a.href);
}
openFile(path) {
window.dispatchEvent(new CustomEvent('obsigate:open-file', {
detail: { vault: this._vault, path },
}));
}
// ── Context ─────────────────────────────────────────────────────────
async _loadContext() {
if (this._mode === MODE.GENERAL) {
this._effectiveScope = MODE.GENERAL;
this._contextFiles = [];
this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 });
this._showSuggestions();
return;
}
const payload = {
mode: this._mode,
vault: this._vault,
directory: this._directory,
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
};
try {
const data = await api('/api/ai/bookslm/context', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
this._contextFiles = data.files || [];
this._effectiveScope = data.scope || this._mode;
this._updateStatus(data);
this._showSuggestions();
} catch (e) {
console.warn('AI assistant: context load failed', e);
this._contextFiles = [];
this._updateStatus({ error: e.message });
}
}
_contextMeta() {
if (this._mode === MODE.DOCUMENTS) {
const names = this._documents.map((d) => (d.path || '').split('/').pop()).filter(Boolean);
return {
icon: 'file-text',
title: t('ai.context_documents', { count: this._documents.length }),
subtitle: names.slice(0, 3).join(', ') + (names.length > 3 ? '…' : ''),
};
}
if (this._mode === MODE.DIRECTORY) {
const name = this._directory ? this._directory.split('/').filter(Boolean).pop() : this._vault;
return {
icon: 'folder',
title: name || this._vault || t('bookslm.title'),
subtitle: `${this._vault}${this._directory ? '/' + this._directory : ''}`,
};
}
return {
icon: 'sparkles',
title: t('ai.context_general'),
subtitle: t('ai.context_general_desc'),
};
}
_contextTitle() {
return this._contextMeta().title;
}
// ── Rendering ───────────────────────────────────────────────────────
_render() {
const panel = document.createElement('div');
panel.className = 'bookslm-panel';
panel.innerHTML = `
<div class="bookslm-header">
<button class="bookslm-btn-toggle" title="${t('bookslm.toggle_sidebar')}" aria-label="${t('bookslm.toggle_sidebar')}">
<i data-lucide="panel-right-close" style="width:16px;height:16px"></i>
</button>
<span class="bookslm-context-icon"><i data-lucide="sparkles" style="width:16px;height:16px"></i></span>
<div class="bookslm-header-titles">
<span class="bookslm-title"></span>
<span class="bookslm-subtitle"></span>
</div>
<div class="bookslm-header-actions">
<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>
<button class="bookslm-btn-fullscreen" title="${t('ai.fullscreen')}" aria-label="${t('ai.fullscreen')}"><i data-lucide="maximize-2" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-close" title="${t('ai.close')}" aria-label="${t('ai.close')}"><i data-lucide="x" style="width:16px;height:16px"></i></button>
</div>
</div>
<div class="bookslm-toolbar">
<span class="bookslm-toolbar-label">${t('ai.model_settings')}</span>
<span class="bookslm-picker-host"></span>
</div>
<div class="bookslm-status"></div>
<div class="bookslm-suggestions"></div>
<div class="bookslm-messages"></div>
<div class="bookslm-input-area">
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('bookslm.placeholder')}"></textarea>
<button class="bookslm-btn-send" title="${t('bookslm.send')}">${t('bookslm.send')}</button>
</div>
<div class="bookslm-input-hint">${t('ai.input_hint')}</div>
`;
// Provider/model picker lives on its own toolbar row so the header
// actions stay visible regardless of the provider/model text width.
const pickerHost = panel.querySelector('.bookslm-picker-host');
buildAIPickerUI().then((picker) => {
if (!picker || !pickerHost) return;
pickerHost.replaceWith(picker);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}).catch(() => { /* ignore */ });
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation());
panel.querySelector('.bookslm-btn-toggle').addEventListener('click', () => this._toggleSidebar());
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
this._isFullscreen = !this._isFullscreen;
panel.classList.toggle('fullscreen', this._isFullscreen);
const icon = panel.querySelector('.bookslm-btn-fullscreen i');
if (icon) {
icon.setAttribute('data-lucide', this._isFullscreen ? 'minimize-2' : 'maximize-2');
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
});
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
const textarea = panel.querySelector('textarea');
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
});
textarea.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd+Enter → insert a newline at the caret.
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.slice(0, start) + '\n' + textarea.value.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + 1;
textarea.dispatchEvent(new Event('input'));
return;
}
// Enter (with or without Shift) → submit. Shift+Enter also submits so
// the documented shortcut (Ctrl+Enter) is the only way to add a line.
e.preventDefault();
this._sendMessage();
});
return panel;
}
_updateHeader() {
if (!this._panel) return;
const meta = this._contextMeta();
const title = this._panel.querySelector('.bookslm-title');
const subtitle = this._panel.querySelector('.bookslm-subtitle');
const icon = this._panel.querySelector('.bookslm-context-icon i');
if (title) title.textContent = meta.title;
if (subtitle) subtitle.textContent = meta.subtitle || '';
if (icon) icon.setAttribute('data-lucide', meta.icon);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
_updateStatus(data) {
if (!this._panel) return;
const status = this._panel.querySelector('.bookslm-status');
if (!status) return;
if (data && data.error) {
status.innerHTML = `<span class="bookslm-status-error">⚠ ${t('bookslm.no_context')}</span>`;
return;
}
if (data && Array.isArray(data.files)) {
const count = data.files.length;
const chars = data.total_chars || 0;
const maxChars = data.max_total_chars || 200000;
const pct = Math.min(100, Math.round((chars / maxChars) * 100));
const label = data.scope === MODE.GENERAL
? t('ai.context_general_desc')
: `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`;
status.innerHTML = `
<span class="bookslm-status-text">${label}</span>
<div class="bookslm-context-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
`;
} else if (this._isLoading) {
status.textContent = '⏳ …';
}
}
_suggestionsForContext() {
if (this._mode === MODE.GENERAL) {
return [
t('ai.suggestion_capabilities'),
t('ai.suggestion_search'),
t('ai.suggestion_create_file'),
];
}
if (this._mode === MODE.DOCUMENTS) {
return [
t('ai.suggestion_docs_summary'),
t('ai.suggestion_docs_keypoints'),
t('ai.suggestion_docs_contradictions'),
];
}
return [
t('bookslm.suggestion_summary'),
t('bookslm.suggestion_themes'),
t('bookslm.suggestion_contradictions'),
];
}
_showSuggestions() {
if (!this._panel) return;
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (!sugEl) return;
sugEl.innerHTML = '';
if (this._messages.length) return;
for (const s of this._suggestionsForContext()) {
const btn = document.createElement('button');
btn.className = 'bookslm-suggestion';
btn.textContent = s;
btn.addEventListener('click', () => {
const textarea = this._panel.querySelector('textarea');
if (textarea) {
textarea.value = s;
this._sendMessage();
}
});
sugEl.appendChild(btn);
}
}
_renderMessages() {
if (!this._panel) return;
const container = this._panel.querySelector('.bookslm-messages');
if (!container) return;
container.innerHTML = '';
for (const msg of this._messages) {
const bubble = document.createElement('div');
bubble.className = `bookslm-bubble ${msg.role}`;
if (msg.role === 'assistant') {
const { text, actions } = this._extractActions(msg.content || '');
bubble.innerHTML = this._renderMarkdown(text);
if (actions.length) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'bookslm-actions';
actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
bubble.appendChild(actionsDiv);
}
if (msg.sources && msg.sources.length) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'bookslm-sources';
for (const src of msg.sources) {
const badge = document.createElement('span');
badge.className = 'bookslm-source-badge';
badge.textContent = `📄 ${src.split('/').pop()}`;
badge.title = src;
badge.addEventListener('click', () => this.openFile(src));
sourcesDiv.appendChild(badge);
}
bubble.appendChild(sourcesDiv);
}
} else {
bubble.textContent = msg.content;
}
container.appendChild(bubble);
}
container.scrollTop = container.scrollHeight;
}
// ── Actions (general assistant) ─────────────────────────────────────
_extractActions(text) {
const actions = [];
const re = /```obsigate-action\s*\n([\s\S]*?)```/g;
let match;
while ((match = re.exec(text)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (obj && typeof obj.action === 'string') actions.push(obj);
} catch { /* malformed block — ignore */ }
}
return { text: text.replace(re, '').trim(), actions };
}
_renderActionCard(action) {
const card = document.createElement('div');
card.className = 'bookslm-action';
const isDir = action.action === 'create_directory';
const label = isDir
? t('ai.action_create_dir', { path: action.path || '' })
: t('ai.action_create_file', { path: action.path || '' });
const meta = document.createElement('div');
meta.className = 'bookslm-action-meta';
meta.innerHTML = `<span class="bookslm-action-icon">${isDir ? '📁' : '📄'}</span>`;
const textEl = document.createElement('span');
textEl.textContent = label;
meta.appendChild(textEl);
card.appendChild(meta);
const apply = document.createElement('button');
apply.className = 'bookslm-action-apply';
apply.textContent = t('ai.action_apply');
apply.addEventListener('click', async () => {
apply.disabled = true;
apply.textContent = t('ai.action_applying');
try {
await this._applyAction(action);
card.classList.add('done');
apply.textContent = t('ai.action_applied');
} catch (e) {
apply.disabled = false;
apply.textContent = t('ai.action_apply');
showToast(t('ai.action_failed', { error: e.message }), 'error');
}
});
card.appendChild(apply);
return card;
}
async _applyAction(action) {
if (!action.vault || !action.path) throw new Error('missing vault/path');
if (action.action === 'create_file') {
await api(`/api/file/${encodeURIComponent(action.vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: action.path, content: action.content || '' }),
});
showToast(t('ai.action_created_file', { path: action.path }), 'success');
} else if (action.action === 'create_directory') {
await api(`/api/directory/${encodeURIComponent(action.vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: action.path }),
});
showToast(t('ai.action_created_dir', { path: action.path }), 'success');
} else {
throw new Error(`unknown action: ${action.action}`);
}
try {
const m = await import('./sidebar.js');
if (m && typeof m.refreshSidebarTreePreservingState === 'function') {
await m.refreshSidebarTreePreservingState();
}
} catch { /* tree refresh is best-effort */ }
}
// ── Messaging ───────────────────────────────────────────────────────
async _sendMessage() {
const textarea = this._panel.querySelector('textarea');
const text = (textarea.value || '').trim();
if (!text || this._isLoading) return;
textarea.value = '';
textarea.style.height = 'auto';
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (sugEl) sugEl.innerHTML = '';
// Snapshot prior turns BEFORE appending the new user message.
const history = this._messages
.filter((m) => m.content && m.content.trim())
.map((m) => ({ role: m.role, content: m.content }));
this._messages.push({ role: 'user', content: text });
this._renderMessages();
const assistantMsg = { role: 'assistant', content: '', sources: [] };
this._messages.push(assistantMsg);
this._renderMessages();
this._isLoading = true;
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
try {
let provider = null;
let model = null;
try {
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
provider = picker.provider || null;
model = picker.model || null;
} catch { /* */ }
const payload = {
mode: this._mode,
vault: this._vault,
directory: this._directory,
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
message: text,
conversation_history: history,
provider,
model,
};
const doFetch = () => {
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch('/api/ai/bookslm/chat', {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify(payload),
signal: this._abortCtrl.signal,
});
};
let resp = await doFetch();
if (resp.status === 401 && AuthManager._authEnabled) {
await AuthManager.refreshAccessToken();
resp = await doFetch();
}
if (!resp.ok) {
let detail = `HTTP ${resp.status}`;
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
throw new Error(detail);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = 'message';
const handleLine = (line) => {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
return;
}
if (!line.startsWith('data:')) return;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') return;
let data;
try {
data = JSON.parse(raw);
} catch {
assistantMsg.content += raw;
return;
}
if (currentEvent === 'error' || data.error) {
const msg = data.error || t('bookslm.error');
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
return;
}
const chunk = data.token || data.content || '';
if (chunk) assistantMsg.content += chunk;
if (data.sources) assistantMsg.sources = data.sources;
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) handleLine(line);
this._renderMessages();
}
if (buffer) handleLine(buffer);
if (!assistantMsg.sources.length && this._contextFiles.length) {
const contentLower = assistantMsg.content.toLowerCase();
assistantMsg.sources = this._contextFiles
.filter((f) => {
const name = (f.path || f || '').split('/').pop().toLowerCase();
return name && contentLower.includes(name);
})
.map((f) => f.path || f);
}
} catch (e) {
if (e.name !== 'AbortError') {
assistantMsg.content = `⚠ Error: ${e.message}`;
console.warn('AI assistant chat error:', e);
}
}
this._isLoading = false;
if (sendBtn) sendBtn.disabled = false;
this._abortCtrl = null;
this._renderMessages();
this._saveHistory();
}
// ── Markdown (lightweight) ──────────────────────────────────────────
_renderMarkdown(text) {
if (!text) return '';
// Escape HTML first so model output cannot inject markup.
let html = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
return `<p>${html}</p>`;
}
// ── History persistence ─────────────────────────────────────────────
_historyKey() {
const ctx = this._mode === MODE.DOCUMENTS
? documentsSignature(this._documents)
: (this._directory || this._vault || 'general');
return `bookslm-history-${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
}
_saveHistory() {
try {
localStorage.setItem(this._historyKey(), JSON.stringify(this._messages));
} catch { /* */ }
}
_loadHistory() {
try {
const data = localStorage.getItem(this._historyKey());
this._messages = data ? JSON.parse(data) : [];
} catch {
this._messages = [];
}
}
}
const booksLM = new BooksLM();
export default booksLM;
export { BooksLM };
+233 -48
View File
@@ -1,5 +1,5 @@
// config.js — extracted from app.js (3872-4865)
import { api, AuthManager } from './auth.js';
import { api, AuthManager, initMfaSettings } from './auth.js';
import { state } from './state.js';
import { el, icon, openFile } from './viewer.js';
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
@@ -663,6 +663,9 @@ function initConfigModal() {
// Init profile
initProfile();
// Init MFA settings (security tab)
initMfaSettings();
}
function closeConfigModal() {
@@ -920,8 +923,7 @@ function loadAbout() {
title: t("about.section_app"),
rows: [
[t("about.name"), "ObsiGate"],
[t("about.version"), state.APP_VERSION],
[t("about.api_version"), health.version || "—"],
[t("about.version"), health.version || "—"],
[t("about.status"), health.status || "—"],
],
},
@@ -1337,21 +1339,77 @@ function updateRegexPreview() {
// ── AI Keys management ──
const AI_KEY_MAP = {
"cfg-deepseek-key": "DEEPSEEK_API_KEY",
"cfg-openrouter-key": "OPENROUTER_API_KEY",
"cfg-gemini-key": "GEMINI_API_KEY",
"cfg-nvidia-key": "NVIDIA_API_KEY",
"cfg-qwencloud-key": "QWENCLOUD_API_KEY",
"cfg-xiaomi-key": "XIAOMI_API_KEY",
"cfg-mistral-key": "MISTRAL_API_KEY",
};
const AI_PROVIDER_NAMES = ["deepseek","openrouter","gemini","nvidia","qwencloud","xiaomi","mistral"];
function _ensureAIKeyUI() {
for (const [inputId] of Object.entries(AI_KEY_MAP)) {
const input = document.getElementById(inputId);
if (!input) continue;
const row = input.closest(".config-row");
if (!row || row.dataset.enhanced) continue;
row.dataset.enhanced = "1";
row.style.cssText += "display:flex;align-items:center;gap:8px;flex-wrap:wrap;";
const badge = document.createElement("span");
badge.id = inputId.replace("-key", "-badge");
badge.style.cssText = "font-size:11px;padding:2px 8px;border-radius:10px;white-space:nowrap;";
row.appendChild(badge);
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.id = inputId.replace("-key", "-delete");
delBtn.className = "config-btn-secondary";
delBtn.style.cssText = "font-size:11px;padding:4px 10px;color:var(--danger,#e74c3c);border-color:var(--danger,#e74c3c);cursor:pointer;display:none;";
delBtn.textContent = "\u00d7 Supprimer";
delBtn.addEventListener("click", () => deleteAIKey(inputId));
row.appendChild(delBtn);
}
}
function _setAIKeyBadge(inputId, hasKey) {
const badge = document.getElementById(inputId.replace("-key", "-badge"));
const delBtn = document.getElementById(inputId.replace("-key", "-delete"));
if (badge) {
if (hasKey) {
badge.textContent = "\u2713 Configur\u00e9";
badge.style.background = "var(--success-bg, #27ae6022)";
badge.style.color = "var(--success, #27ae60)";
badge.style.border = "1px solid var(--success, #27ae60)";
} else {
badge.textContent = "Non configur\u00e9";
badge.style.background = "var(--muted-bg, #ffffff10)";
badge.style.color = "var(--text-muted, #888)";
badge.style.border = "1px solid var(--border, #444)";
}
}
if (delBtn) delBtn.style.display = hasKey ? "inline-block" : "none";
}
async function loadAIKeys() {
_ensureAIKeyUI();
try {
const data = await api("/api/config/ai-keys");
["DEEPSEEK_API_KEY","OPENROUTER_API_KEY","GEMINI_API_KEY"].forEach(k => {
const id = "cfg-" + k.toLowerCase().replace(/_api_key/g, "") + "-key";
const input = document.getElementById(id);
if (input && data[k]) input.placeholder = data[k];
});
for (const [inputId, envName] of Object.entries(AI_KEY_MAP)) {
const input = document.getElementById(inputId);
const val = data[envName] || "";
if (input) {
input.placeholder = val || (inputId.includes("gemini") ? "AIza..." : inputId.includes("openrouter") ? "sk-or-..." : "sk-...");
}
_setAIKeyBadge(inputId, !!val);
}
} catch(e) {}
}
async function saveAIKeys() {
const keys = {};
const map = { "cfg-deepseek-key": "DEEPSEEK_API_KEY", "cfg-openrouter-key": "OPENROUTER_API_KEY", "cfg-gemini-key": "GEMINI_API_KEY" };
for (const [id, name] of Object.entries(map)) {
for (const [id, name] of Object.entries(AI_KEY_MAP)) {
const input = document.getElementById(id);
if (input && input.value.trim()) keys[name] = input.value.trim();
}
@@ -1359,8 +1417,19 @@ async function saveAIKeys() {
try {
await api("/api/config/ai-keys", { method: "POST", body: JSON.stringify(keys) });
showToast(t("config.api_keys_saved"), "success");
// Clear inputs
Object.keys(map).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
Object.keys(AI_KEY_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
loadAIKeys();
} catch(e) { showToast("Erreur: " + e.message, "error"); }
}
async function deleteAIKey(inputId) {
const envName = AI_KEY_MAP[inputId];
if (!envName) return;
const provider = envName.replace("_API_KEY", "").replace(/_/g, " ");
if (!confirm("Supprimer la cl\u00e9 API " + provider + " ?\nLe fournisseur ne sera plus disponible.")) return;
try {
await api("/api/config/ai-keys/" + envName, { method: "DELETE" });
showToast("Cl\u00e9 " + provider + " supprim\u00e9e", "success");
loadAIKeys();
} catch(e) { showToast("Erreur: " + e.message, "error"); }
}
@@ -1369,12 +1438,18 @@ async function testAIKeys() {
const status = document.getElementById("cfg-ai-status");
if (status) status.textContent = "Test en cours...";
try {
// Test connectivity
const results = await api("/api/config/ai-keys/test", { method: "POST" });
var msg = Object.entries(results).map(([k,v]) => k + ": " + v).join(" | ");
if (status) status.textContent = msg;
// Fetch models for configured providers
for (const p of ["deepseek","openrouter","gemini"]) {
for (const p of AI_PROVIDER_NAMES) {
const inputId = "cfg-" + p + "-key";
if (results[p] === "ok") {
_setAIKeyBadge(inputId, true);
} else if (results[p] && results[p].includes("non")) {
_setAIKeyBadge(inputId, false);
}
}
for (const p of AI_PROVIDER_NAMES) {
if (results[p] === "ok") {
try {
const m = await api("/api/config/ai-models?provider=" + p);
@@ -1389,9 +1464,9 @@ async function testAIKeys() {
}
}
} catch(e) {
if (status) status.textContent = "Erreur: " + e.message;
if (status) status.textContent = "Erreur: " + e.message;
}
}
}
export {
@@ -1408,31 +1483,62 @@ export {
initAboutModal,
initProfile,
switchSidebarTab,
populateVersions,
};
// Populate every version display (header badge, About modal, help guide footer)
// from a single source of truth so they always match.
function populateVersions(version) {
var v = version || window.__OBSIGATE_VERSION || '—';
var els = [
document.getElementById('version-badge'),
document.getElementById('about-version'),
document.getElementById('help-version'),
];
for (var i = 0; i < els.length; i++) {
if (els[i]) els[i].textContent = v;
}
return v;
}
// ── Theme Picker ─────────────────────────────────────────────────────
function initThemePicker() {
import('./themes.js').then(function(mod) {
var THEMES = mod.THEMES;
var AVAILABLE_MODES = mod.AVAILABLE_MODES;
var applyTheme = mod.applyTheme;
var getCurrentTheme = mod.getCurrentTheme;
var getCurrentMode = mod.getCurrentMode;
var exportAllThemes = mod.exportAllThemes;
var importTheme = mod.importTheme;
var deleteCustomTheme = mod.deleteCustomTheme;
var loadCustomThemes = mod.loadCustomThemes;
var grid = document.getElementById('theme-grid');
if (!grid) return;
var themeKeys = Object.keys(THEMES);
// Mode label map (uses i18n t() when available, fallback to English)
var modeLabels = {
'dark': function() { return t('theme.dark'); },
'light': function() { return t('theme.light'); },
'high-contrast': function() { return t('theme.high_contrast'); },
'sepia': function() { return t('theme.sepia'); }
};
function renderCards() {
var themeKeys = Object.keys(THEMES);
var current = getCurrentTheme();
var mode = getCurrentMode();
grid.innerHTML = '';
themeKeys.forEach(function(key) {
var t = THEMES[key];
var v = t.modes[mode];
var th = THEMES[key];
// Use dark mode for preview if mode not resolved yet
var v = th.modes[mode] || th.modes.dark || th.modes.light;
if (!v) return;
var card = document.createElement('div');
card.className = 'theme-card' + (key === current ? ' active' : '');
var isCustom = th.desc === 'Custom theme';
card.innerHTML =
'<div class="theme-card-preview">' +
'<div class="theme-card-preview-bar" style="background:' + (v['--bg-sidebar'] || v['--bg-primary']) + '"></div>' +
@@ -1440,33 +1546,101 @@ function initThemePicker() {
'<div class="theme-card-preview-btn" style="background:' + (v['--accent'] || '#58a6ff') + '"></div>' +
'</div>' +
'</div>' +
'<div class="theme-card-name">' + t.name + '</div>' +
'<div class="theme-card-desc">' + t.desc + '</div>';
'<div class="theme-card-name">' + th.name + (isCustom ? ' <span class="theme-custom-badge">✦</span>' : '') + '</div>' +
'<div class="theme-card-desc">' + th.desc + '</div>';
card.addEventListener('click', function() {
applyTheme(key, mode);
renderCards();
});
// Right-click to delete custom themes
if (isCustom) {
card.addEventListener('contextmenu', function(e) {
e.preventDefault();
if (confirm(t('theme.delete_confirm'))) {
deleteCustomTheme(key);
renderCards();
}
});
}
grid.appendChild(card);
});
// Mode toggle
// Mode toggle — 4 modes
var toggle = document.createElement('div');
toggle.className = 'theme-mode-toggle';
var dBtn = elBtn(t('theme.dark'), 'dark', mode);
var lBtn = elBtn(t('theme.light'), 'light', mode);
toggle.appendChild(dBtn);
toggle.appendChild(lBtn);
grid.appendChild(toggle);
}
function elBtn(label, m, currentMode) {
var btn = document.createElement('button');
btn.className = 'theme-mode-btn' + (m === currentMode ? ' active' : '');
btn.textContent = label;
btn.addEventListener('click', function() {
applyTheme(getCurrentTheme(), m);
renderCards();
AVAILABLE_MODES.forEach(function(m) {
var btn = document.createElement('button');
btn.className = 'theme-mode-btn' + (m === mode ? ' active' : '');
btn.textContent = (modeLabels[m] || function() { return m; })();
btn.addEventListener('click', function() {
applyTheme(getCurrentTheme(), m);
renderCards();
});
toggle.appendChild(btn);
});
return btn;
grid.appendChild(toggle);
// Import/Export buttons
var actions = document.createElement('div');
actions.className = 'theme-actions';
var exportBtn = document.createElement('button');
exportBtn.className = 'theme-action-btn';
exportBtn.textContent = t('theme.export_all');
exportBtn.addEventListener('click', function() {
var json = exportAllThemes();
var blob = new Blob([json], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'obsigate-themes.json'; a.click();
URL.revokeObjectURL(url);
});
var importBtn = document.createElement('button');
importBtn.className = 'theme-action-btn';
importBtn.textContent = t('theme.import');
importBtn.addEventListener('click', function() {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.addEventListener('change', function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
var content = ev.target.result;
// Support single theme or all-themes format
try {
var data = JSON.parse(content);
if (data.themes) {
// Multi-theme export: import each
var count = 0;
Object.keys(data.themes).forEach(function(k) {
var res = importTheme(JSON.stringify(data.themes[k]));
if (res.ok) count++;
});
alert(t('theme.import_success', { count: count }));
} else {
var res = importTheme(content);
if (res.ok) {
alert(t('theme.import_success', { count: 1 }));
} else {
alert(t('theme.import_error') + ': ' + res.error);
}
}
} catch(ex) {
alert(t('theme.import_error') + ': ' + ex.message);
}
renderCards();
};
reader.readAsText(file);
});
input.click();
});
actions.appendChild(exportBtn);
actions.appendChild(importBtn);
grid.appendChild(actions);
}
renderCards();
@@ -1494,16 +1668,27 @@ function initAboutModal() {
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.classList.contains('active')) overlay.classList.remove('active'); });
function populateAbout() {
// Live stats from health endpoint
fetch('/api/health')
.then(function(r) { return r.json(); })
.then(function(d) {
var fEl = document.getElementById('about-stat-files');
var vEl = document.getElementById('about-stat-vaults');
if (fEl) fEl.textContent = d.total_files || '—';
if (vEl) vEl.textContent = d.vaults || '—';
})
.catch(function() {});
// Live stats + version from health endpoint (single source of truth).
// Fallback silencieux : si le fetch échoue, on garde la version pré-chargée
// (window.__OBSIGATE_VERSION) au lieu d'un "—" trompeur.
fetch('/api/health')
.then(function(r) { return r.json(); })
.then(function(d) {
var fEl = document.getElementById('about-stat-files');
var vEl = document.getElementById('about-stat-vaults');
if (fEl && d.total_files) fEl.textContent = d.total_files;
if (vEl && d.vaults) vEl.textContent = d.vaults;
// Single source of truth: populate the header badge, About and
// help-guide footer with the same version value.
if (d && (d.version || d.git_describe)) {
populateVersions((d && d.version) || window.__OBSIGATE_VERSION);
}
var commitEl = document.getElementById('about-commit');
if (commitEl) commitEl.textContent = (d && d.git_describe) || (d && d.git_commit) || '—';
})
.catch(function() {
populateVersions();
});
// Build date (today)
var now = new Date();
+258
View File
@@ -0,0 +1,258 @@
/* ObsiGate — Desktop (Tauri) bridge.
* Bridges the vanilla-JS frontend to the Rust/Tauri shell.
*
* On desktop, the frontend is served by the Python backend over
* http://127.0.0.1:<port> (the webview redirects there after boot).
* Tauri exposes its API globally via `window.__TAURI__` (withGlobalTauri).
* Remote IPC access for that origin is granted in capabilities/default.json.
*
* On web (Docker), `window.__TAURI__` is absent — every function degrades
* to a no-op so the same bundle runs unchanged in the browser.
*
* Usage: import { initDesktopIntegration } from './desktop.js';
*/
// ── Detection ──────────────────────────────────────────────────────────────
// window.__TAURI__ is injected by Tauri when app.withGlobalTauri === true.
// window.__TAURI_INTERNALS__ is a secondary signal used by some Tauri builds.
export function isTauriEnv() {
if (typeof window === 'undefined') return false;
return !!(window.__TAURI__ || window.__TAURI_INTERNALS__);
}
// ── invoke wrapper ─────────────────────────────────────────────────────────
// Safely invokes a Tauri command. Resolves to undefined on web or on any
// IPC error, so callers never crash the app.
export async function invoke(cmd, args) {
if (!isTauriEnv()) return undefined;
try {
const core = window.__TAURI__ && window.__TAURI__.core;
if (!core || typeof core.invoke !== 'function') return undefined;
return await core.invoke(cmd, args);
} catch (e) {
console.warn('[desktop] invoke(' + cmd + ') failed:', e);
return undefined;
}
}
// ── System theme ───────────────────────────────────────────────────────────
// Returns 'dark' or 'light' from the OS. Falls back to the CSS media query
// (which the system WebView also honours) if the Tauri command is unavailable.
export async function getSystemTheme() {
const tauriTheme = await invoke('get_system_theme');
if (tauriTheme === 'dark' || tauriTheme === 'light') return tauriTheme;
try {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
} catch (e) { /* ignore */ }
return 'light';
}
// ── Theme preference detection ─────────────────────────────────────────────
// The real theme engine is themes.js: it persists the *mode* under
// 'obsigate-theme-mode' (dark/light/high-contrast/sepia). Absence of that key
// means "no explicit user choice yet" — the desktop app should then follow
// the OS theme instead of defaulting to dark.
export function hasExplicitThemeMode() {
try {
return localStorage.getItem('obsigate-theme-mode') !== null;
} catch (e) {
return false;
}
}
// Apply the OS theme mode, but ONLY if the user hasn't chosen one explicitly.
export async function syncSystemTheme() {
if (!isTauriEnv()) return;
if (hasExplicitThemeMode()) return;
const sys = await getSystemTheme();
// Map OS light/dark onto the theme engine's mode axis (never override
// high-contrast/sepia, which are explicit choices stored in the same key).
const current = (function () {
try { return localStorage.getItem('obsigate-theme-mode'); } catch (e) { return null; }
})();
if (current === 'high-contrast' || current === 'sepia') return;
try { localStorage.setItem('obsigate-theme-mode', sys); } catch (e) { /* ignore */ }
}
// ── Crash banner ───────────────────────────────────────────────────────────
// The Rust shell calls `showBackendCrashBanner()` via window.eval when the
// backend process dies. Define it globally (idempotent) so the call always
// resolves. On web this is never triggered, but defining it is harmless.
export function defineBackendCrashBanner() {
if (typeof window === 'undefined') return;
if (window.showBackendCrashBanner) return;
window.showBackendCrashBanner = function () {
// Avoid stacking duplicates
if (document.getElementById('backend-crash-banner')) return;
const banner = document.createElement('div');
banner.id = 'backend-crash-banner';
banner.setAttribute('role', 'alert');
banner.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999',
'background:#7f1d1d', 'color:#fef2f2', 'padding:10px 16px',
'font-size:13px', 'display:flex', 'align-items:center', 'justify-content:space-between',
'box-shadow:0 2px 8px rgba(0,0,0,0.4)',
].join(';');
const msg = document.createElement('span');
msg.textContent = '⚠ Le backend ObsiGate s\u2019est arrêté. Redémarrage en cours…';
const btn = document.createElement('button');
btn.textContent = 'Recharger';
btn.style.cssText = 'background:#fff;color:#7f1d1d;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-weight:600;';
btn.addEventListener('click', function () { window.location.reload(); });
banner.appendChild(msg);
banner.appendChild(btn);
document.body.appendChild(banner);
};
}
// ── Vault picker ───────────────────────────────────────────────────────────
// Opens the native folder picker and registers the chosen folder as a vault,
// then restarts the backend so the new VAULT_N_* env vars take effect.
export async function pickVaultFolder() {
if (!isTauriEnv()) return undefined;
const path = await invoke('pick_vault_folder');
return typeof path === 'string' ? path : undefined;
}
// ── First-run wizard ───────────────────────────────────────────────────────
// Decides whether the first-run "choose your vault" wizard should be shown.
// Pure function (no DOM) so it is unit-testable.
//
// Returns true when: desktop env, the wizard has never been dismissed, and
// the user has no explicitly-picked vault folder yet.
const WIZARD_DISMISSED_KEY = 'obsigate-desktop-wizard-dismissed';
export function shouldShowWizard(opts) {
opts = opts || {};
const isDesktop = opts.isDesktop !== undefined ? opts.isDesktop : isTauriEnv();
const dismissed = opts.dismissed !== undefined ? opts.dismissed : (function () {
try { return localStorage.getItem(WIZARD_DISMISSED_KEY) === '1'; } catch (e) { return false; }
})();
const hasVaultPath = !!opts.hasVaultPath;
return isDesktop && !dismissed && !hasVaultPath;
}
export function dismissWizard() {
try { localStorage.setItem(WIZARD_DISMISSED_KEY, '1'); } catch (e) { /* ignore */ }
}
export function getWizardDismissedKey() { return WIZARD_DISMISSED_KEY; }
// ── Vault management (native) ──────────────────────────────────────────────
// pickAndAddVault opens the native folder picker, registers the chosen folder
// as a vault in the desktop config, restarts the backend (so VAULT_N_* env
// vars are re-injected) and reloads. Returns the chosen path, or undefined
// if cancelled / not on desktop.
export async function pickAndAddVault() {
if (!isTauriEnv()) return undefined;
const path = await pickVaultFolder();
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';
await invoke('add_vault', { name: name, path: path });
await invoke('restart_backend');
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
// by the Rust shell remains available if the user dismisses it.
export function initDesktopWizard() {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
if (!shouldShowWizard({})) return;
// Defer so the app shell is fully rendered before injecting the banner.
setTimeout(function () {
if (document.getElementById('obsigate-desktop-wizard')) return;
const banner = document.createElement('div');
banner.id = 'obsigate-desktop-wizard';
banner.setAttribute('role', 'dialog');
banner.style.cssText = [
'position:fixed', 'bottom:16px', 'left:16px', 'right:16px', 'z-index:99998',
'background:var(--surface2,#161b22)', 'border:1px solid var(--border,#21262d)',
'border-radius:10px', 'padding:14px 16px', 'display:flex',
'align-items:center', 'gap:12px', 'flex-wrap:wrap',
'box-shadow:0 8px 24px rgba(0,0,0,0.35)',
].join(';');
const text = document.createElement('span');
text.style.cssText = 'flex:1;min-width:220px;font-size:13px;color:var(--text,#e6edf3);';
text.textContent = 'Bienvenue dans ObsiGate Desktop. Choisissez le dossier de vos vaults Obsidian pour commencer.';
const pickBtn = document.createElement('button');
pickBtn.textContent = 'Choisir mon dossier';
pickBtn.style.cssText = 'background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600;font-size:13px;';
pickBtn.addEventListener('click', async function () {
const p = await pickAndAddVault();
if (p) { dismissWizard(); window.location.reload(); }
});
const laterBtn = document.createElement('button');
laterBtn.textContent = 'Plus tard';
laterBtn.style.cssText = 'background:transparent;color:var(--text-2,#8b949e);border:1px solid var(--border,#21262d);border-radius:6px;padding:8px 14px;cursor:pointer;font-size:13px;';
laterBtn.addEventListener('click', function () {
dismissWizard();
banner.remove();
});
banner.appendChild(text);
banner.appendChild(pickBtn);
banner.appendChild(laterBtn);
document.body.appendChild(banner);
}, 800);
}
// ── Integration entry point ────────────────────────────────────────────────
export async function initDesktopIntegration() {
defineBackendCrashBanner();
if (!isTauriEnv()) return false;
// Follow the OS theme on first run, before the theme engine renders.
await syncSystemTheme();
// Refresh the OS theme when the system preference changes at runtime.
try {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
if (mq && mq.addEventListener) {
mq.addEventListener('change', function () { syncSystemTheme(); });
}
} catch (e) { /* ignore */ }
// First-run vault wizard.
initDesktopWizard();
return true;
}
export default {
isTauriEnv,
invoke,
getSystemTheme,
hasExplicitThemeMode,
syncSystemTheme,
defineBackendCrashBanner,
pickVaultFolder,
pickAndAddVault,
shouldShowWizard,
dismissWizard,
getWizardDismissedKey,
initDesktopWizard,
initDesktopIntegration,
};
+13 -2
View File
@@ -114,11 +114,22 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
*/
async function _saveExcalidrawFile(state, excalidrawData) {
try {
const jsonStr = JSON.stringify(excalidrawData, null, 2);
const isMd = (state.path || '').toLowerCase().endsWith('.excalidraw.md');
let content;
if (isMd && excalidrawData && excalidrawData.compressed) {
// Preserve the Obsidian Excalidraw plugin format: frontmatter +
// compressed-json block. Writing raw JSON here would destroy the
// .excalidraw.md file (the plugin would no longer recognize it).
content = `---\nexcalidraw-plugin: parsed\n---\n\n\`\`\`compressed-json\n${excalidrawData.compressed}\n\`\`\`\n`;
} else {
// Transport-only field must not leak into the pure JSON format.
const { compressed, ...scene } = excalidrawData || {};
content = JSON.stringify(scene, null, 2);
}
await api(`/api/file/${encodeURIComponent(state.vault)}/save?path=${encodeURIComponent(state.path)}&backup=false`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: jsonStr }),
body: JSON.stringify({ content }),
});
// Toast is handled by the caller or we show a quick one
showToast('Excalidraw saved', 'success');
+3
View File
@@ -58,6 +58,9 @@ function getCMDS() {
{ id:'focus-next', label:'→ Panneau suivant', desc:'Active le panneau suivant (Ctrl+Alt+→)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId+1)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
{ id:'focus-prev', label:'← Panneau précédent', desc:'Active le panneau précédent (Ctrl+Alt+←)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId-1+window.PaneManager.panes.length)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
{ id:'reset-panes', label:'🔄 Réinitialiser les panneaux', desc:'Ferme tous les panneaux et revient au mode single-pane', cat:'Panneaux', act:()=>{close();if(window.PaneManager){while(window.PaneManager.isSplit()){window.PaneManager.closePane(window.PaneManager.panes.length-1);}localStorage.removeItem('obsigate-panes');}} },
// AI Assistant commands
{ id:'bookslm-open', label:t('palette.bookslm_open'), desc:t('palette.bookslm_open_desc'), cat:'AI', act:async()=>{close();const m=await import('./bookslm.js');m.default.openForCurrentContext();} },
{ id:'bookslm-new', label:t('palette.bookslm_new'), desc:t('palette.bookslm_new_desc'), cat:'AI', act:async()=>{close();const m=await import('./bookslm.js');m.default.newConversation();m.default.openForCurrentContext();} },
];
return _cmdsCache;
}
+568
View File
@@ -0,0 +1,568 @@
/* ObsiGate — Plugin Manager (Frontend)
* Handles plugin lifecycle, UI, and sandboxed execution via Web Worker.
*/
import { api } from './auth.js';
import { showToast } from './ui.js';
import { t } from './i18n.js';
import { safeCreateIcons } from './utils.js';
// ── Plugin Registry (in-memory cache) ─────────────────────────────────────
let _pluginsCache = new Map();
let _worker = null;
let _pendingCalls = new Map();
let _callId = 0;
// ── Worker Management ────────────────────────────────────────────────────
function getOrCreateWorker() {
if (!_worker) {
// Create worker from blob to avoid separate file (simpler deployment)
// Using string concatenation to avoid nested template literal issues
const workerCode =
"const pluginSandbox = {};\n" +
"const handlers = {};\n\n" +
"// Safe globals for plugins\n" +
"const safeGlobals = {\n" +
" console: {\n" +
" log: (...args) => self.postMessage({ type: 'log', args }),\n" +
" warn: (...args) => self.postMessage({ type: 'warn', args }),\n" +
" error: (...args) => self.postMessage({ type: 'error', args })\n" +
" },\n" +
" fetch: async (url, options) => {\n" +
" const response = await fetch(url, options);\n" +
" const data = await response.json();\n" +
" return data;\n" +
" },\n" +
" setTimeout,\n" +
" clearTimeout,\n" +
" Promise,\n" +
" JSON,\n" +
" Math,\n" +
" Date,\n" +
" Object,\n" +
" Array,\n" +
" String,\n" +
" Number,\n" +
" Boolean,\n" +
" RegExp,\n" +
" Error,\n" +
" Map,\n" +
" Set,\n" +
" WeakMap,\n" +
" WeakSet,\n" +
" URL,\n" +
" URLSearchParams,\n" +
" Headers,\n" +
" Request,\n" +
" Response,\n" +
" FormData,\n" +
" Blob,\n" +
" File,\n" +
" atob,\n" +
" btoa,\n" +
" encodeURIComponent,\n" +
" decodeURIComponent,\n" +
" parseInt,\n" +
" parseFloat,\n" +
" isNaN,\n" +
" isFinite\n" +
"};\n\n" +
"// Message handler\n" +
"self.onmessage = async (e) => {\n" +
" const { type, payload, callId } = e.data;\n\n" +
" try {\n" +
" if (type === 'loadPlugin') {\n" +
" const { code, name, permissions } = payload;\n\n" +
" const sandbox = { ...safeGlobals };\n" +
" const pluginModule = { exports: {} };\n\n" +
" const fn = new Function(\n" +
" 'module', 'exports', 'require', 'globals',\n" +
" code\n" +
" );\n" +
" fn(pluginModule, pluginModule.exports, (mod) => {\n" +
" throw new Error('require() not allowed in plugins');\n" +
" }, sandbox);\n\n" +
" for (const [hook, handlerName] of Object.entries(sandbox.handlers || {})) {\n" +
" if (!handlers[hook]) handlers[hook] = [];\n" +
" handlers[hook].push({ name, handler: sandbox[handlerName], permissions });\n" +
" }\n\n" +
" self.postMessage({ type: 'loaded', callId, plugin: name });\n" +
" }\n" +
" else if (type === 'executeHook') {\n" +
" const { hook, context, plugins: pluginList } = payload;\n" +
" const results = [];\n\n" +
" for (const p of pluginList) {\n" +
" const hookHandlers = handlers[hook]?.filter(h => h.name === p.name) || [];\n" +
" for (const h of hookHandlers) {\n" +
" try {\n" +
" const missing = h.permissions?.filter(p => !payload.permissions?.includes(p)) || [];\n" +
" if (missing.length > 0) {\n" +
" results.push({ plugin: p.name, success: false, error: 'Missing permissions: ' + missing.join(', ') });\n" +
" continue;\n" +
" }\n\n" +
" const result = await h.handler(context);\n" +
" results.push({ plugin: p.name, success: true, data: result });\n" +
" } catch (err) {\n" +
" results.push({ plugin: p.name, success: false, error: err.message });\n" +
" }\n" +
" }\n" +
" }\n\n" +
" self.postMessage({ type: 'hookResult', callId, results });\n" +
" }\n" +
" else if (type === 'unloadPlugin') {\n" +
" const { name } = payload;\n" +
" for (const hook of Object.keys(handlers)) {\n" +
" handlers[hook] = handlers[hook].filter(h => h.name !== name);\n" +
" }\n" +
" self.postMessage({ type: 'unloaded', callId });\n" +
" }\n" +
" } catch (err) {\n" +
" self.postMessage({ type: 'error', callId, error: err.message });\n" +
" }\n" +
"};\n";
const blob = new Blob([workerCode], { type: 'application/javascript' });
_worker = new Worker(URL.createObjectURL(blob));
_worker.onmessage = (e) => {
const { type, callId, plugin, result, results, error } = e.data;
const pending = _pendingCalls.get(callId);
if (!pending) return;
clearTimeout(pending.timeout);
_pendingCalls.delete(callId);
if (type === 'error' || type === 'loaded' || type === 'unloaded') {
if (error) pending.reject(new Error(error));
else pending.resolve({ success: true });
} else if (type === 'hookResult') {
pending.resolve(results);
}
};
_worker.onerror = (err) => {
console.error('[PluginWorker] Error:', err);
for (const [, pending] of _pendingCalls) {
clearTimeout(pending.timeout);
pending.reject(new Error('Worker error: ' + err.message));
}
_pendingCalls.clear();
_worker = null;
};
}
return _worker;
}
function callWorker(type, payload, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const worker = getOrCreateWorker();
const id = ++_callId;
const timeout = setTimeout(() => {
_pendingCalls.delete(id);
reject(new Error('Plugin worker timeout'));
}, timeoutMs);
_pendingCalls.set(id, { resolve, reject, timeout });
worker.postMessage({ type, payload, callId: id });
});
}
// ── Public API ────────────────────────────────────────────────────────────
export async function loadPlugins(vault) {
try {
const plugins = await api('/api/plugins?vault=' + encodeURIComponent(vault));
_pluginsCache.set(vault, plugins);
return plugins;
} catch (err) {
console.error('Failed to load plugins:', err);
return [];
}
}
export function getCachedPlugins(vault) {
return _pluginsCache.get(vault) || [];
}
export async function installPlugin(vault, manifest, code) {
const plugin = await api('/api/plugins?vault=' + encodeURIComponent(vault), {
method: 'POST',
body: JSON.stringify({ manifest, code })
});
await loadPlugins(vault);
return plugin;
}
export async function uninstallPlugin(vault, name) {
await api('/api/plugins/' + encodeURIComponent(name) + '?vault=' + encodeURIComponent(vault), {
method: 'DELETE'
});
await loadPlugins(vault);
}
export async function enablePlugin(vault, name) {
const plugin = await api('/api/plugins/' + encodeURIComponent(name) + '/enable?vault=' + encodeURIComponent(vault), {
method: 'POST'
});
await loadPlugins(vault);
return plugin;
}
export async function disablePlugin(vault, name) {
const plugin = await api('/api/plugins/' + encodeURIComponent(name) + '/disable?vault=' + encodeURIComponent(vault), {
method: 'POST'
});
await loadPlugins(vault);
return plugin;
}
export async function getPluginCode(vault, name) {
const { code } = await api('/api/plugins/' + encodeURIComponent(name) + '/code?vault=' + encodeURIComponent(vault));
return code;
}
export async function validateManifest(manifest) {
return api('/api/plugins/validate-manifest', {
method: 'POST',
body: JSON.stringify(manifest)
});
}
export async function getPluginTemplate() {
return api('/api/plugins/template');
}
// ── Hook Execution (for other modules to call) ────────────────────────────
export async function executeHook(vault, hook, context) {
const plugins = getCachedPlugins(vault).filter(p => p.enabled && p.hooks?.includes(hook));
if (plugins.length === 0) return [];
const pluginList = plugins.map(p => ({
name: p.name,
handler: p.hooks?.[hook] || '',
permissions: p.permissions || []
})).filter(p => p.handler);
if (pluginList.length === 0) return [];
try {
const results = await callWorker('executeHook', {
hook,
context,
plugins: pluginList,
permissions: pluginList.flatMap(p => p.permissions)
});
return results;
} catch (err) {
console.error('Hook ' + hook + ' execution failed:', err);
return pluginList.map(p => ({
plugin: p.name,
success: false,
error: err.message
}));
}
}
// Convenience methods for specific hooks
export async function onFileRender(vault, fileContent, filePath) {
const results = await executeHook(vault, 'onFileRender', { fileContent, filePath, vault });
let replaced = null;
const append = [];
for (const r of results) {
if (!r.success) continue;
const d = r.data;
if (typeof d === 'string') {
replaced = d;
} else if (d && typeof d === 'object') {
if (typeof d.content === 'string') replaced = d.content;
if (typeof d.append === 'string') append.push(d.append);
if (typeof d.html === 'string') append.push(d.html);
}
}
return { replace: replaced, append };
}
export async function onSearchFilter(vault, query, results) {
const hookResults = await executeHook(vault, 'onSearchFilter', { query, results, vault });
for (const r of hookResults) {
if (r.success && Array.isArray(r.data)) return r.data;
}
return results;
}
export async function onEditorAction(vault, action, editorState) {
const results = await executeHook(vault, 'onEditorAction', { action, editorState, vault });
return results.some(r => r.success && r.data?.handled === true);
}
export async function onSidebarItem(vault, itemType, itemPath) {
const results = await executeHook(vault, 'onSidebarItem', { itemType, itemPath, vault });
const items = [];
for (const r of results) {
if (r.success && Array.isArray(r.data)) items.push(...r.data);
}
return items;
}
export async function onFileCreate(vault, filePath, content) {
await executeHook(vault, 'onFileCreate', { filePath, content, vault });
}
export async function onFileDelete(vault, filePath) {
const results = await executeHook(vault, 'onFileDelete', { filePath, vault });
return results.every(r => r.success && r.data?.allow !== false);
}
export async function onVaultMount(vault) {
await executeHook(vault, 'onVaultMount', { vault });
}
// ── UI: Plugins Settings Page ────────────────────────────────────────────
export async function renderPluginsSettings(vault, container) {
const plugins = await loadPlugins(vault);
container.innerHTML =
'<div class="plugins-settings">' +
'<div class="plugins-header">' +
'<h2 data-i18n="plugins.title">🧩 Plugins</h2>' +
'<p class="config-description" data-i18n="plugins.description">' +
'Extend ObsiGate with custom renderers, search filters, and editor actions.' +
'</p>' +
'</div>' +
'<div class="plugins-toolbar">' +
'<button class="btn btn-primary" id="plugin-install-btn" data-i18n="plugins.install">Install Plugin</button>' +
'<button class="btn btn-secondary" id="plugin-template-btn" data-i18n="plugins.template">View Template</button>' +
'</div>' +
'<div id="plugins-list" class="plugins-list">' +
(plugins.length === 0
? '<div class="config-description" data-i18n="plugins.none">No plugins installed.</div>'
: plugins.map(p =>
'<div class="plugin-item ' + (p.enabled ? '' : 'disabled') + '" data-name="' + p.name + '">' +
'<div class="plugin-info">' +
'<div class="plugin-name">' +
'<strong>' + p.name + '</strong> v' + p.version +
'<span class="plugin-author">by ' + p.author + '</span>' +
'</div>' +
'<div class="plugin-description">' + p.description + '</div>' +
'<div class="plugin-meta">' +
'<span class="plugin-hooks">' + (p.hooks?.join(', ') || 'no hooks') + '</span>' +
'<span class="plugin-perms">' + (p.permissions?.join(', ') || 'no permissions') + '</span>' +
'</div>' +
'</div>' +
'<div class="plugin-actions">' +
'<button class="btn btn-sm ' + (p.enabled ? 'btn-warning' : 'btn-success') + '"' +
' data-action="' + (p.enabled ? 'disable' : 'enable') + '"' +
' data-name="' + p.name + '"' +
' data-i18n="' + (p.enabled ? 'plugins.disable' : 'plugins.enable') + '">' +
(p.enabled ? 'Disable' : 'Enable') +
'</button>' +
'<button class="btn btn-sm btn-secondary" data-action="code" data-name="' + p.name + '" data-i18n="plugins.view_code">Code</button>' +
'<button class="btn btn-sm btn-danger" data-action="uninstall" data-name="' + p.name + '" data-i18n="plugins.uninstall">Uninstall</button>' +
'</div>' +
'</div>'
).join('')) +
'</div>' +
'</div>';
container.querySelector('#plugin-install-btn')?.addEventListener('click', () => openInstallModal(vault));
container.querySelector('#plugin-template-btn')?.addEventListener('click', () => openTemplateModal());
container.querySelectorAll('.plugin-actions button').forEach(btn => {
btn.addEventListener('click', async (e) => {
const action = btn.dataset.action;
const name = btn.dataset.name;
try {
if (action === 'enable') {
await enablePlugin(vault, name);
showToast(t('plugins.enabled', { name }), 'success');
} else if (action === 'disable') {
await disablePlugin(vault, name);
showToast(t('plugins.disabled', { name }), 'success');
} else if (action === 'uninstall') {
if (confirm(t('plugins.confirm_uninstall', { name }))) {
await uninstallPlugin(vault, name);
showToast(t('plugins.uninstalled', { name }), 'success');
}
} else if (action === 'code') {
const code = await getPluginCode(vault, name);
openCodeModal(name, code);
}
await renderPluginsSettings(vault, container);
} catch (err) {
showToast(err.message, 'error');
}
});
});
safeCreateIcons();
}
function openInstallModal(vault) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML =
'<div class="modal" style="max-width: 700px;">' +
'<div class="modal-header">' +
'<h3 data-i18n="plugins.install">Install Plugin</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="form-group">' +
'<label data-i18n="plugins.manifest_json">Manifest (plugin.json)</label>' +
'<textarea id="plugin-manifest-json" class="modal-textarea" rows="15" spellcheck="false"' +
' placeholder=\'{"name":"my-plugin","version":"1.0.0","description":"...","author":"You","main":"index.js","hooks":{"onFileRender":"renderFile"},"permissions":["read_files"]}\'></textarea>' +
'</div>' +
'<div class="form-group">' +
'<label data-i18n="plugins.code">Plugin Code (main file)</label>' +
'<textarea id="plugin-code" class="modal-textarea" rows="20" spellcheck="false"' +
' placeholder="// Your plugin code here\\nexport async function renderFile(context) { ... }"></textarea>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-secondary modal-cancel" data-i18n="common.cancel">Cancel</button>' +
'<button class="btn btn-primary" id="plugin-install-confirm" data-i18n="plugins.install">Install</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.querySelector('.modal-cancel')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
modal.querySelector('#plugin-install-confirm')?.addEventListener('click', async () => {
const manifestText = modal.querySelector('#plugin-manifest-json').value;
const code = modal.querySelector('#plugin-code').value;
try {
const manifest = JSON.parse(manifestText);
const validation = await validateManifest(manifest);
if (!validation.valid) throw new Error(validation.error || 'Invalid manifest');
await installPlugin(vault, manifest, code);
showToast(t('plugins.installed', { name: manifest.name }), 'success');
modal.remove();
const container = document.getElementById('cfg-plugins');
if (container) await renderPluginsSettings(vault, container);
} catch (err) {
showToast(err.message, 'error');
}
});
}
function openTemplateModal() {
getPluginTemplate().then(template => {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML =
'<div class="modal" style="max-width: 700px;">' +
'<div class="modal-header">' +
'<h3 data-i18n="plugins.template">Plugin Template</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="form-group">' +
'<label data-i18n="plugins.manifest_json">Manifest</label>' +
'<pre class="code-block" style="max-height: 200px; overflow: auto;">' + JSON.stringify(template.manifest, null, 2) + '</pre>' +
'</div>' +
'<div class="form-group">' +
'<label data-i18n="plugins.code">Code</label>' +
'<pre class="code-block" style="max-height: 300px; overflow: auto;">' + template.code + '</pre>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
'<button class="btn btn-secondary" onclick="copyTemplate()">Copy to Clipboard</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
window.copyTemplate = () => {
navigator.clipboard.writeText(JSON.stringify(template, null, 2));
showToast('Template copied to clipboard', 'success');
};
});
}
function openCodeModal(name, code) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML =
'<div class="modal" style="max-width: 800px;">' +
'<div class="modal-header">' +
'<h3>' + name + ' — Source Code</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<pre class="code-block" style="max-height: 500px; overflow: auto;">' + escapeHtml(code) + '</pre>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
}
function escapeHtml(text) {
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// ── Auto-init when settings page loads ────────────────────────────────────
document.addEventListener('click', (e) => {
const link = e.target.closest('[href="#cfg-plugins"]');
if (link) {
setTimeout(() => {
const container = document.getElementById('cfg-plugins');
if (container && !container.dataset.pluginsLoaded) {
container.dataset.pluginsLoaded = 'true';
const vault = document.querySelector('[data-current-vault]')?.dataset.currentVault;
if (vault) renderPluginsSettings(vault, container);
}
}, 100);
}
});
// Export for other modules
export const PluginManager = {
loadPlugins,
getCachedPlugins,
installPlugin,
uninstallPlugin,
enablePlugin,
disablePlugin,
getPluginCode,
validateManifest,
getPluginTemplate,
executeHook,
onFileRender,
onSearchFilter,
onEditorAction,
onSidebarItem,
onFileCreate,
onFileDelete,
onVaultMount,
renderPluginsSettings,
};
// Initialize plugins system
export async function initPlugins() {
// Pre-load plugins for current vault if available
const vault = document.querySelector('[data-current-vault]')?.dataset.currentVault;
if (vault) {
await loadPlugins(vault);
}
}
+228
View File
@@ -0,0 +1,228 @@
/**
* push.js — Push Notifications client for ObsiGate
* ROADMAP #67
*
* Handles:
* - Subscribing to Web Push via VAPID
* - Unsubscribing
* - UI rendering in the config modal
*/
import { t } from './i18n.js';
let _vapidPublicKey = null;
// ── Helpers ──────────────────────────────────────────────────────────────────
function _api(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
};
if (body !== undefined) opts.body = JSON.stringify(body);
return fetch(path, opts).then((r) => {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
});
}
function _urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// ── VAPID key ────────────────────────────────────────────────────────────────
async function getVapidPublicKey() {
if (_vapidPublicKey) return _vapidPublicKey;
const data = await _api('GET', '/api/push/vapid-public-key');
_vapidPublicKey = data.public_key;
return _vapidPublicKey;
}
// ── Subscription management ──────────────────────────────────────────────────
async function subscribePush(vault) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('Push notifications not supported by this browser');
}
const reg = await navigator.serviceWorker.ready;
const vapidKey = await getVapidPublicKey();
// Check existing subscription
let subscription = await reg.pushManager.getSubscription();
if (!subscription) {
// Subscribe to push
subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(vapidKey),
});
}
// Register subscription with backend
const subJSON = subscription.toJSON();
await _api('POST', '/api/push/subscribe', {
subscription: {
endpoint: subJSON.endpoint,
keys: subJSON.keys,
},
vault,
});
return { success: true };
}
async function unsubscribePush() {
if (!('serviceWorker' in navigator)) return;
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.getSubscription();
if (!subscription) return;
// Unsubscribe from browser
await subscription.unsubscribe();
// Unsubscribe from backend
const endpoint = subscription.endpoint;
try {
await _api('DELETE', `/api/push/subscribe?endpoint=${encodeURIComponent(endpoint)}`);
} catch {
// Best-effort: endpoint may already be expired
}
}
async function isSubscribed() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
return !!sub;
}
async function getSubscriptions() {
return _api('GET', '/api/push/subscriptions');
}
// ── Config UI ────────────────────────────────────────────────────────────────
export async function renderPushSection() {
const container = document.getElementById('push-settings');
if (!container) return;
const supported = 'serviceWorker' in navigator && 'PushManager' in window;
const subscribed = await isSubscribed();
if (!supported) {
container.innerHTML = `
<div class="mfa-status-row">
<span class="mfa-status-label">Notifications push</span>
<span class="mfa-badge mfa-badge-off">Non supportées par ce navigateur</span>
</div>`;
return;
}
container.innerHTML = `
<div class="mfa-status-row">
<span class="mfa-status-label">Notifications push</span>
<span id="push-status-badge" class="mfa-badge ${subscribed ? 'mfa-badge-on' : 'mfa-badge-off'}">${subscribed ? 'Activées' : 'Désactivées'}</span>
</div>
<div class="config-row" style="margin-top:12px">
<label class="config-label" for="push-vault-select">Vault cible</label>
<select id="push-vault-select" class="config-input" style="max-width:240px"></select>
</div>
<div class="config-row" style="margin-top:8px">
<button id="push-toggle-btn" class="config-save-btn" style="${subscribed ? 'background:var(--danger-bg);color:var(--danger);border-color:var(--danger)' : ''}">
${subscribed ? 'Désactiver les notifications' : 'Activer les notifications'}
</button>
</div>
<div id="push-sub-count" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>`;
// Populate vault select
const vaultSelect = document.getElementById('push-vault-select');
try {
const vaultData = await _api('GET', '/api/vaults');
const vaults = vaultData.vaults || vaultData || [];
(Array.isArray(vaults) ? vaults : []).forEach((v) => {
const name = typeof v === 'string' ? v : v.name;
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
vaultSelect.appendChild(opt);
});
} catch {
const opt = document.createElement('option');
opt.value = 'default';
opt.textContent = 'default';
vaultSelect.appendChild(opt);
}
// Toggle button
document.getElementById('push-toggle-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('push-toggle-btn');
const badge = document.getElementById('push-status-badge');
try {
btn.disabled = true;
btn.textContent = '…';
if (subscribed) {
await unsubscribePush();
badge.textContent = 'Désactivées';
badge.className = 'mfa-badge mfa-badge-off';
btn.textContent = 'Activer les notifications';
btn.style.background = '';
btn.style.color = '';
btn.style.borderColor = '';
} else {
const vault = vaultSelect.value || 'default';
await subscribePush(vault);
badge.textContent = 'Activées';
badge.className = 'mfa-badge mfa-badge-on';
btn.textContent = 'Désactiver les notifications';
btn.style.background = 'var(--danger-bg)';
btn.style.color = 'var(--danger)';
btn.style.borderColor = 'var(--danger)';
}
} catch (err) {
console.error('[Push] Toggle error:', err);
alert('Erreur: ' + err.message);
} finally {
btn.disabled = false;
}
});
// Show subscription count
try {
const data = await getSubscriptions();
const count = (data.subscriptions || []).length;
document.getElementById('push-sub-count').textContent =
count > 0 ? `${count} abonnement(s) actif(s)` : '';
} catch {
// Ignore
}
}
// ── Init ─────────────────────────────────────────────────────────────────────
export function initPush() {
// Render push section when config modal opens
document.addEventListener('DOMContentLoaded', () => {
// Observe the config modal becoming visible
const observer = new MutationObserver(() => {
const section = document.getElementById('cfg-push');
if (section && section.offsetParent !== null) {
renderPushSection();
}
});
const modal = document.getElementById('config-modal');
if (modal) observer.observe(modal, { attributes: true, attributeFilter: ['class'] });
});
}
export default { initPush, subscribePush, unsubscribePush, isSubscribed, renderPushSection };
+3
View File
@@ -8,6 +8,7 @@ import { _getEffective } from './config.js';
import { TabManager, showToast } from './ui.js';
import { addTagFilter, buildSearchResultsHeader, shouldDisplayPath, removeTagFilter, TagFilterService } from './sidebar.js';
import { t } from './i18n.js';
import { onSearchFilter } from './plugins.js';
// ---------------------------------------------------------------------------
// Search History Service (localStorage, LIFO, max 50, dedup)
// ---------------------------------------------------------------------------
@@ -802,6 +803,8 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse
if (searchId !== state.currentSearchId) return;
state.advancedSearchTotal = data.total;
state.advancedSearchOffset = ofs;
// Plugin hook: filter search results
data.results = await onSearchFilter(state.vault || "default", query, data.results);
renderAdvancedSearchResults(data, query, tagFilter);
} catch (err) {
clearTimeout(timeoutId);
-2
View File
@@ -2,8 +2,6 @@
Use state.xxx to read/write any value. ES module imports are read-only,
so we export a single mutable object instead of individual let bindings. */
export const state = {
APP_VERSION: "1.5.0",
// Core navigation
currentVault: null,
currentPath: null,
+180 -4
View File
@@ -526,26 +526,85 @@ var THEMES = {
var _currentTheme = 'defaut-obsigate';
var _currentMode = 'dark';
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
// ── Mode generators ─────────────────────────────────────────────────
// Generate a high-contrast variant from dark-mode CSS vars.
function _genHighContrast(darkVars) {
return {
'--bg-primary': '#000000', '--bg-secondary': '#0a0a0a', '--bg-sidebar': '#000000',
'--bg-hover': '#141414', '--border': '#444444', '--text-primary': '#ffffff',
'--text-secondary': '#cccccc', '--text-muted': '#888888', '--accent': darkVars['--accent'] || '#58a6ff',
'--accent-green': '#4cff6e', '--tag-bg': '#1a2a44', '--tag-text': '#7dc4ff',
'--code-bg': '#0a0a0a', '--search-bg': '#111111', '--scrollbar': '#444444',
'--resize-handle': '#444444', '--overlay-bg': 'rgba(0,0,0,0.85)',
'--danger': '#ff6b6b', '--danger-bg': '#4a0f0f', '--success': '#4cff6e', '--success-bg': '#0a3a14',
'--accent-card': darkVars['--accent'] || '#58a6ff', '--accent-bg': '#1a2a44', '--accent-text': '#7dc4ff',
'--green': '#4cff6e', '--green-bg': '#0a3a14', '--purple': '#c084fc', '--purple-bg': '#2a1a4a',
'--surface': '#0a0a0a', '--surface2': '#000000', '--surface3': '#000000',
'--border-md': '#444444', '--text': '#ffffff', '--text-2': '#cccccc', '--text-3': '#888888',
'--ok': '#4cff6e', '--warn': '#ffcc00', '--ai': '#c084fc', '--err': '#ff6b6b'
};
}
// Generate a sepia variant from light-mode CSS vars.
function _genSepia(lightVars) {
return {
'--bg-primary': '#f5ecd5', '--bg-secondary': '#eee3c8', '--bg-sidebar': '#e8dcc0',
'--bg-hover': '#e0d4b4', '--border': '#c8b890', '--text-primary': '#3d2e1a',
'--text-secondary': '#6b5a3e', '--text-muted': '#9a8a6a', '--accent': '#b8860b',
'--accent-green': '#5a8a32', '--tag-bg': '#e8dcc0', '--tag-text': '#8b6914',
'--code-bg': '#eee3c8', '--search-bg': '#f5ecd5', '--scrollbar': '#c8b890',
'--resize-handle': '#c8b890', '--overlay-bg': 'rgba(60,40,10,0.25)',
'--danger': '#b33a2a', '--danger-bg': '#f5e0dc', '--success': '#5a8a32', '--success-bg': '#e0edd0',
'--accent-card': '#b8860b', '--accent-bg': '#f0e4c4', '--accent-text': '#8b6914',
'--green': '#5a8a32', '--green-bg': '#e0edd0', '--purple': '#7a5a9a', '--purple-bg': '#e8ddf0',
'--surface': '#eee3c8', '--surface2': '#f5ecd5', '--surface3': '#e8dcc0',
'--border-md': '#c8b890', '--text': '#3d2e1a', '--text-2': '#6b5a3e', '--text-3': '#9a8a6a',
'--ok': '#5a8a32', '--warn': '#b8860b', '--ai': '#7a5a9a', '--err': '#b33a2a'
};
}
// Get CSS vars for a theme + mode (generating HC/sepia on demand).
function _getVars(themeKey, mode) {
var theme = THEMES[themeKey];
if (!theme) return null;
if (theme.modes[mode]) return theme.modes[mode];
// Generate high-contrast / sepia from dark / light
if (mode === 'high-contrast') return _genHighContrast(theme.modes.dark || {});
if (mode === 'sepia') return _genSepia(theme.modes.light || {});
return null;
}
function applyTheme(themeKey, mode) {
var theme = THEMES[themeKey];
if (!theme) return;
mode = mode || _currentMode || 'dark';
var vars = theme.modes[mode];
var vars = _getVars(themeKey, mode);
if (!vars) return;
var root = document.documentElement;
Object.keys(vars).forEach(function(key) {
root.style.setProperty(key, vars[key]);
});
// Store resolved vars on the theme for export/preview
if (!theme.modes[mode]) theme.modes[mode] = vars;
_currentTheme = themeKey;
_currentMode = mode;
try { localStorage.setItem('obsigate-theme', themeKey); } catch(e) {}
try { localStorage.setItem('obsigate-theme-mode', mode); } catch(e) {}
// Notify other components of theme change
try {
root.setAttribute('data-theme', mode);
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeKey, mode: mode } }));
} catch(e) {}
}
function toggleThemeMode() {
var newMode = _currentMode === 'dark' ? 'light' : 'dark';
var idx = AVAILABLE_MODES.indexOf(_currentMode);
var newMode = AVAILABLE_MODES[(idx + 1) % AVAILABLE_MODES.length];
applyTheme(_currentTheme, newMode);
return newMode;
}
@@ -553,14 +612,131 @@ function toggleThemeMode() {
function getCurrentTheme() { return _currentTheme; }
function getCurrentMode() { return _currentMode; }
// ── Public API ──────────────────────────────────────────────────────
function setTheme(name) {
if (THEMES[name]) {
applyTheme(name, _currentMode);
}
}
function getTheme() {
return _currentTheme;
}
function listThemes() {
return Object.keys(THEMES).map(function(key) {
return { id: key, name: THEMES[key].name, desc: THEMES[key].desc };
});
}
function listModes() {
return AVAILABLE_MODES.slice();
}
// ── Import / Export ─────────────────────────────────────────────────
function exportTheme(themeKey) {
themeKey = themeKey || _currentTheme;
var theme = THEMES[themeKey];
if (!theme) return null;
// Ensure HC/sepia are resolved for export
var modes = {};
AVAILABLE_MODES.forEach(function(m) {
modes[m] = _getVars(themeKey, m);
});
return JSON.stringify({
version: 1,
id: themeKey,
name: theme.name,
desc: theme.desc,
modes: modes
}, null, 2);
}
function exportAllThemes() {
var out = {};
Object.keys(THEMES).forEach(function(key) {
out[key] = {
name: THEMES[key].name,
desc: THEMES[key].desc,
modes: {}
};
AVAILABLE_MODES.forEach(function(m) {
out[key].modes[m] = _getVars(key, m);
});
});
return JSON.stringify({ version: 1, themes: out }, null, 2);
}
function importTheme(jsonStr) {
try {
var data = JSON.parse(jsonStr);
if (!data || !data.modes) return { ok: false, error: 'Invalid theme JSON: missing modes' };
var name = data.name || data.id || ('custom-' + Date.now());
var key = (data.id || name).toLowerCase().replace(/[^a-z0-9]+/g, '-');
var modes = {};
if (data.modes.dark && typeof data.modes.dark === 'object') modes.dark = data.modes.dark;
if (data.modes.light && typeof data.modes.light === 'object') modes.light = data.modes.light;
if (Object.keys(modes).length === 0) return { ok: false, error: 'Theme must define at least dark or light mode' };
THEMES[key] = { name: name, desc: data.desc || 'Custom theme', modes: modes };
try { localStorage.setItem('obsigate-custom-' + key, JSON.stringify(THEMES[key])); } catch(e) {}
return { ok: true, key: key };
} catch(e) {
return { ok: false, error: 'JSON parse error: ' + e.message };
}
}
function loadCustomThemes() {
var keys = [];
try {
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (k && k.indexOf('obsigate-custom-') === 0) {
keys.push(k);
}
}
} catch(e) {}
keys.forEach(function(lsKey) {
try {
var data = JSON.parse(localStorage.getItem(lsKey));
if (data && data.modes) {
var key = lsKey.replace('obsigate-custom-', '');
THEMES[key] = data;
}
} catch(e) {}
});
}
function deleteCustomTheme(key) {
if (THEMES[key] && THEMES[key].desc === 'Custom theme') {
delete THEMES[key];
try { localStorage.removeItem('obsigate-custom-' + key); } catch(e) {}
return true;
}
return false;
}
function initThemes() {
// Load any custom themes from localStorage first
loadCustomThemes();
var saved = null;
var savedMode = null;
try { saved = localStorage.getItem('obsigate-theme'); } catch(e) {}
try { savedMode = localStorage.getItem('obsigate-theme-mode'); } catch(e) {}
if (saved && THEMES[saved]) _currentTheme = saved;
if (savedMode) _currentMode = savedMode;
if (savedMode && AVAILABLE_MODES.indexOf(savedMode) !== -1) _currentMode = savedMode;
applyTheme(_currentTheme, _currentMode);
}
export { THEMES, applyTheme, toggleThemeMode, getCurrentTheme, getCurrentMode, initThemes };
export {
THEMES, AVAILABLE_MODES,
applyTheme, toggleThemeMode,
getCurrentTheme, getCurrentMode,
getTheme,
setTheme, listThemes, listModes,
exportTheme, exportAllThemes, importTheme, deleteCustomTheme, loadCustomThemes,
initThemes
};
+28 -6
View File
@@ -177,14 +177,16 @@ export function toggleTheme() {
// Update the theme toggle label
const themeLabel = document.getElementById("theme-label");
if (themeLabel) {
themeLabel.textContent = newMode === "dark" ? t('theme.dark') : t('theme.light');
const modeLabelMap = { dark: 'theme.dark', light: 'theme.light', 'high-contrast': 'theme.high_contrast', sepia: 'theme.sepia' };
themeLabel.textContent = t(modeLabelMap[newMode] || 'theme.dark');
}
// Update the theme toggle icon
const themeBtn = document.getElementById("theme-toggle");
if (themeBtn) {
const icon = themeBtn.querySelector("i");
if (icon) {
icon.setAttribute("data-lucide", newMode === "dark" ? "moon" : "sun");
const isLight = newMode === "light" || newMode === "sepia";
icon.setAttribute("data-lucide", isLight ? "sun" : "moon");
}
}
safeCreateIcons();
@@ -214,6 +216,15 @@ export function initHeaderMenu() {
menuDropdown.addEventListener("click", (e) => {
e.stopPropagation();
});
// API documentation (#72) — opens the OpenAPI/Swagger page in a new tab.
const apiRow = document.getElementById("api-docs-menu-row");
if (apiRow) {
apiRow.addEventListener("click", () => {
closeHeaderMenu();
window.open("/docs", "_blank", "noopener");
});
}
}
export function closeHeaderMenu() {
@@ -753,10 +764,10 @@ const FileOperations = {
});
},
showCreateFileModal(vault, parentPath) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
showCreateFileModal(vault, parentPath, preselectExt = null) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">${t('modal.create_file')}</h3>
@@ -802,6 +813,10 @@ const FileOperations = {
input.focus();
if (preselectExt && [...extSelect.options].some(o => o.value === preselectExt)) {
extSelect.value = preselectExt;
}
const validateName = (name) => {
if (!name.trim()) return 'Le nom ne peut pas être vide';
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
@@ -1693,9 +1708,12 @@ export const ContextMenuManager = {
} else if (type === 'directory') {
this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
this._addItem('pen-tool', 'Nouveau diagramme Excalidraw', () => this._createExcalidraw(), isReadonly);
this._addSeparator();
this._addItem('bookmark-plus', 'Ajouter aux recherches sauvegardees', () => this._saveDirectorySearch(), false);
this._addSeparator();
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false);
this._addSeparator();
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
} else if (type === 'file') {
@@ -1771,6 +1789,10 @@ export const ContextMenuManager = {
FileOperations.showCreateFileModal(this._targetVault, this._targetPath);
},
_createExcalidraw() {
FileOperations.showCreateFileModal(this._targetVault, this._targetPath, '.excalidraw');
},
_renameItem() {
FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType);
},
+91 -2
View File
@@ -1,5 +1,5 @@
/* ObsiGate — Viewer module */
import { api } from './auth.js';
import { api, AuthManager } from './auth.js';
import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard } from './utils.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
@@ -12,6 +12,42 @@ import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, D
import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
import { onFileRender } from './plugins.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
// export endpoints, showing a loading toast while the request is in flight.
async function downloadExport(vault, path, format, fallbackName) {
showToast(t("viewer.export_start"), "info");
const url = `/api/export/${format}?vault=${encodeURIComponent(vault)}&path=${encodeURIComponent(path)}`;
try {
const headers = AuthManager.getAuthHeaders() || {};
const res = await fetch(url, { credentials: "include", headers });
if (!res.ok) {
let detail = "";
try { detail = (await res.json()).detail || ""; } catch (_) { /* ignore */ }
throw new Error(detail || "HTTP " + res.status);
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = fallbackName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
showToast(t("viewer.export_done"), "success");
} catch (err) {
console.error("Export error:", err);
showToast(t("viewer.export_error") + " " + err.message, "error");
}
}
// Close any open export dropdown when clicking elsewhere.
document.addEventListener("click", function (e) {
if (e.target && e.target.closest && e.target.closest(".export-dropdown")) return;
document.querySelectorAll(".export-menu").forEach(function (m) { m.style.display = "none"; });
});
// Pane-aware content area helper — delegates to PaneManager when split
function getContentArea() {
@@ -635,6 +671,36 @@ export function renderFile(data) {
window.open(pdfUrl, "_blank");
});
// Export dropdown button (HTML / MD bundle / ePub)
const exportWrap = el("div", { class: "export-dropdown", style: "position:relative;display:inline-block" });
const exportBtn = el("button", { class: "btn-action", title: t("viewer.export_title") }, [icon("download", 14), document.createTextNode(t("viewer.export"))]);
const exportMenu = el("div", { class: "export-menu", style: "display:none;position:absolute;right:0;top:100%;z-index:1000;min-width:200px;padding:4px;background:var(--bg,#fff);border:1px solid #ccc;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.18)" });
const exportOptions = [
{ format: "html", icon: "file-code", label: t("viewer.export_html"), ext: ".html" },
{ format: "md-bundle", icon: "archive", label: t("viewer.export_md_bundle"), ext: ".zip" },
{ format: "epub", icon: "book-open", label: t("viewer.export_epub"), ext: ".epub" },
];
exportOptions.forEach((opt) => {
const item = el("button", { class: "export-menu-item", style: "display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:none;background:none;cursor:pointer;border-radius:4px;text-align:left;font-size:13px" }, [
icon(opt.icon, 14),
document.createTextNode(opt.label),
]);
item.addEventListener("mouseenter", () => { item.style.background = "rgba(128,128,128,.15)"; });
item.addEventListener("mouseleave", () => { item.style.background = "none"; });
item.addEventListener("click", () => {
exportMenu.style.display = "none";
const baseName = data.path.split("/").pop().replace(/\.[^.]+$/, "") || "document";
downloadExport(data.vault, data.path, opt.format, baseName + opt.ext);
});
exportMenu.appendChild(item);
});
exportBtn.addEventListener("click", (e) => {
e.stopPropagation();
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
});
exportWrap.appendChild(exportBtn);
exportWrap.appendChild(exportMenu);
const editBtn = el("button", { class: "btn-action", title: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]);
editBtn.addEventListener("click", () => {
openEditor(data.vault, data.path);
@@ -754,7 +820,7 @@ export function renderFile(data) {
// Assemble
area.innerHTML = "";
area.appendChild(breadcrumb);
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, editBtn, forgeBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, exportWrap, editBtn, forgeBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
area.appendChild(rawDiv);
@@ -812,6 +878,29 @@ export function renderFile(data) {
// Initialize outline/TOC for this document
OutlineManager.init();
// Plugin hooks: onFileRender — allow plugins to transform the rendered
// content or append custom widgets beneath it. Async + non-blocking so a
// slow/failed plugin never delays the normal file render.
if (data.content && data.is_markdown) {
onFileRender(data.vault, data.content, data.path).then((fr) => {
if (!fr) return;
if (typeof fr.replace === 'string' && fr.replace !== data.content) {
const rendered = document.getElementById('file-rendered-content');
if (rendered) rendered.innerHTML = fr.replace;
}
if (Array.isArray(fr.append) && fr.append.length > 0) {
const out = el('div', { class: 'plugin-render-output' });
fr.append.forEach((html) => {
const w = el('div', { class: 'plugin-widget' });
w.innerHTML = html;
out.appendChild(w);
});
area.appendChild(out);
safeCreateIcons();
}
}).catch(() => { /* plugins must never break rendering */ });
}
}
+218 -2
View File
@@ -42,6 +42,19 @@
"about.tests": "Tests",
"about.vaults_configured": "Configured vaults",
"about.version": "Version",
"admin.audit_all_actions": "All actions",
"admin.audit_empty": "No log available",
"admin.audit_filter_action": "Action",
"admin.audit_filter_user": "Filter by user",
"admin.audit_refresh": "Refresh",
"admin.audit_title": "Audit logs",
"admin.back_btn": "← Back",
"admin.backups_newest": "Newest: {days} days",
"admin.backups_oldest": "Oldest: {days} days",
"admin.backups_per_vault": "Per vault",
"admin.backups_title": "Backup statistics",
"admin.backups_total": "Total: {count} backups ({size})",
"admin.backups_vault_empty": "No backup",
"admin.close": "Close",
"admin.col_actions": "Actions",
"admin.col_last_login": "Last login",
@@ -49,10 +62,36 @@
"admin.col_status": "Status",
"admin.col_user": "User",
"admin.col_vaults": "Vaults",
"admin.forbidden_msg": "You must be an administrator to access this page.",
"admin.forbidden_title": "Access denied",
"admin.header_subtitle": "Administrator dashboard",
"admin.new_user": "+ New user",
"admin.page_title": "ObsiGate — Administration",
"admin.stats_cpu": "CPU",
"admin.stats_disk": "Disk",
"admin.stats_memory": "Memory",
"admin.stats_refresh": "Last update: {time}",
"admin.stats_sessions": "Active sessions",
"admin.stats_title": "Real-time statistics",
"admin.stats_uptime": "Uptime",
"admin.title": "Administration — Users",
"admin.users_manage_btn": "Manage users",
"admin.users_manage_hint": "Open the user management modal (full CRUD)",
"admin.users_title": "User management",
"ai.action_applied": "Applied ✓",
"ai.action_apply": "Apply",
"ai.action_applying": "Applying…",
"ai.action_create_dir": "Create folder {path}",
"ai.action_create_file": "Create file {path}",
"ai.action_created_dir": "Folder created: {path}",
"ai.action_created_file": "File created: {path}",
"ai.action_failed": "Action failed: {error}",
"ai.casual": "Casual tone",
"ai.close": "Close assistant",
"ai.completion_added": "AI: completion added",
"ai.context_documents": "{count} open document(s)",
"ai.context_general": "General context",
"ai.context_general_desc": "Questions about the app and file creation",
"ai.continue": "Continue",
"ai.custom_rewrite": "Custom rewrite",
"ai.done": "AI: text processed",
@@ -60,7 +99,9 @@
"ai.error_invalid_key": "Invalid API key",
"ai.explain": "Explain",
"ai.fix_spelling": "Fix spelling",
"ai.fix_spelling_hint": "Fixes spelling and grammar",
"ai.frontmatter": "Generate frontmatter",
"ai.fullscreen": "Fullscreen",
"ai.generate": "Generate",
"ai.hint_add_details": "Adds details",
"ai.hint_edit_selection": "Modifies the selected text",
@@ -68,6 +109,7 @@
"ai.hint_improve": "Improves text quality",
"ai.hint_translate": "Translates the selected text",
"ai.improve": "Improve",
"ai.input_hint": "Enter to send · Ctrl+Enter for a line break",
"ai.inline_completion_title": "AI Inline Completion (Ctrl+J) — Auto-complete text",
"ai.invalid_api_key": "AI: invalid API key. Check DEEPSEEK_API_KEY in .env",
"ai.longer": "Longer",
@@ -75,20 +117,38 @@
"ai.not_configured": "⚠️ AI not configured — add DEEPSEEK_API_KEY, OPENROUTER_API_KEY or GEMINI_API_KEY in .env",
"ai.processing": "⏳ AI: processing...",
"ai.professional": "Professional tone",
"ai.provider": "Provider",
"ai.provider_default": "— default —",
"ai.model": "Model",
"ai.model_default": "— default —",
"ai.model_loading": "Loading…",
"ai.model_offline": "offline",
"ai.model_load_error": "Load error",
"ai.model_settings": "Provider & model",
"ai.quota_exceeded": "AI: quota exceeded or payment required",
"ai.rewrite": "💬 Rewrite",
"ai.rewrite_done": "AI: text rewritten",
"ai.rewrite_prompt": "Rewrite instruction:",
"ai.rewrite_placeholder": "e.g. make the tone more formal and add a conclusion",
"ai.rewrite_title": "Rewrite text according to your instructions",
"ai.select_text": "AI: select text to process",
"ai.shorter": "Shorter",
"ai.shorter_hint": "Makes the text more concise",
"ai.simplify": "Simplify",
"ai.simplify_hint": "Simplifies the language",
"ai.suggestion_capabilities": "What can you do?",
"ai.suggestion_create_file": "Create a meeting notes file",
"ai.suggestion_docs_contradictions": "Are there contradictions between these documents?",
"ai.suggestion_docs_keypoints": "What are the key points of these documents?",
"ai.suggestion_docs_summary": "Summarize the open documents",
"ai.suggestion_search": "How do I search my notes effectively?",
"ai.summarize": "Summarize",
"ai.text_processed": "AI: text processed ✓",
"ai.text_rewritten": "AI: text rewritten",
"ai.to_canvas": "Convert to Canvas",
"ai.to_list": "Convert to list",
"ai.to_table": "Convert to table",
"ai.tone_hint": "Changes the text tone",
"ai.toolbar_edit": "Edit",
"ai.toolbar_generate": "Generate",
"ai.toolbar_rewrite": "Rewrite",
@@ -96,6 +156,7 @@
"ai.toolbar_toolbox": "Toolbox",
"ai.toolbar_translate": "Translate",
"ai.toolbox": "🧰 Toolbox",
"ai.toolbox_hint": "Conversion tools",
"ai.translate_to": "Translate to {lang}",
"auth.cannot_delete_self": "Cannot delete your own account",
"auth.logged_out": "Signed out",
@@ -425,6 +486,8 @@
"config.section_vue-graphe": "🗺️ Vue Graphe",
"config.section_watcher": "Auto sync",
"config.section_webhooks": "🔗 Webhooks",
"config.section_push": "🔔 Push notifications",
"config.section_plugins": "🧩 Plugins",
"config.section_workflow-recommande": "Recommended workflow",
"config.section_zone-de-contenu": "Zone de contenu",
"config.server_config": "Server config",
@@ -451,6 +514,8 @@
"config.webhook_url_placeholder": "URL",
"config.webhooks": "Webhooks",
"config.webhooks_desc": "HTTP notifications to external services on file changes.",
"config.plugins": "Plugins",
"config.plugins_desc": "Manage installed plugins for this vault.",
"config.workers_label": "Search workers",
"confirm.delete_dir": "Delete this folder and all its contents?",
"confirm.delete_dir_named": "Delete \"{path}\" and ALL its contents?",
@@ -586,6 +651,8 @@
"header.menu_about_desc": "Version, credits & info",
"header.menu_admin": "Administration",
"header.menu_admin_desc": "Manage users",
"header.menu_api": "API",
"header.menu_api_desc": "OpenAPI documentation",
"header.menu_config": "Settings",
"header.menu_config_desc": "Application settings",
"header.menu_help": "User guide",
@@ -1037,6 +1104,8 @@
"help.nav_tags": "🏷️ Tags",
"help.nav_tips": "💡 Tips",
"help.nav_webhooks": "🪝 Webhooks",
"help.nav_plugins": "🧩 Plugins",
"help.nav_excalidraw": "🎨 Excalidraw",
"help.new_file": "New file",
"help.new_folder": "New folder",
"help.nights": "sleepless nights 😅",
@@ -1303,6 +1372,7 @@
"settings.search_placeholder": "Search...",
"settings.sync": "🔄 Sync",
"settings.themes": "🎨 Themes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Link copied!",
"share.copy_link": "Copy link",
"share.create": "Create share link",
@@ -1377,7 +1447,14 @@
"theme.change": "Change theme",
"theme.dark": "Dark",
"theme.light": "Light",
"theme.high_contrast": "High Contrast",
"theme.sepia": "Sepia",
"theme.title": "Theme",
"theme.export_all": "Export Themes",
"theme.import": "Import Theme",
"theme.import_success": "{count} theme(s) imported",
"theme.import_error": "Import error",
"theme.delete_confirm": "Delete this custom theme?",
"themes.name_gradients": "Subtle gradients",
"themes.name_terminal": "Pure black & green",
"toast.ai_keys_saved": "API keys saved",
@@ -1465,6 +1542,14 @@
"viewer.download_pdf": "Download as PDF",
"viewer.edit": "Edit",
"viewer.error": "Loading error",
"viewer.export": "Export",
"viewer.export_done": "Export complete",
"viewer.export_epub": "Export as ePub",
"viewer.export_error": "Export error:",
"viewer.export_html": "Export as HTML",
"viewer.export_md_bundle": "Export as Markdown bundle (.zip)",
"viewer.export_start": "Exporting...",
"viewer.export_title": "Export document",
"viewer.forge_brand": "Forge",
"viewer.forge_title": "Forge (new editor)",
"viewer.index_start": "Starting index...",
@@ -1495,5 +1580,136 @@
"webhook.trigger_dir_create": "Directory creation via API",
"webhook.trigger_dir_delete": "Directory deletion via API",
"webhook.trigger_dir_rename": "Directory rename via API",
"webhook.trigger_rename": "Rename via API"
}
"webhook.trigger_rename": "Rename via API",
"settings.security": "🔒 Account Security",
"settings.security_desc": "Enable two-factor authentication (2FA) to strengthen your account security.",
"mfa.title": "Two-Factor Authentication",
"mfa.subtitle": "Enter the 6-digit code from your authenticator app.",
"mfa.verify": "Verify",
"mfa.verifying": "Verifying...",
"mfa.use_recovery": "Use a recovery code",
"mfa.back_to_login": "Back to login",
"mfa.recovery_title": "Recovery Code",
"mfa.recovery_subtitle": "Enter one of your recovery codes (format: XXXX-XXXX).",
"mfa.use_totp": "Use authenticator code",
"mfa.status_label": "Two-Factor Authentication",
"mfa.enabled": "Enabled",
"mfa.disabled": "Disabled",
"mfa.enabled_desc": "Your account is protected by two-factor authentication.",
"mfa.setup_desc": "Add an extra layer of security to your account with TOTP.",
"mfa.enable_btn": "Enable 2FA",
"mfa.disable_btn": "Disable 2FA",
"mfa.scan_qr": "Scan this QR code with your authenticator app",
"mfa.manual_entry": "Manual entry (if you can't scan)",
"mfa.enter_code": "Enter the 6-digit code to confirm",
"mfa.confirm_enable": "Confirm & Enable",
"mfa.recovery_codes_title": "Recovery Codes",
"mfa.recovery_codes_warning": "Save these codes in a secure place. Each code can only be used once.",
"mfa.copy_codes": "Copy",
"mfa.download_codes": "Download",
"mfa.done": "Done",
"mfa.codes_copied": "Recovery codes copied!",
"mfa.disable_confirm_title": "Disable 2FA",
"mfa.disable_confirm_desc": "Enter your password and a valid TOTP code to disable two-factor authentication.",
"mfa.password_label": "Password",
"mfa.password_placeholder": "Your current password",
"mfa.totp_code_label": "TOTP Code",
"mfa.disable_confirm_btn": "Disable 2FA",
"mfa.disabled_success": "2FA has been disabled.",
"mfa.fill_all_fields": "Please fill in all fields.",
"bookslm.title": "BooksLM",
"bookslm.files_indexed": "{count} files indexed",
"bookslm.chars_loaded": "{chars} chars loaded",
"bookslm.placeholder": "Ask a question about these documents...",
"bookslm.send": "Send",
"bookslm.new_conversation": "New conversation",
"bookslm.export": "Export conversation",
"bookslm.toggle_sidebar": "Hide/Show AI sidebar",
"bookslm.copy": "Copy",
"bookslm.error": "AI service error",
"bookslm.regenerate": "Regenerate",
"bookslm.suggestion_summary": "Summarize this directory",
"bookslm.suggestion_themes": "What are the main themes?",
"bookslm.suggestion_contradictions": "Are there contradictions between these documents?",
"bookslm.no_context": "No files found in this directory",
"bookslm.context_too_large": "Directory too large — some files were truncated",
"bookslm.source": "Source",
"palette.bookslm_open": "BooksLM: Open for current directory",
"palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)",
"palette.bookslm_new": "BooksLM: New conversation",
"palette.bookslm_new_desc": "Resets the AI assistant conversation",
"fab.open": "Open AI assistant",
"fab.close": "Close AI assistant",
"mfa.webauthn_title": "Security keys (WebAuthn)",
"mfa.webauthn_desc": "Authenticate with a physical key (YubiKey) or your device biometrics (Windows Hello, Touch ID).",
"mfa.webauthn_none": "No registered keys.",
"mfa.webauthn_add": "Add a security key",
"mfa.webauthn_label_prompt": "Key name (e.g. Pocket YubiKey)",
"mfa.webauthn_added": "Security key registered.",
"mfa.webauthn_removed": "Security key removed.",
"mfa.webauthn_remove": "Remove",
"mfa.webauthn_remove_confirm": "Enter your password to remove this key:",
"mfa.webauthn_prompt": "Present your security key or confirm with Windows Hello.",
"mfa.webauthn_btn": "Verify with my key",
"mfa.webauthn_cancelled": "WebAuthn ceremony cancelled.",
"mfa.webauthn_no_key": "No security key registered for this account.",
"plugins.title": "🧩 Plugins",
"plugins.description": "Extend ObsiGate with custom renderers, search filters, and editor actions.",
"plugins.install": "Install Plugin",
"plugins.template": "View Template",
"plugins.none": "No plugins installed.",
"plugins.manifest_json": "Manifest (plugin.json)",
"plugins.code": "Plugin Code (main file)",
"plugins.view_code": "View Code",
"plugins.uninstall": "Uninstall",
"plugins.enable": "Enable",
"plugins.disable": "Disable",
"plugins.enabled": "Plugin '{name}' enabled.",
"plugins.disabled": "Plugin '{name}' disabled.",
"plugins.installed": "Plugin '{name}' installed successfully.",
"plugins.uninstalled": "Plugin '{name}' uninstalled.",
"plugins.confirm_uninstall": "Uninstall plugin '{name}'? This cannot be undone.",
"help.plugins_title": "🧩 Plugins",
"help.plugins_intro": "Plugins extend ObsiGate: custom file rendering, search filters, editor actions, widgets and integrations. They run in sandboxed Web Workers — isolated from the page for security.",
"help.plugins_install": "Installation",
"help.plugins_install_1": "Open Settings → 🧩 Plugins.",
"help.plugins_install_2": "Click 'Install a plugin'.",
"help.plugins_install_3": "Select a .zip file containing the plugin.json manifest and the plugin code.",
"help.plugins_install_4": "The manifest is validated (name, semver version, hooks, permissions) before installation.",
"help.plugins_manage": "Management",
"help.plugins_manage_1": "Enable / Disable: buttons on each plugin — hooks only run if the plugin is enabled.",
"help.plugins_manage_2": "Uninstall: permanently removes the plugin from the vault.",
"help.plugins_manage_3": "Code: view the source code of the installed plugin.",
"help.plugins_dev": "Develop a plugin",
"help.plugins_dev_intro": "Download the template from Settings → Plugins → 'Get template'. A plugin consists of a plugin.json manifest and a main file (e.g. index.js).",
"help.plugins_hooks": "Available hooks",
"help.plugins_hook_render": "onFileRender",
"help.plugins_hook_render_desc": "transforms the displayed content of a file.",
"help.plugins_hook_search": "onSearchFilter",
"help.plugins_hook_search_desc": "filters search results.",
"help.plugins_hook_editor": "onEditorAction",
"help.plugins_hook_editor_desc": "intercepts editor actions.",
"help.plugins_hook_sidebar": "onSidebarItem",
"help.plugins_hook_sidebar_desc": "adds items to the sidebar.",
"help.plugins_hook_file": "onFileCreate / onFileDelete",
"help.plugins_hook_file_desc": "reacts to file creation / deletion.",
"help.plugins_hook_vault": "onVaultMount",
"help.plugins_hook_vault_desc": "runs when a vault is loaded.",
"help.plugins_security": "Security",
"help.plugins_security_1": "Web Worker sandbox: plugin code runs isolated, with no direct DOM access.",
"help.plugins_security_2": "Permissions: each declared hook must be satisfied by the manifest permissions.",
"help.plugins_security_3": "Secure packaging: the .zip is checked against path traversal on install.",
"help.plugins_security_4": "Admin only: installation and removal require the admin role.",
"help.plugins_template": "Use the <strong>Template</strong> button in Settings > Plugins to generate a valid starter plugin.",
"help.excalidraw_title": "🎨 Excalidraw diagrams",
"help.excalidraw_intro": "<code>.excalidraw</code> and <code>.excalidraw.md</code> files open in a full visual Excalidraw editor embedded in a sandboxed iframe.",
"help.excalidraw_open_title": "Opening",
"help.excalidraw_open": "Click a <code>.excalidraw</code> file in the tree: the editor opens instead of the markdown viewer.",
"help.excalidraw_edit": "Draw, add shapes, text, arrows and images — the complete Excalidraw experience.",
"help.excalidraw_save_title": "Saving",
"help.excalidraw_save": "Changes are saved automatically (2s debounce) or with <strong>Ctrl+S</strong>. The editor follows the light/dark theme.",
"help.excalidraw_search_title": "Search",
"help.excalidraw_search": "Text inside diagram elements is extracted on indexing, so it is searchable via the full-text search.",
"help.excalidraw_compat": "Files created with the Obsidian Excalidraw plugin (including the compressed <code>.excalidraw.md</code> format) are compatible.",
"help.footer_tagline": "- Web gateway for your Obsidian vaults"
}
+218 -2
View File
@@ -42,6 +42,19 @@
"about.tests": "Tests",
"about.vaults_configured": "Vaults configurés",
"about.version": "Version",
"admin.audit_all_actions": "Toutes les actions",
"admin.audit_empty": "Aucun log disponible",
"admin.audit_filter_action": "Action",
"admin.audit_filter_user": "Filtrer par utilisateur",
"admin.audit_refresh": "Rafraîchir",
"admin.audit_title": "Logs d'audit",
"admin.back_btn": "← Retour",
"admin.backups_newest": "Plus récent : {days} jours",
"admin.backups_oldest": "Plus ancien : {days} jours",
"admin.backups_per_vault": "Par vault",
"admin.backups_title": "Statistiques backups",
"admin.backups_total": "Total : {count} backups ({size})",
"admin.backups_vault_empty": "Aucun backup",
"admin.close": "Fermer",
"admin.col_actions": "Actions",
"admin.col_last_login": "Dernière connexion",
@@ -49,10 +62,36 @@
"admin.col_status": "Statut",
"admin.col_user": "Utilisateur",
"admin.col_vaults": "Vaults",
"admin.forbidden_msg": "Vous devez être administrateur pour accéder à cette page.",
"admin.forbidden_title": "Accès refusé",
"admin.header_subtitle": "Tableau de bord administrateur",
"admin.new_user": "+ Nouvel utilisateur",
"admin.page_title": "ObsiGate — Administration",
"admin.stats_cpu": "CPU",
"admin.stats_disk": "Disque",
"admin.stats_memory": "Mémoire",
"admin.stats_refresh": "Dernière mise à jour : {time}",
"admin.stats_sessions": "Sessions actives",
"admin.stats_title": "Statistiques temps réel",
"admin.stats_uptime": "Uptime",
"admin.title": "Administration — Utilisateurs",
"admin.users_manage_btn": "Gérer les utilisateurs",
"admin.users_manage_hint": "Ouvrir la modale de gestion des utilisateurs (CRUD complet)",
"admin.users_title": "Gestion utilisateurs",
"ai.action_applied": "Appliqué ✓",
"ai.action_apply": "Appliquer",
"ai.action_applying": "Application…",
"ai.action_create_dir": "Créer le dossier {path}",
"ai.action_create_file": "Créer le fichier {path}",
"ai.action_created_dir": "Dossier créé : {path}",
"ai.action_created_file": "Fichier créé : {path}",
"ai.action_failed": "Échec de l'action : {error}",
"ai.casual": "Ton décontracté",
"ai.close": "Fermer l'assistant",
"ai.completion_added": "AI: complétion ajoutée",
"ai.context_documents": "{count} document(s) ouvert(s)",
"ai.context_general": "Contexte général",
"ai.context_general_desc": "Questions sur l'application et création de fichiers",
"ai.continue": "Continuer",
"ai.custom_rewrite": "Réécriture personnalisée",
"ai.done": "AI: texte traité",
@@ -60,7 +99,9 @@
"ai.error_invalid_key": "clé API invalide",
"ai.explain": "Expliquer",
"ai.fix_spelling": "Corriger l'orthographe",
"ai.fix_spelling_hint": "Corrige les fautes",
"ai.frontmatter": "Générer frontmatter",
"ai.fullscreen": "Plein écran",
"ai.generate": "Générer",
"ai.hint_add_details": "Ajoute des détails",
"ai.hint_edit_selection": "Modifie le texte sélectionné",
@@ -68,6 +109,7 @@
"ai.hint_improve": "Améliore la qualité du texte",
"ai.hint_translate": "Traduit le texte sélectionné",
"ai.improve": "Améliorer",
"ai.input_hint": "Entrée pour envoyer · Ctrl+Entrée pour un saut de ligne",
"ai.inline_completion_title": "AI Inline Completion (Ctrl+J) — Complète le texte automatiquement",
"ai.invalid_api_key": "AI: clé API invalide. Vérifiez DEEPSEEK_API_KEY dans .env",
"ai.longer": "Allonger",
@@ -75,20 +117,38 @@
"ai.not_configured": "⚠️ AI non configuré — ajouter DEEPSEEK_API_KEY, OPENROUTER_API_KEY ou GEMINI_API_KEY dans .env",
"ai.processing": "⏳ AI: traitement en cours...",
"ai.professional": "Ton professionnel",
"ai.provider": "Fournisseur",
"ai.provider_default": "— défaut —",
"ai.model": "Modèle",
"ai.model_default": "— défaut —",
"ai.model_loading": "Chargement…",
"ai.model_offline": "hors ligne",
"ai.model_load_error": "Erreur de chargement",
"ai.model_settings": "Fournisseur & modèle",
"ai.quota_exceeded": "AI: quota dépassé ou paiement requis",
"ai.rewrite": "💬 Réécrire",
"ai.rewrite_done": "AI: texte réécrit",
"ai.rewrite_prompt": "Instruction de réécriture :",
"ai.rewrite_placeholder": "Ex. : rends le ton plus formel et ajoute une conclusion",
"ai.rewrite_title": "Réécrit le texte selon vos instructions",
"ai.select_text": "AI: sélectionnez du texte à traiter",
"ai.shorter": "Raccourcir",
"ai.shorter_hint": "Rend le texte plus concis",
"ai.simplify": "Simplifier",
"ai.simplify_hint": "Simplifie le langage",
"ai.suggestion_capabilities": "Que sais-tu faire ?",
"ai.suggestion_create_file": "Crée un fichier de notes de réunion",
"ai.suggestion_docs_contradictions": "Y a-t-il des contradictions entre ces documents ?",
"ai.suggestion_docs_keypoints": "Quels sont les points clés de ces documents ?",
"ai.suggestion_docs_summary": "Résume les documents ouverts",
"ai.suggestion_search": "Comment rechercher efficacement dans mes notes ?",
"ai.summarize": "Résumer",
"ai.text_processed": "AI: texte traité ✓",
"ai.text_rewritten": "AI: texte réécrit",
"ai.to_canvas": "Convertir en Canvas",
"ai.to_list": "Convertir en liste",
"ai.to_table": "Convertir en tableau",
"ai.tone_hint": "Change le ton du texte",
"ai.toolbar_edit": "Éditer",
"ai.toolbar_generate": "Générer",
"ai.toolbar_rewrite": "Réécrire",
@@ -96,6 +156,7 @@
"ai.toolbar_toolbox": "Toolbox",
"ai.toolbar_translate": "Traduire",
"ai.toolbox": "🧰 Boîte",
"ai.toolbox_hint": "Outils de conversion",
"ai.translate_to": "Traduire en {lang}",
"auth.cannot_delete_self": "Impossible de supprimer son propre compte",
"auth.logged_out": "Déconnecté",
@@ -425,6 +486,8 @@
"config.section_vue-graphe": "🗺️ Vue Graphe",
"config.section_watcher": "Synchronisation automatique",
"config.section_webhooks": "🪝 Webhooks",
"config.section_push": "🔔 Notifications push",
"config.section_plugins": "🧩 Plugins",
"config.section_workflow-recommande": "Workflow recommandé",
"config.section_zone-de-contenu": "Zone de contenu",
"config.server_config": "Configuration serveur",
@@ -451,6 +514,8 @@
"config.webhook_url_placeholder": "URL",
"config.webhooks": "Webhooks",
"config.webhooks_desc": "Notifications HTTP vers des services externes lors des changements de fichiers.",
"config.plugins": "Plugins",
"config.plugins_desc": "Gérer les plugins installés pour ce vault.",
"config.workers_label": "Workers de recherche",
"confirm.delete_dir": "Supprimer ce dossier et tout son contenu ?",
"confirm.delete_dir_named": "Supprimer \"{path}\" et TOUT son contenu ?",
@@ -586,6 +651,8 @@
"header.menu_about_desc": "Version, crédits & info",
"header.menu_admin": "Administration",
"header.menu_admin_desc": "Gérer les utilisateurs",
"header.menu_api": "API",
"header.menu_api_desc": "Documentation OpenAPI",
"header.menu_config": "Configurations",
"header.menu_config_desc": "Paramètres de l'application",
"header.menu_help": "Guide d'utilisation",
@@ -1037,6 +1104,8 @@
"help.nav_tags": "🏷️ Tags",
"help.nav_tips": "💡 Astuces",
"help.nav_webhooks": "🪝 Webhooks",
"help.nav_plugins": "🧩 Plugins",
"help.nav_excalidraw": "🎨 Excalidraw",
"help.new_file": "Nouveau fichier",
"help.new_folder": "Nouveau dossier",
"help.nights": "nuits blanches 😅",
@@ -1303,6 +1372,7 @@
"settings.search_placeholder": "Rechercher...",
"settings.sync": "🔄 Synchronisation",
"settings.themes": "🎨 Thèmes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Lien copié !",
"share.copy_link": "Copier le lien",
"share.create": "Créer un lien de partage",
@@ -1377,7 +1447,14 @@
"theme.change": "Changer le thème",
"theme.dark": "Sombre",
"theme.light": "Clair",
"theme.high_contrast": "Contraste élevé",
"theme.sepia": "Sépia",
"theme.title": "Thème",
"theme.export_all": "Exporter les thèmes",
"theme.import": "Importer un thème",
"theme.import_success": "{count} thème(s) importé(s)",
"theme.import_error": "Erreur d'importation",
"theme.delete_confirm": "Supprimer ce thème personnalisé ?",
"themes.name_gradients": "Degrades subtils",
"themes.name_terminal": "Noir pur & vert",
"toast.ai_keys_saved": "Clés API sauvegardées",
@@ -1465,6 +1542,14 @@
"viewer.download_pdf": "Télécharger en PDF",
"viewer.edit": "Éditer",
"viewer.error": "Erreur de chargement",
"viewer.export": "Exporter",
"viewer.export_done": "Export terminé",
"viewer.export_epub": "Exporter en ePub",
"viewer.export_error": "Erreur d'export :",
"viewer.export_html": "Exporter en HTML",
"viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)",
"viewer.export_start": "Export en cours...",
"viewer.export_title": "Exporter le document",
"viewer.forge_brand": "Forge",
"viewer.forge_title": "Forge (nouvel éditeur)",
"viewer.index_start": "Démarrage index.",
@@ -1495,5 +1580,136 @@
"webhook.trigger_dir_create": "Création de dossier via API",
"webhook.trigger_dir_delete": "Suppression de dossier via l'API",
"webhook.trigger_dir_rename": "Renommage de dossier via l'API",
"webhook.trigger_rename": "Renommage via l'API"
}
"webhook.trigger_rename": "Renommage via l'API",
"settings.security": "🔒 Sécurité du compte",
"settings.security_desc": "Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.",
"mfa.title": "Authentification à deux facteurs",
"mfa.subtitle": "Entrez le code à 6 chiffres de votre application d'authentification.",
"mfa.verify": "Vérifier",
"mfa.verifying": "Vérification...",
"mfa.use_recovery": "Utiliser un code de récupération",
"mfa.back_to_login": "Retour à la connexion",
"mfa.recovery_title": "Code de récupération",
"mfa.recovery_subtitle": "Entrez l'un de vos codes de récupération (format : XXXX-XXXX).",
"mfa.use_totp": "Utiliser le code authenticator",
"mfa.status_label": "Authentification à deux facteurs",
"mfa.enabled": "Activée",
"mfa.disabled": "Désactivée",
"mfa.enabled_desc": "Votre compte est protégé par l'authentification à deux facteurs.",
"mfa.setup_desc": "Ajoutez une couche de sécurité supplémentaire à votre compte avec le TOTP.",
"mfa.enable_btn": "Activer la 2FA",
"mfa.disable_btn": "Désactiver la 2FA",
"mfa.scan_qr": "Scannez ce QR code avec votre application d'authentification",
"mfa.manual_entry": "Saisie manuelle (si vous ne pouvez pas scanner)",
"mfa.enter_code": "Entrez le code à 6 chiffres pour confirmer",
"mfa.confirm_enable": "Confirmer et activer",
"mfa.recovery_codes_title": "Codes de récupération",
"mfa.recovery_codes_warning": "Enregistrez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois.",
"mfa.copy_codes": "Copier",
"mfa.download_codes": "Télécharger",
"mfa.done": "Terminé",
"mfa.codes_copied": "Codes de récupération copiés !",
"mfa.disable_confirm_title": "Désactiver la 2FA",
"mfa.disable_confirm_desc": "Entrez votre mot de passe et un code TOTP valide pour désactiver l'authentification à deux facteurs.",
"mfa.password_label": "Mot de passe",
"mfa.password_placeholder": "Votre mot de passe actuel",
"mfa.totp_code_label": "Code TOTP",
"mfa.disable_confirm_btn": "Désactiver la 2FA",
"mfa.disabled_success": "La 2FA a été désactivée.",
"mfa.fill_all_fields": "Veuillez remplir tous les champs.",
"bookslm.title": "BooksLM",
"bookslm.files_indexed": "{count} fichiers indexés",
"bookslm.chars_loaded": "{chars} caractères chargés",
"bookslm.placeholder": "Posez une question sur ces documents...",
"bookslm.send": "Envoyer",
"bookslm.new_conversation": "Nouvelle conversation",
"bookslm.export": "Exporter la conversation",
"bookslm.toggle_sidebar": "Masquer/Afficher la sidebar AI",
"bookslm.copy": "Copier",
"bookslm.error": "Erreur du service AI",
"bookslm.regenerate": "Régénérer",
"bookslm.suggestion_summary": "Résume ce répertoire",
"bookslm.suggestion_themes": "Quels sont les thèmes principaux ?",
"bookslm.suggestion_contradictions": "Y a-t-il des contradictions entre ces documents ?",
"bookslm.no_context": "Aucun fichier trouvé dans ce répertoire",
"bookslm.context_too_large": "Répertoire trop volumineux — certains fichiers ont été tronqués",
"bookslm.source": "Source",
"palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant",
"palette.bookslm_open_desc": "Ouvre l'assistant AI pour le contexte courant (documents ouverts ou général)",
"palette.bookslm_new": "BooksLM: Nouvelle conversation",
"palette.bookslm_new_desc": "Réinitialise la conversation de l'assistant AI",
"fab.open": "Ouvrir l'assistant AI",
"fab.close": "Fermer l'assistant AI",
"mfa.webauthn_title": "Clés de sécurité (WebAuthn)",
"mfa.webauthn_desc": "Authentifiez-vous avec une clé physique (YubiKey) ou la biométrie de votre appareil (Windows Hello, Touch ID).",
"mfa.webauthn_none": "Aucune clé enregistrée.",
"mfa.webauthn_add": "Ajouter une clé de sécurité",
"mfa.webauthn_label_prompt": "Nom de la clé (ex : YubiKey de poche)",
"mfa.webauthn_added": "Clé de sécurité enregistrée.",
"mfa.webauthn_removed": "Clé de sécurité supprimée.",
"mfa.webauthn_remove": "Retirer",
"mfa.webauthn_remove_confirm": "Entrez votre mot de passe pour retirer cette clé :",
"mfa.webauthn_prompt": "Présentez votre clé de sécurité ou confirmez avec Windows Hello.",
"mfa.webauthn_btn": "Valider avec ma clé",
"mfa.webauthn_cancelled": "Cérémonie WebAuthn annulée.",
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte.",
"plugins.title": "🧩 Plugins",
"plugins.description": "Étendez ObsiGate avec des renderers personnalisés, filtres de recherche et actions d'éditeur.",
"plugins.install": "Installer le plugin",
"plugins.template": "Voir le modèle",
"plugins.none": "Aucun plugin installé.",
"plugins.manifest_json": "Manifeste (plugin.json)",
"plugins.code": "Code du plugin (fichier principal)",
"plugins.view_code": "Voir le code",
"plugins.uninstall": "Désinstaller",
"plugins.enable": "Activer",
"plugins.disable": "Désactiver",
"plugins.enabled": "Plugin '{name}' activé.",
"plugins.disabled": "Plugin '{name}' désactivé.",
"plugins.installed": "Plugin '{name}' installé avec succès.",
"plugins.uninstalled": "Plugin '{name}' désinstallé.",
"plugins.confirm_uninstall": "Désinstaller le plugin '{name}' ? Cette action est irréversible.",
"help.plugins_title": "🧩 Plugins",
"help.plugins_intro": "Les plugins étendent ObsiGate : affichage personnalisé des fichiers, filtres de recherche, actions d'édition, widgets et intégrations. Ils s'exécutent dans des Web Workers sandboxés — isolés de la page pour la sécurité.",
"help.plugins_install": "Installation",
"help.plugins_install_1": "Ouvrez Réglages → 🧩 Plugins.",
"help.plugins_install_2": "Cliquez « Installer un plugin ».",
"help.plugins_install_3": "Sélectionnez un fichier .zip contenant le manifest plugin.json et le code du plugin.",
"help.plugins_install_4": "Le manifest est validé (nom, version semver, hooks, permissions) avant installation.",
"help.plugins_manage": "Gestion",
"help.plugins_manage_1": "Activer / Désactiver : boutons sur chaque plugin — les hooks ne tournent que si le plugin est activé.",
"help.plugins_manage_2": "Désinstaller : supprime définitivement le plugin du vault.",
"help.plugins_manage_3": "Code : visualisez le code source du plugin installé.",
"help.plugins_dev": "Développer un plugin",
"help.plugins_dev_intro": "Téléchargez le modèle depuis Réglages → Plugins → « Obtenir le modèle ». Un plugin se compose d'un manifest plugin.json et d'un fichier principal (ex. index.js).",
"help.plugins_hooks": "Hooks disponibles",
"help.plugins_hook_render": "onFileRender",
"help.plugins_hook_render_desc": "transforme le contenu affiché d'un fichier.",
"help.plugins_hook_search": "onSearchFilter",
"help.plugins_hook_search_desc": "filtre les résultats de recherche.",
"help.plugins_hook_editor": "onEditorAction",
"help.plugins_hook_editor_desc": "intercepte les actions de l'éditeur.",
"help.plugins_hook_sidebar": "onSidebarItem",
"help.plugins_hook_sidebar_desc": "ajoute des éléments à la barre latérale.",
"help.plugins_hook_file": "onFileCreate / onFileDelete",
"help.plugins_hook_file_desc": "réagit à la création / suppression de fichiers.",
"help.plugins_hook_vault": "onVaultMount",
"help.plugins_hook_vault_desc": "exécuté au chargement d'un vault.",
"help.plugins_security": "Sécurité",
"help.plugins_security_1": "Sandbox Web Worker : le code plugin tourne isolé, sans accès direct au DOM.",
"help.plugins_security_2": "Permissions : chaque hook déclaré doit être satisfait par les permissions du manifest.",
"help.plugins_security_3": "Empaquetage sécurisé : le .zip est vérifié contre le path traversal à l'installation.",
"help.plugins_security_4": "Admin uniquement : l'installation et la suppression requièrent le rôle admin.",
"help.plugins_template": "Utilisez le bouton <strong>Template</strong> dans Paramètres > Plugins pour générer un plugin starter valide.",
"help.excalidraw_title": "🎨 Diagrammes Excalidraw",
"help.excalidraw_intro": "Les fichiers <code>.excalidraw</code> et <code>.excalidraw.md</code> s'ouvrent dans un éditeur visuel Excalidraw complet, intégré dans une iframe sandboxée.",
"help.excalidraw_open_title": "Ouverture",
"help.excalidraw_open": "Cliquez sur un fichier <code>.excalidraw</code> dans l'arborescence : l'éditeur s'ouvre à la place du viewer markdown.",
"help.excalidraw_edit": "Dessinez, ajoutez des formes, du texte, des flèches et des images — l'expérience Excalidraw complète.",
"help.excalidraw_save_title": "Sauvegarde",
"help.excalidraw_save": "Les modifications sont sauvegardées automatiquement (2 s) ou avec <strong>Ctrl+S</strong>. L'éditeur suit le thème clair/sombre.",
"help.excalidraw_search_title": "Recherche",
"help.excalidraw_search": "Le texte des éléments du diagramme est extrait à l'indexation : il est donc recherchable via la recherche full-text.",
"help.excalidraw_compat": "Les fichiers créés avec le plugin Obsidian Excalidraw (y compris le format <code>.excalidraw.md</code> compressé) sont compatibles.",
"help.footer_tagline": "- Porte d'entrée web pour vos vaults Obsidian"
}
+357 -7
View File
@@ -522,15 +522,16 @@ a:hover {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
/* Version badge in header */
/* Version badge in header — always visible */
.version-badge {
display: none;
font-size: 10px;
color: var(--text-muted);
padding: 2px 8px;
display: inline-block;
font-size: 11px;
color: var(--text-secondary);
font-weight: 500;
padding: 3px 10px;
border-radius: 10px;
background: color-mix(in srgb, var(--text-muted) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--text-muted) 15%, transparent);
background: color-mix(in srgb, var(--text-secondary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--text-secondary) 20%, transparent);
font-family: var(--font-mono, monospace);
white-space: nowrap;
cursor: default;
@@ -2398,6 +2399,34 @@ select {
background: var(--accent);
color: #fff;
}
/* Theme import/export actions */
.theme-actions {
display: flex;
gap: 8px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.theme-action-btn {
padding: 5px 14px;
font-size: 0.7rem;
font-weight: 600;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.theme-action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.theme-custom-badge {
font-size: 0.6rem;
color: var(--accent);
margin-left: 2px;
}
/* Profile section */
.profile-form { max-width: 420px; }
.profile-field { margin-bottom: 14px; }
@@ -2646,6 +2675,88 @@ select {
50% { opacity: 0.6; }
}
/* AI custom-rewrite modal — replaces the browser prompt() for a consistent,
theme-aware and keyboard-accessible experience. */
.ai-modal-overlay {
position: fixed;
inset: 0;
z-index: 10050;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
}
.ai-modal {
width: 100%;
max-width: 460px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 18px 14px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
}
.ai-modal-title {
margin: 0 0 4px;
font-size: 0.95rem;
color: var(--text-primary);
}
.ai-modal-hint {
margin: 0 0 10px;
font-size: 0.78rem;
color: var(--text-muted);
}
.ai-modal-input {
width: 100%;
resize: vertical;
min-height: 72px;
padding: 8px 10px;
font-family: inherit;
font-size: 0.85rem;
color: var(--text-primary);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
}
.ai-modal-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.25);
}
.ai-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.ai-modal-btn {
padding: 6px 14px;
font-size: 0.8rem;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--text-primary);
}
.ai-modal-btn:hover { background: var(--bg-hover); }
.ai-modal-btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.ai-modal-btn.primary:hover { filter: brightness(1.08); }
.editor-title {
font-family: "JetBrains Mono", monospace;
font-size: 0.9rem;
@@ -3805,6 +3916,12 @@ body.resizing-v {
cursor: pointer;
}
.config-select option {
background: var(--bg-input, #1a1a2e);
color: var(--text-primary, #e0e0e0);
padding: 4px 8px;
}
.config-btn-add {
padding: 8px 16px;
border: 1px solid var(--accent);
@@ -8878,3 +8995,236 @@ body.popup-mode .content-area {
right: 4px;
}
}
/* ── MFA (Multi-Factor Authentication) ────────────────────────────────── */
.mfa-challenge {
margin-top: 20px;
padding: 24px;
border-radius: 12px;
background: var(--surface, #1a1a2e);
border: 1px solid var(--border, #333);
text-align: center;
}
.mfa-challenge .mfa-icon { font-size: 48px; margin-bottom: 12px; }
.mfa-challenge h3 { margin: 0 0 8px; color: var(--text, #fff); }
.mfa-challenge p { color: var(--muted, #999); margin: 0 0 16px; }
.mfa-code-input {
width: 180px;
font-size: 28px;
text-align: center;
letter-spacing: 12px;
padding: 12px;
border: 2px solid var(--border, #444);
border-radius: 8px;
background: var(--bg, #0d0d1a);
color: var(--text, #fff);
font-family: monospace;
outline: none;
}
.mfa-code-input:focus { border-color: var(--accent, #7C3AED); }
.mfa-code-input-group { display: flex; gap: 8px; justify-content: center; align-items: center; margin-bottom: 12px; }
.mfa-code-input-group span { color: var(--muted, #999); font-size: 24px; }
.mfa-link-btn {
background: none; border: none; color: var(--accent, #7C3AED);
cursor: pointer; font-size: 13px; text-decoration: underline;
}
.mfa-link-btn:hover { opacity: 0.8; }
.mfa-error { color: #e74c3c; font-size: 13px; margin-top: 8px; }
.mfa-recovery-input { width: 200px; font-size: 20px; letter-spacing: 4px; }
/* MFA Settings (Security tab) */
.mfa-status-section { padding: 16px 0; }
.mfa-status-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.mfa-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600;
}
.mfa-badge-on { background: #1a3a2a; color: #4ade80; }
.mfa-badge-off { background: #3a2a1a; color: #f59e0b; }
.mfa-status-label { color: var(--text, #fff); font-weight: 500; }
/* MFA Setup card */
.mfa-setup-card {
padding: 20px; border-radius: 10px;
background: var(--surface2, #1e1e3a); border: 1px solid var(--border, #333);
}
.mfa-setup-card h4 { margin: 0 0 12px; color: var(--text, #fff); }
.mfa-qr-container { text-align: center; margin: 16px 0; }
.mfa-qr-code img, .mfa-qr-code canvas { max-width: 200px; border-radius: 8px; }
.mfa-secret-details { margin-top: 12px; }
.mfa-secret-code {
font-family: monospace; font-size: 14px; padding: 8px 12px;
background: var(--bg, #0d0d1a); border-radius: 6px;
color: var(--accent, #7C3AED); cursor: pointer; user-select: all;
display: inline-block;
}
.mfa-info-text { color: var(--muted, #999); font-size: 13px; margin: 8px 0; }
.mfa-subtitle { color: var(--muted, #999); font-size: 13px; margin: 0 0 16px; }
.mfa-verify-section { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
.mfa-actions { margin-top: 16px; }
/* MFA Recovery codes display */
.mfa-recovery-card {
padding: 20px; border-radius: 10px;
background: var(--surface2, #1e1e3a); border: 1px solid var(--border, #333);
margin-top: 16px;
}
.mfa-recovery-card h4 { margin: 0 0 8px; color: var(--text, #fff); }
.mfa-recovery-list {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 24px;
margin: 12px 0; list-style: none; padding: 0;
}
.mfa-recovery-code {
font-family: monospace; font-size: 14px; padding: 4px 8px;
background: var(--bg, #0d0d1a); border-radius: 4px;
color: var(--text, #fff); letter-spacing: 1px;
}
.mfa-recovery-actions { display: flex; gap: 8px; margin-top: 12px; }
/* WebAuthn security keys (ROADMAP #64) */
.webauthn-settings { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border, #333); }
.webauthn-title { margin: 0 0 6px; color: var(--text, #fff); font-size: 0.95rem; }
.webauthn-key-list { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.webauthn-key-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
background: var(--surface2, #1a1a2e); border-radius: 6px;
}
.webauthn-key-label { flex: 1; font-size: 0.9rem; color: var(--text, #fff); }
.webauthn-key-meta { font-size: 0.75rem; color: var(--text-muted, #888); }
.config-btn-sm { padding: 4px 10px; font-size: 0.78rem; }
/* MFA Disable card */
.mfa-disable-card {
padding: 16px; border-radius: 10px;
background: var(--surface2, #1e1e3a); border: 1px solid #e74c3c33;
margin-top: 16px;
}
.mfa-disable-card h4 { margin: 0 0 8px; color: #e74c3c; }
.mfa-disable-actions { display: flex; gap: 8px; margin-top: 12px; }
.mfa-warning {
color: #f59e0b; font-size: 13px; padding: 8px 12px;
background: #3a2a1a; border-radius: 6px; margin-top: 8px;
}
/* ── BooksLM Panel ──────────────────────────────────── */
.bookslm-panel { position: fixed; right: 0; top: 0; bottom: 0; width: 450px;
background: var(--bg-primary); border-left: 1px solid var(--border);
z-index: 100; display: flex; flex-direction: column;
transform: translateX(100%); transition: transform 300ms ease-out;
box-shadow: -4px 0 20px rgba(0,0,0,0.3);
color: var(--text-primary); }
.bookslm-panel.open { transform: translateX(0); }
/* The `.hidden` class is applied by the header toggle button to collapse the
panel while keeping it mounted (so the toggle icon stays usable). When
hidden, the panel is slid fully off-screen and the open animation no
longer overrides the translateX. */
.bookslm-panel.open.hidden { transform: translateX(100%); }
.bookslm-btn-toggle {
display: flex; align-items: center; justify-content: center;
background: none; border: none; cursor: pointer;
color: var(--text-secondary); padding: 4px;
border-radius: 4px; flex-shrink: 0;
transition: color 200ms ease, background 200ms ease;
}
.bookslm-btn-toggle:hover { color: var(--accent); background: var(--surface2); }
.bookslm-header { display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border-bottom: 1px solid var(--border); }
.bookslm-header .bookslm-context-icon { display: flex; color: var(--accent); flex-shrink: 0; }
.bookslm-header-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
.bookslm-header-titles .bookslm-title { font-size: 13px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookslm-header-titles .bookslm-subtitle { font-size: 11px; color: var(--text-secondary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookslm-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.bookslm-header button { background: none; border: none; cursor: pointer;
color: var(--text-secondary); padding: 5px; border-radius: 6px; display: flex; align-items: center; }
.bookslm-header button:hover { background: var(--surface2); color: var(--text-primary); }
/* Provider/model picker on its own row so the header actions never get hidden. */
.bookslm-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); }
.bookslm-toolbar-label { font-size: 11px; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
.bookslm-toolbar .ai-picker { margin-left: 0; padding-left: 0; border-left: none;
flex-wrap: wrap; row-gap: 4px; }
.bookslm-toolbar .ai-picker select { max-width: 200px; }
.bookslm-panel.fullscreen { width: 100vw; }
.bookslm-status { padding: 6px 16px; font-size: 12px; color: var(--text-secondary);
border-bottom: 1px solid var(--border); display: flex; gap: 12px; align-items: center; }
.bookslm-status .bookslm-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookslm-status .bookslm-status-error { color: #f87171; }
.bookslm-status .bookslm-context-bar { flex: 1; height: 4px; border-radius: 2px;
background: var(--surface2); overflow: hidden; }
.bookslm-status .bookslm-context-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; }
.bookslm-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.bookslm-bubble { max-width: 85%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.5; word-wrap: break-word; }
.bookslm-bubble.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
.bookslm-bubble.assistant { align-self: flex-start; background: var(--surface2); color: var(--text-primary); border-bottom-left-radius: 4px; }
.bookslm-bubble.assistant code { background: rgba(0,0,0,0.2); padding: 1px 4px; border-radius: 3px; font-size: 0.9em; }
.bookslm-bubble.assistant pre { background: rgba(0,0,0,0.3); padding: 10px; border-radius: 6px; overflow-x: auto; margin: 8px 0; }
.bookslm-sources { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
.bookslm-source-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px;
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
font-size: 11px; color: var(--accent); cursor: pointer; }
.bookslm-source-badge:hover { background: var(--surface2); }
.bookslm-input-area { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); align-items: flex-end; }
.bookslm-input-area textarea { flex: 1; resize: none; min-height: 36px; max-height: 120px;
padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
background: var(--bg-primary); color: var(--text-primary); font-size: 14px; font-family: inherit; }
.bookslm-input-area textarea:focus { border-color: var(--accent); outline: none; }
.bookslm-input-area button { padding: 8px 16px; border-radius: 8px; border: none;
background: var(--accent); color: #fff; cursor: pointer; font-size: 14px; }
.bookslm-input-area button:hover { opacity: 0.9; }
.bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
.bookslm-input-hint { padding: 0 16px 10px; font-size: 10px; color: var(--text-secondary);
text-align: right; border-top: none; }
/* Action cards proposed by the General assistant (file/dir creation). */
.bookslm-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.bookslm-action { display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border: 1px solid var(--border); border-radius: 8px; background: var(--bg-primary); }
.bookslm-action.done { opacity: 0.65; }
.bookslm-action-meta { flex: 1; display: flex; align-items: center; gap: 6px; font-size: 12px;
color: var(--text-primary); word-break: break-word; }
.bookslm-action-icon { flex-shrink: 0; }
.bookslm-action-apply { padding: 5px 12px; border-radius: 6px; border: none;
background: var(--accent); color: #fff; cursor: pointer; font-size: 12px; flex-shrink: 0; }
.bookslm-action-apply:disabled { opacity: 0.6; cursor: default; }
.bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
.bookslm-suggestions { display: flex; flex-direction: column; gap: 6px; padding: 8px 16px 0; }
.bookslm-suggestion { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; }
.bookslm-suggestion:hover { background: var(--surface2); color: var(--text-primary); border-color: var(--accent); }
@media (max-width: 768px) {
.bookslm-panel { width: 100%; }
}
/* ── AI Floating Action Button ───────────────────────── */
.ai-fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 99;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: transform 200ms ease, opacity 200ms ease;
padding: 0;
}
.ai-fab:hover {
transform: scale(1.08);
}
.ai-fab:active {
transform: scale(0.96);
}
.ai-fab[hidden] {
display: none;
}
+172 -149
View File
@@ -1,197 +1,220 @@
/* ObsiGate Service Worker - PWA v2 with IndexedDB offline support */
/* ObsiGate — Service Worker (Push notifications + Offline cache)
* Version: 1.0.0
* Scope: /
*/
const CACHE_NAME = 'obsigate-v1';
const OFFLINE_CACHE = 'obsigate-offline-v1';
const CACHE_VERSION = 'obsigate-v6-i18n';
const STATIC_CACHE = CACHE_VERSION + '-static';
const DYNAMIC_CACHE = CACHE_VERSION + '-dynamic';
const MAX_DYNAMIC_CACHE_SIZE = 100;
const STATIC_ASSETS = [
// Files to cache for offline access
const PRECACHE_URLS = [
'/',
'/static/index.html',
'/static/app.js',
'/static/style.css',
'/static/manifest.json',
'/static/js/viewer.js',
'/static/js/i18n.js',
'/static/js/auth.js',
'/static/js/dashboard.js',
'/static/js/app.js',
'/static/locales/en.json',
'/static/locales/fr.json'
'/index.html',
'/manifest.json',
'/frontend/js/app.js',
'/frontend/js/state.js',
'/frontend/js/auth.js',
'/frontend/js/ui.js',
'/frontend/js/sidebar.js',
'/frontend/js/viewer.js',
'/frontend/js/search.js',
'/frontend/js/config.js',
'/frontend/js/utils.js',
'/frontend/js/i18n.js',
'/frontend/js/offline.js',
'/frontend/js/offline-db.js',
'/frontend/style.css',
'/static/lucide.min.js'
];
/* ── Install ── */
// ── Install: precache static assets ────────────────────────────────────────
self.addEventListener('install', (event) => {
console.log('[SW] Installing v' + CACHE_VERSION);
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('[SW] Caching static assets');
return cache.addAll(STATIC_ASSETS.map(url => new Request(url, { cache: 'reload' })));
})
.catch((err) => console.error('[SW] Cache error:', err))
.then(() => self.skipWaiting())
caches.open(CACHE_NAME).then((cache) => {
console.log('[SW] Precaching static assets');
return cache.addAll(PRECACHE_URLS);
}).then(() => self.skipWaiting())
);
});
/* ── Activate ── */
// ── Activate: clean old caches ─────────────────────────────────────────────
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name.startsWith('obsigate-') && name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== OFFLINE_CACHE)
.map((name) => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
/* ── Fetch ── */
// ── Fetch: Network-first for API, Cache-first for static ───────────────────
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET') return;
if (url.pathname === '/api/events') return;
if (url.pathname.startsWith('/api/auth/')) return;
// API requests: Network first, tell client to use IndexedDB if offline
const url = new URL(event.request.url);
// Skip non-GET
if (event.request.method !== 'GET') return;
// Skip cross-origin
if (url.origin !== location.origin) return;
// API requests: Network-first with offline fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstAPI(request));
event.respondWith(networkFirstStrategy(event.request));
return;
}
// Static assets: Cache first
event.respondWith(cacheFirstStatic(request, url));
// Static assets: Cache-first
event.respondWith(cacheFirstStrategy(event.request));
});
/* ── Network First for API ── */
async function networkFirstAPI(request) {
// Network-first strategy (for API)
async function networkFirstStrategy(request) {
try {
const response = await fetch(request);
// Cache successful GETs in dynamic cache
if (response && response.status === 200) {
const cache = await caches.open(DYNAMIC_CACHE);
if (response.ok) {
const cache = await caches.open(OFFLINE_CACHE);
cache.put(request, response.clone());
limitCacheSize(DYNAMIC_CACHE, MAX_DYNAMIC_CACHE_SIZE);
}
return response;
} catch (error) {
console.log('[SW] Offline — checking cache for:', request.url);
// Try dynamic cache first
const cached = await caches.match(request);
if (cached) {
// Add offline header so the page can detect
const headers = new Headers(cached.headers);
headers.set('X-ObsiGate-Offline', 'true');
return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers });
}
// Fallback: tell the page to use IndexedDB
return new Response(JSON.stringify({
offline: true,
endpoint: new URL(request.url).pathname,
message: 'Vous etes hors-ligne. Les donnees locales sont utilisees.'
}), {
status: 200,
headers: { 'Content-Type': 'application/json', 'X-ObsiGate-Offline': 'true' }
if (cached) return cached;
return new Response(JSON.stringify({ error: 'Offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
/* ── Cache First for Static ── */
async function cacheFirstStatic(request, url) {
// Cache-first strategy (for static assets)
async function cacheFirstStrategy(request) {
const cached = await caches.match(request);
if (cached) return cached;
if (cached) {
// Stale-while-revalidate: update cache in background
fetch(request).then((response) => {
if (response.ok) {
caches.open(CACHE_NAME).then((cache) => cache.put(request, response));
}
}).catch(() => {});
return cached;
}
try {
const response = await fetch(request);
if (response && response.status === 200) {
const cache = await caches.open(STATIC_CACHE);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
// Offline fallback for HTML navigation
if (request.mode === 'navigate' || url.pathname === '/' || url.pathname.endsWith('/')) {
const offlinePage = await caches.match('/static/index.html');
if (offlinePage) return offlinePage;
} catch (error) {
// Return offline page for navigation requests
if (request.mode === 'navigate') {
return caches.match('/index.html');
}
return new Response('Offline', { status: 503 });
}
}
async function limitCacheSize(cacheName, maxSize) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxSize) {
for (let i = 0; i < keys.length - maxSize; i++) {
await cache.delete(keys[i]);
// ── Push Event: Display notification ───────────────────────────────────────
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body || data.message || 'Nouvelle notification',
icon: '/static/icon-192.png',
badge: '/static/badge-72.png',
vibrate: [200, 100, 200],
tag: data.tag || 'obsigate-notification',
renotify: true,
data: data.data || {},
actions: [
{ action: 'open', title: 'Ouvrir' },
{ action: 'dismiss', title: 'Ignorer' }
],
requireInteraction: false
};
event.waitUntil(
self.registration.showNotification(data.title || 'ObsiGate', options)
);
});
// ── Notification Click: Open the app ───────────────────────────────────────
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
// Default action or 'open' action: navigate to the file
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Try to focus existing window
for (const client of clientList) {
if (client.url.includes(urlToOpen) && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
// ── Notification Close ─────────────────────────────────────────────────────
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event.notification.tag);
});
// ── Background Sync: Periodic index sync ──────────────────────────────────
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-file-index') {
event.waitUntil(syncFileIndex());
}
});
async function syncFileIndex() {
try {
const resp = await fetch('/api/search/advanced?q=**&vault=all&limit=200');
if (resp.ok) {
const data = await resp.json();
const files = (data.results || []).map(r => ({
vault: r.vault,
path: r.path,
title: r.title,
tags: r.tags || [],
snippet: r.snippet || '',
modified: r.modified || ''
}));
// Send to main thread for IndexedDB storage
const clients = await self.clients.matchAll();
clients.forEach(client => client.postMessage({
type: 'OFFLINE_INDEX_SYNC',
files
}));
}
} catch (e) {
console.warn('[SW] Background sync failed:', e);
}
}
/* ── Messages ── */
// ── Periodic Background Sync (if supported) ────────────────────────────────
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'periodic-index-sync') {
event.waitUntil(syncFileIndex());
}
});
// ── Message from main thread ───────────────────────────────────────────────
self.addEventListener('message', (event) => {
const data = event.data;
if (!data || !data.type) return;
switch (data.type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'CLEAR_CACHE':
event.waitUntil(
caches.keys().then(names => Promise.all(names.map(n => caches.delete(n))))
);
break;
case 'CLAIM_CLIENTS':
event.waitUntil(self.clients.claim());
break;
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
/* ── Sync ── */
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'obsigate-sync') {
event.waitUntil(
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({ type: 'SYNC_TRIGGERED' });
});
})
);
}
});
/* ── Push ── */
self.addEventListener('push', (event) => {
const options = {
body: event.data ? event.data.text() : 'Nouvelle mise a jour',
icon: '/static/icons/icon-192x192.svg',
badge: '/static/icons/icon-72x72.svg',
vibrate: [200, 100, 200],
actions: [
{ action: 'open', title: 'Ouvrir ObsiGate' },
{ action: 'close', title: 'Fermer' }
]
};
event.waitUntil(self.registration.showNotification('ObsiGate', options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open') {
event.waitUntil(clients.openWindow('/'));
}
});
});
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# ObsiGate — version bump helper (SemVer MAJOR.MINOR.PATCH)
#
# The canonical version is the latest release tag. This tool inspects the
# commits since that tag and decides the next version from their type
# (Conventional Commits), then creates and pushes the new tag:
#
# breaking change / `!:` / "BREAKING CHANGE" -> MAJOR bump (x.0.0)
# feat / feature -> MINOR bump (x.y.0)
# anything else (fix, perf, refactor, ...) -> PATCH bump (x.y.z)
#
# Usage:
# scripts/bump_version.sh [--major|--minor|--patch] [--dry-run] [--push]
#
# --dry-run : only print the computed next version, do not tag.
# --push : also `git push origin` the new tag (default: local only).
# --major|--minor|--patch : force the increment, overriding commit detection.
#
# Examples:
# scripts/bump_version.sh --dry-run # preview next version
# scripts/bump_version.sh # tag locally per commits
# scripts/bump_version.sh --patch --push # force patch + push
# -----------------------------------------------------------------------------
set -euo pipefail
cd "$(git rev-parse --show-toplevel)" # run from repo root regardless of cwd
BRANCH="$(git branch --show-current)"
# ── Parse args ────────────────────────────────────────────────────────────
FORCE=""; DRY_RUN=0; DO_PUSH=0
for a in "$@"; do
case "$a" in
--major) FORCE=major ;;
--minor) FORCE=minor ;;
--patch) FORCE=patch ;;
--dry-run) DRY_RUN=1 ;;
--push) DO_PUSH=1 ;;
*) echo "✗ Argument inconnu: $a"; exit 2 ;;
esac
done
# ── Current version = latest tag base ─────────────────────────────────────
CURRENT="$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")"
CURRENT="${CURRENT#v}"
IFS='.' read -r MAJ MIN PAT <<< "$CURRENT"
MAJ="${MAJ:-0}"; MIN="${MIN:-0}"; PAT="${PAT:-0}"
echo "Version actuelle : $CURRENT (branche: $BRANCH)"
# ── Detect bump type from commits since last tag ──────────────────────────
if [ -z "$FORCE" ]; then
# Use HEAD~1..HEAD when no tag exists yet (all commits "since" nothing).
RANGE="${CURRENT#0.0.0}" # if 0.0.0 fallback there may be no tag at all
if git rev-parse "v${CURRENT}" >/dev/null 2>&1; then
RANGE="v${CURRENT}..HEAD"
else
RANGE="HEAD~10..HEAD"
echo "⚠ Aucun tag v${CURRENT} trouvé — analyse des 10 derniers commits."
fi
LOG="$(git log --format='%s%n%b' "$RANGE" 2>/dev/null || true)"
BUMP="patch" # default
if echo "$LOG" | grep -qE '^[a-zA-Z]+\([^)]*\)!:|\!:\s|BREAKING CHANGE:'; then
BUMP="major"
elif echo "$LOG" | grep -qiE '^(feat|feature)(\(|:|\s)'; then
BUMP="minor"
fi
# If nothing meaningful in range, keep patch
echo "Détection (${RANGE}) : changement ${BUMP}"
else
BUMP="$FORCE"
echo "Incrément forcé : ${BUMP}"
fi
# ── Compute next version ──────────────────────────────────────────────────
case "$BUMP" in
major) MAJ=$((MAJ + 1)); MIN=0; PAT=0 ;;
minor) MIN=$((MIN + 1)); PAT=0 ;;
*) PAT=$((PAT + 1)) ;;
esac
NEXT="${MAJ}.${MIN}.${PAT}"
# ── Guard: tag already exists? ────────────────────────────────────────────
if git rev-parse "v${NEXT}" >/dev/null 2>&1; then
echo "✗ Le tag v${NEXT} existe déjà. Corrigez l'incrément ou retirez le tag."
exit 1
fi
if [ "$DRY_RUN" = "1" ]; then
echo "── DRY RUN ─────────────────────────────"
echo " Prochain tag : v${NEXT}"
echo " (aucun tag créé)"
exit 0
fi
# ── Create tag ────────────────────────────────────────────────────────────
git tag -a "v${NEXT}" -m "Release v${NEXT} (${BUMP} bump depuis v${CURRENT})"
echo "✓ Tag créé localement : v${NEXT}"
if [ "$DO_PUSH" = "1" ]; then
git push origin "v${NEXT}"
echo "✓ Tag poussé : v${NEXT}"
else
echo "ℹ Tag local uniquement. Poussez avec: git push origin v${NEXT}"
fi
echo "Done."
+7
View File
@@ -128,6 +128,13 @@ def make_gitea_request(
if content_type:
req.add_header("Content-Type", content_type)
req.add_header("Accept", "application/json")
# Cloudflare sur git.dracodev.net bloque les User-Agents "Python-urllib/*"
# (HTTP 403 Error 1010) pour les écritures ; on présente un UA navigateur.
req.add_header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
)
try:
with urllib.request.urlopen(req, data=data) as resp:
+11 -2
View File
@@ -57,11 +57,20 @@ fi
# ----- Démarrage du serveur (mêmes conditions que le CI e2e) -----
echo "[INFO] Démarrage d'ObsiGate sur $BASE_URL (auth désactivée)..."
mkdir -p data
# Sous MSYS/git-bash, les env vars ne sont PAS converties : un chemin
# '/c/dev/...' est illisible pour le python natif Windows → vaults vides.
if command -v cygpath &>/dev/null; then
VP="$(cygpath -w "$PWD/test_vault")"
DP="$(cygpath -w "$PWD/test_dir")"
else
VP="$PWD/test_vault"
DP="$PWD/test_dir"
fi
OBSIGATE_AUTH_ENABLED=false \
VAULT_1_NAME=TestVault \
VAULT_1_PATH="$(pwd)/test_vault" \
VAULT_1_PATH="$VP" \
DIR_1_NAME=TestDir \
DIR_1_PATH="$(pwd)/test_dir" \
DIR_1_PATH="$DP" \
"$PYTHON" -m uvicorn backend.main:app --host 127.0.0.1 --port "$PORT" \
>"$SERVER_LOG" 2>&1 &
SERVER_PID=$!
+1
View File
@@ -0,0 +1 @@
{"type":"excalidraw","version":2,"elements":[{"id":"rect-1","type":"rectangle","x":100,"y":200,"width":300,"height":150,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#ffec99","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"roundness":{"type":3},"boundElements":null,"updated":1,"link":null,"locked":false},{"id":"text-1","type":"text","x":150,"y":260,"width":200,"height":30,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"roundness":null,"boundElements":null,"updated":1,"link":null,"locked":false,"text":"Hello Excalidraw E2E","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":"rect-1","originalText":"Hello Excalidraw E2E","autoResize":true,"lineHeight":1.25}],"appState":{"viewBackgroundColor":"#ffffff","name":"E2E Diagram"},"files":{}}
+8
View File
@@ -0,0 +1,8 @@
---
excalidraw-plugin: parsed
---
```compressed-json
N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcmTGAWxkbBoQBtUHgQh0ZcNDiIwKMJLnIFiABIdM9AAQBRNFjyES5evmwBzbIywAVeWPWZNu/TgLFJyBAEYADOQhWXwBfUhFcMQAjRWkxJQVyeLEAJQBLmgBXLlStOVQAC0ZsAEcMmC0AMi1GejkBEzNLa0w7ZRT0rJy8wpKyyuramHqQL3gAZn8QQPgAFhCwkFFEVBjYMXwYVDB0RnNOSUx0SI4xACF6ADu5LWwuKFNtvjhyUYBWAIRX4IBdcnQoKAAZW2cgQoGsPDEABEhnRmKEQAAzbCcATwYDBYJAA
```
Ce texte ne doit pas polluer l'index.
+92 -1
View File
@@ -1,6 +1,8 @@
# tests/conftest.py — Shared fixtures for ObsiGate test suite
import os
import sys
import shutil
import json
from pathlib import Path
import pytest
@@ -106,7 +108,96 @@ def app_with_vault(test_vault_dir: str):
return client
@pytest.fixture
@ pytest.fixture
def client(app_with_vault):
"""Alias for app_with_vault."""
return app_with_vault
@ pytest.fixture
def admin_client(tmp_path):
"""TestClient with auth enabled, isolated temp data, admin user provisioned.
Used by tests that need admin authentication (e.g., /api/health/detailed).
"""
data_dir = tmp_path / "data"
data_dir.mkdir()
import json
from backend.auth.password import hash_password
pw_hash = hash_password("chab30")
users = {
"version": 1,
"users": {
"admin": {
"id": "admin-1",
"username": "admin",
"display_name": "admin",
"password_hash": pw_hash,
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
},
"normaluser": {
"id": "user-1",
"username": "normaluser",
"display_name": "normal",
"password_hash": hash_password("normal123"),
"role": "user",
"vaults": ["TestVault"],
"active": True,
"created_at": "2026-01-01T00:00:00",
},
},
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
test_vault_path = os.path.abspath("test-vault")
os.chdir(str(tmp_path))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = test_vault_path
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_ADMIN_USER"] = "admin"
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "chab30"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.main import app
from backend.indexer import build_index, index
import asyncio
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from backend.search import init_inverted_index
init_inverted_index()
from fastapi.testclient import TestClient
client = TestClient(app)
yield client
if hasattr(client, "close"):
client.close()
loop.run_until_complete(asyncio.sleep(0))
os.chdir(orig_cwd)
shutil.rmtree(str(tmp_path), ignore_errors=True)
for k in [
"VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED",
]:
os.environ.pop(k, None)
+147
View File
@@ -0,0 +1,147 @@
/**
* E2E tests for ObsiGate Excalidraw Support (#78).
*
* Run (local):
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/excalidraw.spec.js
* BASE_URL=http://localhost:2021 npx playwright test tests/e2e/excalidraw.spec.js --headed
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
// Wait until app init() resolved (all handlers bound).
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
if (!(await treeItem.isVisible().catch(() => false))) {
const vaultItem = page.locator(`.vault-item[data-vault="${vault}"]`).first();
if (await vaultItem.count() > 0) {
await vaultItem.click();
await page.waitForTimeout(800);
}
}
await treeItem.dblclick({ timeout: 5000 });
await page.waitForTimeout(500);
}
async function waitForExcalidrawIframe(page) {
await page.waitForSelector('.content-area iframe', { timeout: 15000 });
const iframe = page.locator('.content-area iframe').first();
await expect(iframe).toBeVisible({ timeout: 10000 });
// Give React/Excalidraw time to mount (loads React + Excalidraw from esm.sh)
await page.waitForTimeout(2500);
return iframe;
}
test.describe('Excalidraw — .excalidraw file support', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.evaluate(() => localStorage.removeItem('obsigate-panes'));
});
test('opens .excalidraw file — iframe renders Excalidraw canvas', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw');
const iframe = await waitForExcalidrawIframe(page);
await expect(iframe).toHaveAttribute('src', /excalidraw-editor\.html/);
// Excalidraw mounts two <canvas> layers (static + interactive) — target the first.
const canvas = page.locator('.content-area iframe').contentFrame().locator('canvas').first();
await expect(canvas).toBeVisible({ timeout: 20000 });
});
test('opens .excalidraw.md (Obsidian plugin format) — decompresses and renders', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw.md');
const iframe = await waitForExcalidrawIframe(page);
await expect(iframe).toHaveAttribute('src', /excalidraw-editor\.html/);
const canvas = page.locator('.content-area iframe').contentFrame().locator('canvas').first();
await expect(canvas).toBeVisible({ timeout: 20000 });
});
test('theme toggle propagates to iframe without crash', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw');
await waitForExcalidrawIframe(page);
const themeToggle = page.locator('#theme-toggle').first();
if (await themeToggle.isVisible()) {
await themeToggle.click();
await page.waitForTimeout(500);
await themeToggle.click();
}
// No crash = pass; iframe still present
await expect(page.locator('.content-area iframe').first()).toBeVisible();
});
});
test.describe('Excalidraw — Creation from UI', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.evaluate(() => localStorage.removeItem('obsigate-panes'));
});
test('creates new .excalidraw via directory context menu (C8)', async ({ page }) => {
// Expand the vault so a directory node is visible.
const vaultItem = page.locator('.vault-item[data-vault="TestVault"]').first();
await vaultItem.click();
await page.waitForTimeout(800);
// Right-click a directory (context menu offers "Nouveau diagramme Excalidraw").
const dirItem = page.locator('.tree-item[data-vault="TestVault"][data-path="IT"]').first();
await dirItem.click({ button: 'right' });
await page.waitForSelector('#context-menu.active', { timeout: 5000 });
const excalidrawMenu = page.locator('#context-menu .context-menu-item', { hasText: 'Nouveau diagramme Excalidraw' });
await expect(excalidrawMenu).toBeVisible();
await excalidrawMenu.click();
// Modal opens with .excalidraw pre-selected.
await page.waitForSelector('.obsigate-modal-overlay.active', { timeout: 5000 });
const extSelect = page.locator('#file-ext-select');
await expect(extSelect).toHaveValue('.excalidraw');
const unique = `e2e-diagram-${Date.now()}`;
await page.fill('#file-name-input', unique);
await page.click('#file-create-btn');
// New file opens in the Excalidraw editor.
await waitForExcalidrawIframe(page);
});
});
test.describe('Excalidraw — Search integration', () => {
test.beforeEach(async ({ page }) => {
await login(page);
});
test('Excalidraw file appears in search results with text snippet', async ({ page }) => {
const searchInput = page.locator('#search-input').first();
await searchInput.fill('diagram');
await searchInput.press('Enter');
await page.waitForSelector('.search-result-item, .search-results-header', { timeout: 10000 });
const results = page.locator('.search-result-item');
expect(await results.count()).toBeGreaterThan(0);
});
});
+2
View File
@@ -8,6 +8,8 @@ const BASE = process.env.BASE_URL || 'http://localhost:2029';
// ── Helpers ──────────────────────────────────────────────────────────
async function goHome(page) {
await page.goto(BASE);
// Wait until app init() resolved (all handlers bound, incl. search).
await page.waitForFunction(() => (window as any).__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
}
+403
View File
@@ -0,0 +1,403 @@
/**
* E2E tests for ObsiGate Split View (#75) — COMPLETION MATRIX.
*
* split-view.spec.js covers the core flows; this file closes the remaining
* gaps of the open/close usage matrix:
* Ouverture : boutons ⊞→/⊞↓ (C3), menu contextuel (splitDown, moveTo),
* drag-to-split BAS (D3b), palette (C→, ↓, focus next/prev, reset)
* Fermeture : .tab-close (X), dblclick, Ctrl+W single-pane, menu contextuel
* Fermer / Fermer les autres / Fermer à droite / Fermer tout /
* Fermer le panneau, Ctrl+Alt+W via menu
* Edge : plafond 4 panneaux, auto-collapse d'un panneau vidé,
* focus voisin à la fermeture, dashboard si dernier onglet fermé
*
* Run: ./scripts/run-e2e-local.sh -g "Split View Matrix"
*/
import { test, expect } from '@playwright/test';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto('/');
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
await page.waitForSelector('#app:not(.hidden)', { timeout: 10000 });
}
await page.evaluate(() => localStorage.removeItem('obsigate-panes'));
}
async function openFile(page, vault, filePath) {
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
// Expand the vault root if not expanded
if (!(await treeItem.isVisible().catch(() => false))) {
const vaultItem = page.locator(`.vault-item[data-vault="${vault}"]`);
if (await vaultItem.count() > 0) {
await vaultItem.click();
await page.waitForTimeout(700);
}
}
// Expand parent folders one level at a time (lazy tree)
const parts = filePath.split('/');
for (let i = 1; i < parts.length; i++) {
if (await treeItem.isVisible().catch(() => false)) break;
const dirPath = parts.slice(0, i).join('/');
const dir = page.locator(`.tree-item[data-vault="${vault}"][data-path="${dirPath}"]`);
if (await dir.count() > 0) {
await dir.click();
await page.waitForTimeout(600);
}
}
// Re-expand vault if clicking a folder collapsed the tree
if (!(await treeItem.isVisible().catch(() => false))) {
const vaultItem = page.locator(`.vault-item[data-vault="${vault}"]`);
await vaultItem.click().catch(() => {});
await page.waitForTimeout(500);
for (let i = 1; i < parts.length; i++) {
const dirPath = parts.slice(0, i).join('/');
const dir = page.locator(`.tree-item[data-vault="${vault}"][data-path="${dirPath}"]`);
if (await dir.count() > 0) { await dir.click(); await page.waitForTimeout(500); }
}
}
await treeItem.dblclick({ timeout: 5000 });
await page.waitForTimeout(500);
}
// Open a file programmatically in a given pane (robust setup for pane>0)
async function openInPane(page, paneId, vault, path) {
await page.evaluate(([pid, v, f]) => {
const tm = window.PaneManager.tabManagers[pid];
if (!tm) throw new Error('no pane ' + pid);
tm.open(v, f);
}, [paneId, vault, path]);
await page.waitForTimeout(800);
}
async function paneCount(page) {
return page.locator('.pane-container').count();
}
async function splitVia(page) {
await page.keyboard.press('Control+Alt+\\');
await page.waitForSelector('.pane-grid', { timeout: 3000 });
}
test.describe('Split View Matrix — Boutons & menu contextuel', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('⊞→ button in tab bar splits right (C3)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
const splitBtn = page.locator('#tab-bar .tab-split-btn[data-action="split-right"]');
await expect(splitBtn).toBeAttached();
await splitBtn.click({ force: true });
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
});
test('⊞↓ button in tab bar splits down (C3)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
const splitBtn = page.locator('#tab-bar .tab-split-btn[data-action="split-down"]');
await expect(splitBtn).toBeAttached();
await splitBtn.click({ force: true });
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
});
test('context menu → Diviser en bas (C1)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await page.locator('.tab-item').first().click({ button: 'right' });
const menuItem = page.locator('#tab-context-menu .context-menu-item', { hasText: 'Diviser en bas' });
await expect(menuItem).toBeVisible();
await menuItem.click();
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
});
test('context menu → Fermer le panneau returns to single pane', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await splitVia(page);
await page.waitForTimeout(600);
// put a tab into pane 1 so the pane actually holds content
await openInPane(page, 1, 'TestVault', 'Accueil.md');
// right-click pane 1's tab — active pane becomes 1 — use the contextual close
const pane1Tab = page.locator('.pane-container').nth(1).locator('.tab-item').first();
await pane1Tab.click({ button: 'right' });
const closePaneItem = page.locator('#tab-context-menu [data-action="closePane"]');
await expect(closePaneItem).toBeVisible();
await closePaneItem.click();
await page.waitForTimeout(600);
await expect(page.locator('.pane-container')).toHaveCount(0);
// pane 0 tabs survived (single mode)
await expect(page.locator('#tab-list .tab-item').first()).toBeVisible();
});
test('context menu → Déplacer vers... flyout transfers tab (C1b)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await splitVia(page);
await page.waitForTimeout(600);
// pane 1 starts empty after split — put a file there via API
await openInPane(page, 1, 'TestVault', 'note2.md');
const pane1Tabs = page.locator('.pane-container').nth(1).locator('.tab-item');
await expect(pane1Tabs.first()).toBeVisible({ timeout: 5000 });
await pane1Tabs.first().click({ button: 'right' });
const moveTo = page.locator('#tab-context-menu [data-action="moveTo"]');
await expect(moveTo).toBeVisible();
await moveTo.hover();
const flyout = page.locator('.context-submenu .context-menu-item', { hasText: 'Panneau 1' });
await expect(flyout).toBeVisible({ timeout: 3000 });
await flyout.click();
await page.waitForTimeout(600);
// Pane 0 (single) holds both files now; empty source pane auto-collapsed
await expect(page.locator('.pane-container')).toHaveCount(0);
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
});
});
test.describe('Split View Matrix — Palette de commandes', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('palette command ⊞→ splits (G3/H1)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await page.keyboard.press('Control+Shift+Space');
const input = page.locator('.cp-input');
await expect(input).toBeVisible({ timeout: 5000 });
await input.fill('>Diviser à droite');
await page.waitForSelector('.cp-item', { timeout: 3000 });
await page.keyboard.press('Enter');
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
});
test('palette Panneau suivant / Panneau précédent navigate', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await splitVia(page);
await page.waitForTimeout(600);
await page.keyboard.press('Control+Shift+Space');
const input = page.locator('.cp-input');
await expect(input).toBeVisible({ timeout: 5000 });
await input.fill('>Panneau suivant');
await page.waitForSelector('.cp-item', { timeout: 3000 });
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
await expect(page.locator('.pane-container').nth(1)).toHaveClass(/pane-active/);
});
});
test.describe('Split View Matrix — Fermeture', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('X button on tab closes it', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await expect(page.locator('.tab-item')).toHaveCount(2);
await page.locator('.tab-item').first().locator('.tab-close').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
});
test('double-click on tab closes it', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('.tab-item').first().dblclick();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
});
test('Ctrl+W closes active tab (single pane)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await page.keyboard.press('Control+w');
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(0);
await expect(page.locator('#dashboard-home')).toBeVisible();
});
test('context menu Fermer (close single)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="close"]').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
});
test('context menu Fermer les autres keeps one', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('#header-logo').click(); await page.waitForTimeout(300);
await openFile(page, 'TestVault', 'Accueil.md');
await page.waitForTimeout(300);
const tabs = page.locator('.tab-item');
await expect(tabs).toHaveCount(3);
await tabs.nth(1).click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeOthers"]').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
});
test('context menu Fermer à droite', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('#header-logo').click(); await page.waitForTimeout(300);
await openFile(page, 'TestVault', 'Accueil.md');
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(3);
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeRight"]').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
});
test('context menu Fermer tout → dashboard', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeAll"]').click();
await page.waitForTimeout(400);
// closeAll hides the tab bar and shows dashboard (tabs list not re-rendered)
await expect(page.locator('#tab-bar')).toBeHidden();
await expect(page.locator('#dashboard-home')).toBeVisible();
});
test('closing last tab of a non-last pane auto-collapses pane (C4)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await splitVia(page);
await page.waitForTimeout(600);
await openInPane(page, 1, 'TestVault', 'note2.md');
const pane1 = page.locator('.pane-container').nth(1);
await expect(pane1.locator('.tab-item')).toHaveCount(1);
// close pane 1's only tab → pane itself auto-collapses
await pane1.locator('.tab-item').first().locator('.tab-close').click({ force: true });
await page.waitForTimeout(700);
await expect(page.locator('.pane-container')).toHaveCount(0);
// pane 0 file is still open in single mode
await expect(page.locator('#tab-list .tab-item')).toHaveCount(1);
});
test('closing pane 0 of 2 focuses neighbour then collapses correctly', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await splitVia(page);
await page.waitForTimeout(600);
// give pane 1 its own tab so the neighbour survives pane-0 closure
await openInPane(page, 1, 'TestVault', 'Accueil.md');
// activate pane 0 (both migrated tabs live there) then close it —
// documented C4 behaviour: tabs of the closed pane are LOST, neighbour survives
await page.locator('.pane-container').nth(0).click({ position: { x: 50, y: 200 } });
await page.waitForTimeout(200);
await page.keyboard.press('Control+Alt+w');
await page.waitForTimeout(700);
// collapsed back to single-pane grid with only the surviving Accueil tab
await expect(page.locator('.pane-container')).toHaveCount(0);
const tabs = page.locator('#tab-list .tab-item');
await expect(tabs).toHaveCount(1);
await expect(tabs.first()).toContainText('Accueil');
});
});
test.describe('Split View Matrix — Edge cases', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('PANE_MAX: split is capped at 4 panes', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
// grow to 4 through the API (fast, deterministic — the cap lives in splitRight/splitDown)
await page.evaluate(() => {
window.PaneManager.splitRight();
window.PaneManager.splitDown();
window.PaneManager.splitRight();
});
await page.waitForTimeout(800);
await expect(page.locator('.pane-container')).toHaveCount(4);
// 5th attempt must be a no-op
await page.evaluate(() => window.PaneManager.splitRight());
await page.waitForTimeout(400);
await expect(page.locator('.pane-container')).toHaveCount(4);
});
test('split down then Ctrl+Alt+Down navigates vertically', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.keyboard.press('Control+Alt+Shift+\\');
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await page.waitForTimeout(600);
await page.keyboard.press('Control+Alt+ArrowDown');
await page.waitForTimeout(300);
await expect(page.locator('.pane-container').nth(1)).toHaveClass(/pane-active/);
await page.keyboard.press('Control+Alt+ArrowUp');
await page.waitForTimeout(300);
await expect(page.locator('.pane-container').nth(0)).toHaveClass(/pane-active/);
});
test('drag-to-split BOTTOM zone creates vertical split (D3)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
const tabBox = await page.locator('.tab-item').first().boundingBox();
const areaBox = await page.locator('#content-area').boundingBox();
expect(tabBox && areaBox).toBeTruthy();
await page.mouse.move(tabBox.x + tabBox.width / 2, tabBox.y + tabBox.height / 2);
await page.mouse.down();
await page.mouse.move(
areaBox.x + areaBox.width / 2,
areaBox.y + areaBox.height * 0.9,
{ steps: 15 }
);
await page.waitForTimeout(300);
await page.mouse.up();
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
// vertical layout → gridTemplateRows defines 3 rows
const rows = await page.evaluate(() =>
document.querySelector('.pane-grid')?.style.gridTemplateRows || '');
expect(rows).toContain('1fr');
});
test('reopening an open file focuses existing tab — no duplicate (E4)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
// dblclick note1 again
await page.locator('.tree-item[data-path="note1.md"]').dblclick();
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(2);
// it should be the active one
const active = page.locator('.tab-item.active');
await expect(active).toHaveCount(1);
await expect(active).toContainText('note1');
});
test('reset panes from palette fully collapses split (G3)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await splitVia(page);
await page.waitForTimeout(600);
await page.keyboard.press('Control+Shift+Space');
const input = page.locator('.cp-input');
await expect(input).toBeVisible({ timeout: 5000 });
await input.fill('>Réinitialiser les panneaux');
await page.waitForSelector('.cp-item', { timeout: 3000 });
await page.keyboard.press('Enter');
await page.waitForTimeout(600);
await expect(page.locator('.pane-container')).toHaveCount(0);
const saved = await page.evaluate(() => localStorage.getItem('obsigate-panes'));
expect(saved).toBeNull();
});
});
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env node
/**
* ObsiGate - JSDOM integration tests for the AI tooling (#72 / AI UX).
*
* Covers the regressions fixed for the AI editor toolbar and BooksLM:
* - custom-rewrite modal replaces window.prompt()
* - Ctrl/Cmd+J inline-completion shortcut is bound exactly once
* - BooksLM sends the user message (not the empty placeholder) and parses
* the `{token}` SSE frames streamed by the backend
* - BooksLM uses the authenticated request helper
*
* Usage: node tests/frontend/ai.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
// ── JSDOM bootstrap ────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html>
<html>
<body>
<div id="editor-modal" class="active"></div>
<div id="ai-toolbar-container"></div>
</body>
</html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.KeyboardEvent = w.KeyboardEvent;
globalThis.MessageEvent = w.MessageEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
globalThis.MutationObserver = w.MutationObserver;
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
// ── Test harness ───────────────────────────────────────────────────────────
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
try {
await fn();
console.log(` \u2713 ${name}`);
passCount++;
} catch (e) {
console.log(` \u2717 ${name}`);
console.log(` ${e.message}`);
}
}
function freshToolbarContainer() {
const container = document.createElement("div");
container.id = "ai-toolbar-container";
document.body.appendChild(container);
return container;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
// A minimal fetch mock: AI status reports one available provider and
// inline-complete counts how many times it was invoked.
let inlineCompleteCalls = 0;
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/ai/status")) {
return { ok: true, status: 200, json: async () => ({ configured: true, providers: { deepseek: { available: true, model: "deepseek-chat" } } }) };
}
if (u.includes("/api/config/ai-models")) {
return { ok: true, status: 200, json: async () => ({ models: ["deepseek-chat"], source: "fallback" }) };
}
if (u.includes("/api/ai/inline-complete")) {
inlineCompleteCalls++;
return { ok: true, status: 200, json: async () => ({ result: "complété", provider: "deepseek" }) };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const ai = await import(pathToFileURL(path.join(JS_DIR, "ai.js")).href);
const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href);
const { BooksLM } = bookslmMod;
const { collectOpenDocuments, documentsSignature } = bookslmMod;
// ── 1. Rewrite modal resolves with the typed instruction ──
await test("rewrite modal returns the typed instruction on confirm", async () => {
const promise = ai._promptRewriteInstruction();
const overlay = document.querySelector(".ai-modal-overlay");
assert.ok(overlay, "modal overlay should be rendered");
const input = overlay.querySelector(".ai-modal-input");
input.value = "Rends le ton plus formel";
overlay.querySelector(".ai-modal-ok").click();
const result = await promise;
assert.equal(result, "Rends le ton plus formel");
assert.equal(document.querySelector(".ai-modal-overlay"), null, "overlay removed");
});
// ── 2. Rewrite modal cancels on Escape ──
await test("rewrite modal returns null on Escape", async () => {
const promise = ai._promptRewriteInstruction();
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
const result = await promise;
assert.equal(result, null);
});
// ── 3. Ctrl+J inline completion fires exactly once after multiple toolbar builds ──
await test("Ctrl+J shortcut is bound once across toolbar re-creations", async () => {
const view = {
state: {
selection: { main: { from: 0, to: 5 } },
doc: { toString: () => "hello", sliceString: () => "hello" },
},
dispatch: () => {},
};
inlineCompleteCalls = 0;
await ai.createAIToolbar(freshToolbarContainer(), () => view);
await ai.createAIToolbar(freshToolbarContainer(), () => view);
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "j", ctrlKey: true, bubbles: true }));
await sleep(20);
assert.equal(inlineCompleteCalls, 1, `expected 1 inline-complete call, got ${inlineCompleteCalls}`);
});
// ── 4. BooksLM sends the user message and parses {token} SSE frames ──
await test("BooksLM streams assistant tokens and sends the user message", async () => {
const sse =
'event: message\ndata: {"token":"Bonjour le monde","provider":"deepseek","model":"deepseek-chat"}\n\n' +
"event: done\ndata: {}\n\n";
let capturedBody = null;
const encoder = new TextEncoder();
const chunks = [encoder.encode(sse)];
const reader = {
read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }),
};
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/chat")) {
capturedBody = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => reader } };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const panel = document.createElement("div");
panel.innerHTML = `
<div class="bookslm-suggestions"></div>
<div class="bookslm-messages"></div>
<textarea></textarea>
<button class="bookslm-btn-send"></button>`;
document.body.appendChild(panel);
const b = new BooksLM();
b._panel = panel;
b._vault = "TestVault";
b._directory = "notes";
b._contextFiles = [{ path: "notes/a.md" }];
panel.querySelector("textarea").value = "Résume ce dossier";
await b._sendMessage();
const assistant = b._messages[b._messages.length - 1];
assert.equal(assistant.role, "assistant");
assert.equal(assistant.content, "Bonjour le monde", "assistant token should be rendered");
assert.equal(capturedBody.message, "Résume ce dossier", "current user message must be sent");
assert.equal(capturedBody.vault, "TestVault");
assert.deepEqual(capturedBody.conversation_history, [], "history excludes the current message");
panel.remove();
});
// ── 5. Source guards: no window.prompt() and authenticated BooksLM ──
await test("ai.js no longer uses window.prompt()", async () => {
const { readFileSync } = await import("node:fs");
const raw = readFileSync(path.join(JS_DIR, "ai.js"), "utf-8");
const code = raw.replace(/\/\*[\s\S]*?\*\//g, "").split("\n").map((l) => l.replace(/\/\/.*$/, "")).join("\n");
assert.ok(!/\bprompt\s*\(/.test(code), "ai.js should not call prompt()");
assert.ok(code.includes("_promptRewriteInstruction"), "rewrite modal helper present");
});
await test("bookslm.js uses the authenticated API helper", async () => {
const { readFileSync } = await import("node:fs");
const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8");
assert.ok(/import\s*\{[^}]*AuthManager[^}]*\}\s*from\s*['"]\.\/auth\.js['"]/.test(src), "AuthManager imported");
assert.ok(src.includes("data.token"), "SSE token field parsed");
});
// ── 6. Every t() key used by the AI tooling exists in FR and EN ──
await test("AI/BooksLM i18n keys exist in fr.json and en.json", async () => {
const { readFileSync } = await import("node:fs");
const localesDir = path.resolve(JS_DIR, "..", "locales");
const fr = JSON.parse(readFileSync(path.join(localesDir, "fr.json"), "utf-8"));
const en = JSON.parse(readFileSync(path.join(localesDir, "en.json"), "utf-8"));
const keys = new Set();
for (const file of ["ai.js", "bookslm.js"]) {
const src = readFileSync(path.join(JS_DIR, file), "utf-8");
for (const m of src.matchAll(/\bt\(\s*['"]([a-z0-9_.]+)['"]/g)) {
keys.add(m[1]);
}
}
assert.ok(keys.size > 20, "should discover AI translation keys");
const missingFr = [...keys].filter((k) => !(k in fr));
const missingEn = [...keys].filter((k) => !(k in en));
assert.deepEqual(missingFr, [], `missing FR keys: ${missingFr.join(", ")}`);
assert.deepEqual(missingEn, [], `missing EN keys: ${missingEn.join(", ")}`);
});
// ── 7. Context detection: open documents across tabs and panes ──
await test("collectOpenDocuments merges tabs and panes without duplicates", () => {
window.TabManager = {
_activeTabId: "t1",
_tabs: [
{ id: "t1", vault: "V", path: "a.md" },
{ id: "t2", vault: "V", path: "b.md" },
],
};
window.PaneManager = {
tabManagers: {
0: {
_activeTabId: "p1",
_tabs: [
{ id: "p1", vault: "V", path: "b.md" },
{ id: "p2", vault: "W", path: "c.md" },
],
},
},
};
const docs = collectOpenDocuments();
assert.equal(docs.length, 3, "b.md must be de-duplicated");
assert.equal(docs[0].path, "a.md", "active tab first");
delete window.TabManager;
delete window.PaneManager;
});
await test("documentsSignature is stable", () => {
const sig = documentsSignature([{ vault: "V", path: "a.md" }, { vault: "V", path: "b.md" }]);
assert.equal(sig, "V/a.md\u0000V/b.md");
assert.equal(documentsSignature([]), "");
});
await test("openForCurrentContext picks documents when tabs are open", async () => {
window.TabManager = {
_activeTabId: "t1",
_tabs: [
{ id: "t1", vault: "V", path: "notes/a.md" },
{ id: "t2", vault: "V", path: "notes/b.md" },
],
};
let ctxBody = null;
globalThis.fetch = async (url, opts) => {
const u = String(url);
if (u.includes("/api/ai/status")) {
return { ok: true, status: 200, json: async () => ({ configured: true, providers: {} }) };
}
if (u.includes("/api/ai/bookslm/context")) {
ctxBody = JSON.parse(opts.body);
return {
ok: true,
status: 200,
json: async () => ({ files: [{ path: "notes/a.md" }], total_chars: 10, file_count: 1, scope: "documents", max_total_chars: 200000 }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
await b.openForCurrentContext();
assert.equal(b._mode, "documents");
assert.equal(ctxBody.mode, "documents");
assert.deepEqual(ctxBody.context_files, ["notes/a.md", "notes/b.md"]);
await sleep(10);
assert.ok(b._panel.classList.contains("open"));
b.close();
delete window.TabManager;
});
await test("openForCurrentContext falls back to general with no document", async () => {
window.TabManager = { _activeTabId: null, _tabs: [] };
const b = new BooksLM();
await b.openForCurrentContext();
assert.equal(b._mode, "general");
await sleep(10);
assert.ok(b._panel.classList.contains("open"));
assert.ok(!b._panel.classList.contains("hidden"), "explicit open must reveal the panel");
b.close();
delete window.TabManager;
});
// ── 8. Keyboard behaviour: Enter sends, Ctrl+Enter adds a line ──
await test("Enter submits and Ctrl+Enter inserts a newline", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._isOpen = true;
let sent = 0;
b._sendMessage = () => { sent++; };
const textarea = b._panel.querySelector("textarea");
textarea.value = "hello";
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
assert.equal(sent, 1, "Enter should submit");
textarea.value = "line1";
textarea.selectionStart = textarea.selectionEnd = 5;
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }));
assert.equal(sent, 1, "Ctrl+Enter must not submit");
assert.ok(textarea.value.includes("\n"), "Ctrl+Enter should insert a newline");
b._panel.remove();
});
// ── 9. General assistant action blocks ──
await test("_extractActions parses obsigate-action blocks", () => {
const b = new BooksLM();
const raw = 'Je crée le fichier.\n```obsigate-action\n{"action":"create_file","vault":"V","path":"a.md","content":"# A"}\n```';
const { text, actions } = b._extractActions(raw);
assert.equal(actions.length, 1);
assert.equal(actions[0].action, "create_file");
assert.equal(actions[0].path, "a.md");
assert.ok(!text.includes("obsigate-action"), "action block removed from display text");
});
await test("_extractActions ignores malformed blocks", () => {
const b = new BooksLM();
const { text, actions } = b._extractActions("```obsigate-action\nnot json\n```");
assert.equal(actions.length, 0);
assert.ok(text.length >= 0);
});
// ── 10. Header ergonomics: actions always visible, picker on its own row ──
await test("assistant header exposes context, actions and a picker row", () => {
const b = new BooksLM();
const panel = b._render();
const actions = panel.querySelector(".bookslm-header-actions");
assert.ok(actions, "header actions container");
assert.ok(actions.querySelector(".bookslm-btn-new"));
assert.ok(actions.querySelector(".bookslm-btn-export"));
assert.ok(actions.querySelector(".bookslm-btn-fullscreen"));
assert.ok(actions.querySelector(".bookslm-btn-close"));
assert.ok(panel.querySelector(".bookslm-toolbar .bookslm-picker-host"), "picker on its own toolbar row");
assert.ok(panel.querySelector(".bookslm-input-hint"), "keyboard hint shown");
panel.remove();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
process.exit(1);
}}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* ObsiGate — Desktop bridge (desktop.js) unit tests.
*
* Tests the pure logic of the Tauri bridge: env detection, invoke degradation,
* OS theme sync gating, and the first-run wizard decision. No real Tauri needed.
*
* Usage: node tests/frontend/desktop.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html><html><body></body></html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.localStorage = w.localStorage;
const moduleUrl = pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "desktop.js")).href;
const desktop = await import(moduleUrl);
let passed = 0;
function ok(name, cond) {
assert.ok(cond, name);
passed++;
console.log(" ✓ " + name);
}
// ── Detection ──────────────────────────────────────────────────────────────
console.log("detection");
ok("isTauriEnv false when no __TAURI__", desktop.isTauriEnv() === false);
window.__TAURI__ = { core: { invoke: () => {} } };
ok("isTauriEnv true when __TAURI__ present", desktop.isTauriEnv() === true);
// ── invoke degradation ──────────────────────────────────────────────────────
console.log("invoke");
{
// Web env: invoke resolves undefined, never throws
delete window.__TAURI__;
const r = await desktop.invoke("get_version");
ok("invoke resolves undefined on web", r === undefined);
}
{
// Tauri env with working core.invoke
window.__TAURI__ = { core: { invoke: async (cmd) => "called:" + cmd } };
const r = await desktop.invoke("get_version");
ok("invoke forwards to core.invoke", r === "called:get_version");
}
{
// Tauri env but invoke throws → resolves undefined, no crash
window.__TAURI__ = { core: { invoke: async () => { throw new Error("boom"); } } };
const r = await desktop.invoke("get_version");
ok("invoke swallows IPC errors", r === undefined);
}
// ── getSystemTheme ──────────────────────────────────────────────────────────
console.log("getSystemTheme");
{
window.__TAURI__ = { core: { invoke: async () => "dark" } };
ok("getSystemTheme returns Tauri theme", (await desktop.getSystemTheme()) === "dark");
window.__TAURI__ = { core: { invoke: async () => undefined } };
const t = await desktop.getSystemTheme();
ok("getSystemTheme falls back to light/dark", t === "light" || t === "dark");
}
// ── hasExplicitThemeMode / syncSystemTheme ──────────────────────────────────
console.log("theme gating");
localStorage.clear();
ok("no explicit mode initially", desktop.hasExplicitThemeMode() === false);
// Web env: syncSystemTheme is a no-op (does not touch localStorage)
delete window.__TAURI__;
await desktop.syncSystemTheme();
ok("syncSystemTheme no-op on web", localStorage.getItem("obsigate-theme-mode") === null);
// Tauri env + no explicit mode → follows OS theme
window.__TAURI__ = { core: { invoke: async () => "dark" } };
await desktop.syncSystemTheme();
ok("syncSystemTheme sets OS mode on desktop first-run", localStorage.getItem("obsigate-theme-mode") === "dark");
// Tauri env + explicit mode → never overrides
localStorage.setItem("obsigate-theme-mode", "light");
window.__TAURI__ = { core: { invoke: async () => "dark" } };
await desktop.syncSystemTheme();
ok("syncSystemTheme respects explicit mode", localStorage.getItem("obsigate-theme-mode") === "light");
// High-contrast / sepia are never overridden even with no explicit flag check
localStorage.removeItem("obsigate-theme-mode");
localStorage.setItem("obsigate-theme-mode", "high-contrast");
await desktop.syncSystemTheme();
ok("syncSystemTheme never overrides high-contrast", localStorage.getItem("obsigate-theme-mode") === "high-contrast");
// ── Wizard decision ─────────────────────────────────────────────────────────
console.log("wizard");
localStorage.clear();
window.__TAURI__ = { core: { invoke: async () => "dark" } };
ok("shouldShowWizard: desktop, not dismissed, no vault → true",
desktop.shouldShowWizard({ isDesktop: true, dismissed: false, hasVaultPath: false }) === true);
ok("shouldShowWizard: dismissed → false",
desktop.shouldShowWizard({ isDesktop: true, dismissed: true, hasVaultPath: false }) === false);
ok("shouldShowWizard: has vault path → false",
desktop.shouldShowWizard({ isDesktop: true, dismissed: false, hasVaultPath: true }) === false);
ok("shouldShowWizard: web → false",
desktop.shouldShowWizard({ isDesktop: false, dismissed: false, hasVaultPath: false }) === false);
// dismissWizard persists the flag
desktop.dismissWizard();
ok("dismissWizard persists flag", localStorage.getItem(desktop.getWizardDismissedKey()) === "1");
ok("shouldShowWizard reflects dismissed flag via localStorage",
desktop.shouldShowWizard({ isDesktop: true, hasVaultPath: false }) === false);
// ── crash banner ────────────────────────────────────────────────────────────
console.log("crash banner");
{
delete window.__TAURI__;
desktop.defineBackendCrashBanner();
ok("defines window.showBackendCrashBanner", typeof window.showBackendCrashBanner === "function");
window.showBackendCrashBanner();
ok("crash banner injects DOM node", document.getElementById("backend-crash-banner") !== null);
window.showBackendCrashBanner(); // idempotent — no duplicate
ok("crash banner idempotent", document.querySelectorAll("#backend-crash-banner").length === 1);
}
console.log("\n✅ " + passed + " tests passés");
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM integration tests for Excalidraw Viewer (ROADMAP #78 F2).
*
* Loads the actual excalidraw-viewer.js ES module in a JSDOM environment and
* verifies iframe creation, the postMessage init flow, and export helpers.
*
* Usage: node tests/frontend/excalidraw-viewer.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html>
<html>
<body>
<div id="content-area"></div>
</body>
</html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.MessageEvent = w.MessageEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
// The viewer calls addEventListener on the iframe contentWindow's source check.
// We stub window.postMessage (jsdom doesn't dispatch it).
w.HTMLIFrameElement.prototype.postMessage = () => {};
// jsdom may not initialize iframe.sandbox for created elements; we define it
// lazily so the viewer's sandbox.add() calls work.
Object.defineProperty(w.HTMLIFrameElement.prototype, "sandbox", {
configurable: true,
get() {
if (!this._sandboxList) {
this._sandboxList = new Set();
const list = this._sandboxList;
this._sandboxListAdd = (token) => {
list.add(token);
};
}
return {
add: (token) => this._sandboxListAdd(token),
contains: (token) => this._sandboxList.has(token),
};
},
});
// ── Helpers ─────────────────────────────────────────────────────────────────
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
try {
await fn();
console.log(` ✓ ${name}`);
passCount++;
} catch (e) {
console.log(` ✗ ${name}`);
console.log(` ${e.message}`);
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
}
}
// ── Dynamic import (after globals are set) ─────────────────────────────────
const mod = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "excalidraw-viewer.js")).href
);
const { renderExcalidraw, notifyExcalidrawThemeChange, destroyExcalidrawEditor } = mod;
// ── Test suite ──────────────────────────────────────────────────────────────
console.log("\n── excalidraw-viewer.js JSDOM integration tests (#78 F2) ──\n");
await test("module exports renderExcalidraw + helpers", () => {
assert.equal(typeof renderExcalidraw, "function");
assert.equal(typeof notifyExcalidrawThemeChange, "function");
assert.equal(typeof destroyExcalidrawEditor, "function");
});
await test("renderExcalidraw creates an iframe with sandbox + static src", () => {
const container = document.getElementById("content-area");
const data = {
is_excalidraw: true,
vault: "TestVault",
path: "diagram.excalidraw",
excalidraw_data: { elements: [], appState: {}, files: {} },
};
renderExcalidraw(container, data, "TestVault", "diagram.excalidraw");
const iframe = container.querySelector("iframe");
assert.ok(iframe, "iframe should be created");
assert.ok(iframe.src.includes("/static/excalidraw-editor.html"), `src: ${iframe.src}`);
assert.ok(iframe.sandbox.contains("allow-scripts"), "sandbox allow-scripts");
assert.ok(iframe.sandbox.contains("allow-same-origin"), "sandbox allow-same-origin");
assert.match(iframe.style.cssText, /100%/);
});
await test("renderExcalidraw accepts compressed .excalidraw.md data", () => {
const container = document.getElementById("content-area");
const data = {
is_excalidraw: true,
vault: "V",
path: "diagram.excalidraw.md",
excalidraw_data_compressed: "dummy",
};
// Should not throw when building with compressed payload
renderExcalidraw(container, data, "V", "diagram.excalidraw.md");
const iframe = container.querySelector("iframe");
assert.ok(iframe, "iframe created for .excalidraw.md");
});
await test("notifyExcalidrawThemeChange does not throw with no editors", () => {
notifyExcalidrawThemeChange("dark");
});
await test("destroyExcalidrawEditor is idempotent (no-op for unknown id)", () => {
destroyExcalidrawEditor("excalidraw-999");
});
console.log(`\n${passCount}/${testCount} excalidraw-viewer tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+555
View File
@@ -0,0 +1,555 @@
{
"name": "frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"jsdom": "^30.0.1"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
"integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.3.0",
"@csstools/css-color-parser": "^4.1.10",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz",
"integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz",
"integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.3.0",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.2",
"undici": "^8.9.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.4.11",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz",
"integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.11"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.11",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz",
"integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/undici": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"jsdom": "^30.0.1"
}
}
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM integration tests for PaneManager (ROADMAP #75 I2).
*
* Loads the actual pane-manager.js ES module in a JSDOM environment and
* verifies DOM-level behavior: tab creation via open(), activation,
* removal via close(), pane isolation, drag/drop smoke.
*
* Usage: node tests/frontend/pane-manager.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html>
<html>
<body>
<div id="content-wrapper">
<div id="dashboard-home"></div>
<div id="tab-bar" class="tab-bar">
<div id="tab-list" class="tab-list"></div>
</div>
<div id="content-area"></div>
</div>
</body>
</html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.DragEvent = w.DragEvent || w.Event;
globalThis.MouseEvent = w.MouseEvent || w.Event;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
// ── Helpers ─────────────────────────────────────────────────────────────────
function $$(sel) {
return Array.from(document.querySelectorAll(sel));
}
function resetBody(html) {
document.body.innerHTML = html;
}
// ── Dynamic import (after globals are set) ──────────────────────────────────
const pm = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "pane-manager.js")).href
);
const { createPaneTabManager, PaneManager, getActiveTabManager, getActiveContentArea } = pm;
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
try {
await fn();
console.log(` ✓ ${name}`);
passCount++;
} catch (e) {
console.log(` ✗ ${name}`);
console.log(` ${e.message}`);
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
}
}
// ── Test suite ──────────────────────────────────────────────────────────────
console.log("\n── pane-manager.js JSDOM integration tests (#75 I2) ──\n");
await test("createPaneTabManager returns expected shape", () => {
resetBody(`
<div id="pane-t1"><div class="tab-bar"></div><div id="tl1" class="tab-list"></div><div class="content-area"></div></div>
`);
const tm = createPaneTabManager("t1");
assert.equal(tm.paneId, "t1");
assert.equal(typeof tm.open, "function");
assert.equal(typeof tm.activate, "function");
assert.equal(typeof tm.close, "function");
assert.equal(typeof tm.init, "function");
});
await test("init() binds tab bar / list / content area to provided DOM", () => {
resetBody(`
<div id="pane-t2">
<div class="tab-bar"></div>
<div id="tb2" class="tab-list"></div>
<div id="ca2" class="content-area"></div>
</div>
`);
const tm = createPaneTabManager("t2");
const bar = document.querySelector("#pane-t2 .tab-bar");
const list = document.querySelector("#tb2");
const area = document.querySelector("#ca2");
tm.init(bar, list, area);
assert.equal(tm.getTabBar(), bar);
assert.equal(tm.getTabList(), list);
assert.equal(tm.getContentArea(), area);
});
await test("open() appends a tab-item to the tab-list", async () => {
resetBody(`
<div id="pane-t3">
<div class="tab-bar"></div>
<div id="tl3" class="tab-list"></div>
<div class="content-area"></div>
</div>
`);
const tm = createPaneTabManager("t3");
tm.init(
document.querySelector("#pane-t3 .tab-bar"),
document.querySelector("#tl3"),
document.querySelector("#pane-t3 .content-area")
);
await tm.open("vault1", "/notes/foo.md");
const items = $$("#tl3 .tab-item");
assert.equal(items.length, 1, `expected 1 tab-item, got ${items.length}`);
assert.match(items[0].textContent, /foo/);
});
await test("activate() switches .active class between tabs", async () => {
resetBody(`
<div id="pane-t4">
<div class="tab-bar"></div>
<div id="tl4" class="tab-list"></div>
<div class="content-area"></div>
</div>
`);
const tm = createPaneTabManager("t4");
tm.init(
document.querySelector("#pane-t4 .tab-bar"),
document.querySelector("#tl4"),
document.querySelector("#pane-t4 .content-area")
);
await tm.open("vault1", "/notes/foo.md");
await tm.open("vault1", "/notes/bar.md");
// PaneTabManager.activate(tabId) is called inside open(), so the most
// recently opened tab is active by default.
let [t1, t2] = $$("#tl4 .tab-item");
assert.ok(!t1.classList.contains("active"), `t1 should NOT be active (it was opened first): ${t1.className}`);
assert.ok(t2.classList.contains("active"), `t2 should be active (most recent): ${t2.className}`);
// Now explicitly activate the first tab.
tm.activate("vault1::/notes/foo.md");
tm._renderTabs();
[t1, t2] = $$("#tl4 .tab-item");
assert.ok(t1.classList.contains("active"), "after explicit activate, t1 should be active");
assert.ok(!t2.classList.contains("active"), "t2 should no longer be active");
});
await test("close() removes the tab-item from DOM", async () => {
resetBody(`
<div id="pane-t5">
<div class="tab-bar"></div>
<div id="tl5" class="tab-list"></div>
<div class="content-area"></div>
</div>
`);
const tm = createPaneTabManager("t5");
tm.init(
document.querySelector("#pane-t5 .tab-bar"),
document.querySelector("#tl5"),
document.querySelector("#pane-t5 .content-area")
);
await tm.open("vault1", "/notes/foo.md");
await tm.open("vault1", "/notes/bar.md");
assert.equal($$("#tl5 .tab-item").length, 2);
await tm.close("vault1::/notes/foo.md");
assert.equal($$("#tl5 .tab-item").length, 1);
assert.match($$("#tl5 .tab-item")[0].textContent, /bar/);
});
await test("isolation: tabs in pane-A do not appear in pane-B", async () => {
resetBody(`
<div id="pane-A"><div class="tab-bar"></div><div id="tlA" class="tab-list"></div><div class="content-area"></div></div>
<div id="pane-B"><div class="tab-bar"></div><div id="tlB" class="tab-list"></div><div class="content-area"></div></div>
`);
const tmA = createPaneTabManager("A");
tmA.init(
document.querySelector("#pane-A .tab-bar"),
document.querySelector("#tlA"),
document.querySelector("#pane-A .content-area")
);
const tmB = createPaneTabManager("B");
tmB.init(
document.querySelector("#pane-B .tab-bar"),
document.querySelector("#tlB"),
document.querySelector("#pane-B .content-area")
);
await tmA.open("v1", "/only-in-a.md");
assert.equal($$("#tlA .tab-item").length, 1);
assert.equal($$("#tlB .tab-item").length, 0, "pane-B must be empty");
await tmB.open("v1", "/only-in-b.md");
assert.equal($$("#tlA .tab-item").length, 1, "pane-A unchanged");
assert.equal($$("#tlB .tab-item").length, 1);
});
await test("PaneManager singleton exposes expected methods", () => {
assert.equal(typeof PaneManager, "object");
assert.equal(PaneManager.isSplit(), false);
assert.deepEqual(PaneManager.panes, []);
assert.equal(typeof PaneManager.splitRight, "function");
assert.equal(typeof PaneManager.splitDown, "function");
assert.equal(typeof PaneManager.closePane, "function");
});
await test("DragEvent dispatch on tab-item does not crash", async () => {
resetBody(`
<div id="pane-d">
<div class="tab-bar"></div>
<div id="tld" class="tab-list"></div>
<div class="content-area"></div>
</div>
`);
const tm = createPaneTabManager("d");
tm.init(
document.querySelector("#pane-d .tab-bar"),
document.querySelector("#tld"),
document.querySelector("#pane-d .content-area")
);
await tm.open("v1", "/drag.md");
const tab = document.querySelector("#tld .tab-item");
assert.ok(tab, "tab should exist");
const dt = {
setData: () => {},
getData: () => "",
types: ["text/plain"],
};
const ds = new Event("dragstart", { bubbles: true, cancelable: true });
Object.defineProperty(ds, "dataTransfer", { value: dt });
tab.dispatchEvent(ds); // should not throw
const de = new Event("dragend", { bubbles: true });
Object.defineProperty(de, "dataTransfer", { value: dt });
tab.dispatchEvent(de); // should not throw
});
await test("getActiveTabManager / getActiveContentArea helpers exist", () => {
assert.equal(typeof getActiveTabManager, "function");
assert.equal(typeof getActiveContentArea, "function");
});
// ── Summary ─────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} pane-manager tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM integration tests for Plugin Manager (ROADMAP #61).
*
* Tests the plugin sandbox worker communication, plugin lifecycle UI,
* and security model (CSP, sandbox iframe).
*
* Usage: node tests/frontend/plugins.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html>
<html>
<body>
<div id="plugins-list"></div>
<div id="plugin-viewer" class="hidden">
<div id="plugin-viewer-title"></div>
<pre id="plugin-viewer-code"></pre>
<pre id="plugin-viewer-manifest"></pre>
</div>
</body>
</html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.MessageEvent = w.MessageEvent;
globalThis.Worker = class MockWorker {
constructor(url) {
this.url = url;
this.onmessage = null;
this.onerror = null;
this._handlers = {};
}
postMessage(data) {
this._lastMessage = data;
}
addEventListener(evt, fn) {
this._handlers[evt] = fn;
}
terminate() {}
};
globalThis.URL = w.URL;
globalThis.Blob = w.Blob;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
// Stub API and toast modules (imported by plugins.js)
globalThis.__plugins_api_stubs = {};
globalThis.__toast_calls = [];
// ── Minimal in-memory test implementations ───────────────────────────────────
// Instead of importing plugins.js directly (ES module + DOM dependencies),
// we replicate core logic and test it.
let passed = 0;
let failed = 0;
const failures = [];
function test(name, fn) {
try {
fn();
passed++;
console.log(` ✅ ${name}`);
} catch (e) {
failed++;
failures.push({ name, error: e.message });
console.log(` ❌ ${name}: ${e.message}`);
}
}
// ── Sandbox Worker Security ──────────────────────────────────────────────────
console.log("\n🔒 C3 — Sandbox Worker Security\n");
test("Worker created with blob URL (no filesystem access)", () => {
const fakeCode = "self.onmessage = function(e) { self.postMessage({type:'pong'}); }";
// JSDOM doesn't implement createObjectURL; stub it to prove we create a blob: URL
const origCreate = w.URL.createObjectURL;
w.URL.createObjectURL = (blob) => `blob:mock-${Math.random().toString(36).slice(2)}`;
try {
const blob = new w.Blob([fakeCode], { type: "application/javascript" });
const url = w.URL.createObjectURL(blob);
assert.ok(url.startsWith("blob:"), "Worker URL should be a blob URL");
const worker = new Worker(url);
assert.ok(worker, "Worker should be constructible");
} finally {
w.URL.createObjectURL = origCreate;
}
});
test("Worker message protocol: install request contains manifest + code", () => {
const worker = new Worker("blob:test");
const msg = {
type: "install",
manifest: { name: "test", version: "1.0.0" },
code: "export default {}",
};
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker message protocol: enable request", () => {
const worker = new Worker("blob:test");
const msg = { type: "enable", plugin: "test" };
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker message protocol: disable request", () => {
const worker = new Worker("blob:test");
const msg = { type: "disable", plugin: "test" };
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker message protocol: uninstall request", () => {
const worker = new Worker("blob:test");
const msg = { type: "uninstall", plugin: "test" };
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker message protocol: hook execution", () => {
const worker = new Worker("blob:test");
const msg = {
type: "execute",
hook: "onFileRender",
data: { content: "# Hello", path: "test.md" },
};
worker.postMessage(msg);
assert.deepStrictEqual(worker._lastMessage, msg);
});
test("Worker terminate prevents further messages", () => {
const worker = new Worker("blob:test");
worker.postMessage({ type: "test" });
worker.terminate();
// After terminate, onmessage should not fire
worker.onmessage = () => {
throw new Error("Should not fire after terminate");
};
});
// ── Plugin Manifest Validation (frontend mirror) ────────────────────────────
console.log("\n📋 B5 — Manifest Validation (frontend)\n");
function validateManifest(data) {
const required = ["name", "version", "description", "author", "main"];
for (const f of required) {
if (!data[f]) throw new Error(`Missing required field: ${f}`);
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(data.name) && data.name.length > 1) {
throw new Error("Plugin name must be lowercase alphanumeric with hyphens");
}
if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/.test(data.version)) {
throw new Error("Version must be semantic version (e.g., '1.0.0')");
}
return true;
}
test("Valid manifest passes frontend validation", () => {
assert.ok(validateManifest({
name: "my-plugin", version: "1.0.0",
description: "d", author: "a", main: "index.js",
}));
});
test("Missing name fails", () => {
assert.throws(() => validateManifest({
version: "1.0.0", description: "d", author: "a", main: "index.js",
}), /Missing required field: name/);
});
test("Uppercase name fails", () => {
assert.throws(() => validateManifest({
name: "MyPlugin", version: "1.0.0",
description: "d", author: "a", main: "index.js",
}), /lowercase alphanumeric/);
});
test("Bad version fails", () => {
assert.throws(() => validateManifest({
name: "ok", version: "not-a-version",
description: "d", author: "a", main: "index.js",
}), /semantic version/);
});
test("Valid single-char name passes", () => {
assert.ok(validateManifest({
name: "x", version: "0.0.1",
description: "d", author: "a", main: "x.js",
}));
});
test("Version with pre-release tag passes", () => {
assert.ok(validateManifest({
name: "ok", version: "2.0.0-beta.1",
description: "d", author: "a", main: "index.js",
}));
});
// ── Plugin Manager State Logic ───────────────────────────────────────────────
console.log("\n🧩 B6 — Plugin Manager Lifecycle\n");
function createPluginManager() {
const plugins = new Map();
const disabled = new Set();
return {
install(manifest, code) {
if (plugins.has(manifest.name)) throw new Error("Already installed");
plugins.set(manifest.name, { manifest, code, enabled: true });
disabled.delete(manifest.name);
return { name: manifest.name, version: manifest.version, enabled: true };
},
uninstall(name) {
if (!plugins.has(name)) throw new Error("Not found");
plugins.delete(name);
disabled.delete(name);
},
enable(name) {
if (!plugins.has(name)) throw new Error("Not found");
disabled.delete(name);
plugins.get(name).enabled = true;
},
disable(name) {
if (!plugins.has(name)) throw new Error("Not found");
disabled.add(name);
plugins.get(name).enabled = false;
},
list() {
return Array.from(plugins.entries()).map(([_, p]) => ({
name: p.manifest.name,
version: p.manifest.version,
enabled: !disabled.has(p.manifest.name),
}));
},
isDisabled(name) {
return disabled.has(name);
},
};
}
test("Install plugin", () => {
const mgr = createPluginManager();
const r = mgr.install({ name: "a", version: "1.0.0", description: "d", author: "a", main: "x.js" }, "code");
assert.equal(r.enabled, true);
assert.equal(mgr.list().length, 1);
});
test("Duplicate install rejected", () => {
const mgr = createPluginManager();
mgr.install({ name: "a", version: "1.0.0", description: "d", author: "a", main: "x.js" }, "code");
assert.throws(() => mgr.install({ name: "a", version: "2.0.0", description: "d", author: "a", main: "x.js" }, "code2"), /Already installed/);
});
test("Uninstall plugin", () => {
const mgr = createPluginManager();
mgr.install({ name: "a", version: "1.0.0", description: "d", author: "a", main: "x.js" }, "code");
mgr.uninstall("a");
assert.equal(mgr.list().length, 0);
});
test("Uninstall nonexistent rejected", () => {
const mgr = createPluginManager();
assert.throws(() => mgr.uninstall("nope"), /Not found/);
});
test("Enable/Disable toggle", () => {
const mgr = createPluginManager();
mgr.install({ name: "a", version: "1.0.0", description: "d", author: "a", main: "x.js" }, "code");
mgr.disable("a");
assert.ok(mgr.isDisabled("a"));
assert.equal(mgr.list()[0].enabled, false);
mgr.enable("a");
assert.ok(!mgr.isDisabled("a"));
assert.equal(mgr.list()[0].enabled, true);
});
// ── C3 — Sandbox Isolation ──────────────────────────────────────────────────
console.log("\n🔒 C3 — Sandbox Isolation\n");
test("Plugin code cannot access DOM directly (no document reference)", () => {
// In the sandbox worker, document/window are undefined
// We simulate by running code in a scope without DOM
const fakeScope = { self: {}, postMessage: () => {} };
delete fakeScope.document;
delete fakeScope.window;
const code = "try { document.getElementById('x'); } catch(e) { self.postMessage({type:'error', message: e.message}); }";
// This should throw ReferenceError in strict isolation
try {
const fn = new Function("self", "document", "window", code);
fn(fakeScope, undefined, undefined);
} catch (e) {
assert.ok(e instanceof ReferenceError, "Should throw ReferenceError for document access");
}
});
test("Plugin worker only receives structured-clone-safe messages", () => {
const msg = {
type: "execute",
hook: "onFileRender",
data: { content: "# Test", path: "test.md" },
};
// structuredClone should work
const clone = structuredClone(msg);
assert.deepStrictEqual(clone, msg);
});
test("Worker cannot use importScripts (CSP)", () => {
// Blob workers in modern browsers enforce CSP — importScripts is not available
// We verify the mock worker doesn't expose it
const worker = new Worker("blob:test");
assert.equal(typeof worker.importScripts, "undefined",
"Worker should not expose importScripts");
});
// ── Summary ──────────────────────────────────────────────────────────────────
console.log(`\n${"═".repeat(60)}`);
console.log(` Results: ${passed} passed, ${failed} failed`);
console.log(`${"═".repeat(60)}`);
if (failures.length > 0) {
console.log("\nFailures:");
failures.forEach(f => console.log(` ❌ ${f.name}: ${f.error}`));
process.exit(1);
}
process.exit(0);
+89 -4
View File
@@ -28,7 +28,7 @@ function testEscapeHtml() {
// ── Test state object structure ────────────────────────────────────────────
// Replicate state.js structure for testing
const expectedStateKeys = [
'APP_VERSION', 'currentVault', 'currentPath', 'allVaults', 'selectedContextVault',
'currentVault', 'currentPath', 'allVaults', 'selectedContextVault',
'searchTimeout', 'searchAbortController', 'advancedSearchOffset', 'advancedSearchTotal',
'advancedSearchSort', 'advancedSearchLastQuery', 'suggestAbortController',
'dropdownActiveIndex', 'dropdownItems', 'currentSearchId', 'selectedTags',
@@ -43,7 +43,7 @@ const expectedStateKeys = [
function testStateKeys() {
const state = {
APP_VERSION: "1.5.0", currentVault: null, currentPath: null, allVaults: [],
currentVault: null, currentPath: null, allVaults: [],
selectedContextVault: "all", searchTimeout: null, searchAbortController: null,
advancedSearchOffset: 0, advancedSearchTotal: 0, advancedSearchSort: "relevance",
advancedSearchLastQuery: "", suggestAbortController: null, dropdownActiveIndex: -1,
@@ -108,8 +108,9 @@ function testModulesHaveImports() {
{ file: 'offline-db.js', reason: 'standalone — IndexedDB' },
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
{ file: 'themes.js', reason: 'standalone — no deps' },
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
];
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
];
const expectNoImport = new Set(expected.map(e => e.file));
for (const file of files) {
@@ -127,6 +128,85 @@ function testModulesHaveImports() {
console.log(' ✓ All modules have imports and exports (except state.js)');
}
// ── Test admin.js exports (ROADMAP #71) ─────────────────────────────────
// Static analysis of frontend/js/admin.js — confirms the ES module shape
// without depending on a DOM environment.
const ADMIN_PATH = join(JS_DIR, 'admin.js');
const ADMIN_REQUIRED_EXPORTS = [
'init',
'connectSSE',
'loadStatsOnce',
'loadAuditLogs',
'loadBackupStats',
'renderStatsWidget',
'renderAuditTable',
'renderBackups',
'getAuthHeaders',
'formatBytes',
'formatUptime',
'severityColor',
];
function _collectExportedNames(content) {
const names = new Set();
// Multi-line export { ... } blocks
for (const m of content.matchAll(/^export\s*\{([\s\S]*?)\}/gm)) {
for (const part of m[1].split(',')) {
const n = part.trim().split(/\s+as\s+/)[0].trim();
if (n) names.add(n);
}
}
// export function/const/let/class/async function NAME
for (const m of content.matchAll(/^export\s+(?:async\s+)?(?:function|const|let|class)\s+(\w+)/gm)) {
names.add(m[1]);
}
return names;
}
function testAdminModuleExists() {
const content = readFileSync(ADMIN_PATH, 'utf-8');
assert.ok(content.length > 0, 'admin.js must be a non-empty file');
// Sanity: it must import from auth.js, state.js, utils.js, i18n.js
assert.ok(/from\s+['"]\.\/auth\.js['"]/.test(content), 'admin.js must import from ./auth.js');
assert.ok(/from\s+['"]\.\/state\.js['"]/.test(content), 'admin.js must import from ./state.js');
assert.ok(/from\s+['"]\.\/utils\.js['"]/.test(content), 'admin.js must import from ./utils.js');
assert.ok(/from\s+['"]\.\/i18n\.js['"]/.test(content), 'admin.js must import from ./i18n.js');
console.log(' ✓ admin.js exists and imports the expected modules');
}
function testAdminModuleExports() {
const content = readFileSync(ADMIN_PATH, 'utf-8');
const exported = _collectExportedNames(content);
const missing = ADMIN_REQUIRED_EXPORTS.filter((name) => !exported.has(name));
assert.strictEqual(missing.length, 0, `admin.js missing exports: ${missing.join(', ')}`);
console.log(` ✓ admin.js exports all ${ADMIN_REQUIRED_EXPORTS.length} expected names`);
}
function testAdminModuleSyntax() {
// node --check verifies parseability without executing side effects.
execSync(`node --check "${ADMIN_PATH}"`, { stdio: 'pipe' });
console.log(' ✓ admin.js parses without syntax errors');
}
// ── Test ai-fab.js (FAB — Floating Action Button) ─────────────────────────
const AIFAB_PATH = join(JS_DIR, 'ai-fab.js');
function testAIFabModuleExists() {
const content = readFileSync(AIFAB_PATH, 'utf-8');
assert.ok(content.length > 0, 'ai-fab.js must be a non-empty file');
assert.ok(/from\s+['"]\.\/bookslm\.js['"]/.test(content), 'ai-fab.js must import from ./bookslm.js');
assert.ok(/from\s+['"]\.\/utils\.js['"]/.test(content), 'ai-fab.js must import from ./utils.js');
assert.ok(/from\s+['"]\.\/i18n\.js['"]/.test(content), 'ai-fab.js must import from ./i18n.js');
console.log(' ✓ ai-fab.js exists and imports the expected modules');
}
function testAIFabModuleExports() {
const content = readFileSync(AIFAB_PATH, 'utf-8');
const exported = _collectExportedNames(content);
assert.ok(exported.has('initAIFab'), 'ai-fab.js must export initAIFab');
console.log(' ✓ ai-fab.js exports initAIFab');
}
// ── Run all tests ──────────────────────────────────────────────────────────
async function main() {
let passed = 0, failed = 0;
@@ -136,6 +216,11 @@ async function main() {
['state keys', testStateKeys],
['module syntax', testAllModulesParse],
['module structure', testModulesHaveImports],
['admin module exists', testAdminModuleExists],
['admin module exports', testAdminModuleExports],
['admin module syntax', testAdminModuleSyntax],
['ai-fab module exists', testAIFabModuleExists],
['ai-fab module exports', testAIFabModuleExports],
];
for (const [name, fn] of tests) {
+9 -2
View File
@@ -38,8 +38,15 @@ function collectExports(filePath, modName) {
const m = exportBlockText.match(/^export\s*\{([^}]+)\}/);
if (m) {
for (const name of m[1].split(',')) {
const n = name.trim().replace(/\s+as\s+\w+.*/, '').trim();
if (n && n !== '') exports.add(n);
// Handle `original as exported` — export both names
const asMatch = name.trim().match(/^(\w+)\s+as\s+(\w+)$/);
if (asMatch) {
exports.add(asMatch[2]); // exported name
exports.add(asMatch[1]); // original name too (in case it's re-imported)
} else {
const n = name.trim().trim();
if (n && n !== '') exports.add(n);
}
}
}
exportBlockText = '';
+338
View File
@@ -0,0 +1,338 @@
# tests/test_admin.py — Integration tests for the Admin Dashboard endpoints
# (ROADMAP #71)
#
# These tests cover:
# - GET /api/admin/stats — CPU/RAM/Disk/Uptime snapshot
# - GET /api/admin/audit — recent audit log entries with filters
# - GET /api/admin/backup-stats — backup counts/sizes per vault
# - GET /api/admin/stream — Server-Sent Events stream
# All endpoints must require admin auth.
import os
import shutil
import tempfile
from pathlib import Path
import pytest
@pytest.fixture
def admin_client(tmp_path):
"""TestClient with auth enabled, isolated temp data, admin user provisioned."""
data_dir = tmp_path / "data"
data_dir.mkdir()
import json
from backend.auth.password import hash_password
pw_hash = hash_password("chab30")
users = {
"version": 1,
"users": {
"admin": {
"id": "admin-1",
"username": "admin",
"display_name": "admin",
"password_hash": pw_hash,
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
},
"normaluser": {
"id": "user-1",
"username": "normaluser",
"display_name": "normal",
"password_hash": hash_password("normal123"),
"role": "user",
"vaults": ["TestVault"],
"active": True,
"created_at": "2026-01-01T00:00:00",
},
},
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
test_vault_path = os.path.abspath("test-vault")
os.chdir(str(tmp_path))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = test_vault_path
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_ADMIN_USER"] = "admin"
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "chab30"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.main import app
from backend.indexer import build_index, index
import asyncio
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from backend.search import init_inverted_index
init_inverted_index()
from fastapi.testclient import TestClient
client = TestClient(app)
yield client
if hasattr(client, "close"):
client.close()
loop.run_until_complete(asyncio.sleep(0))
os.chdir(orig_cwd)
shutil.rmtree(str(tmp_path), ignore_errors=True)
for k in [
"VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED",
]:
os.environ.pop(k, None)
def _login(client, username="admin", password="chab30"):
resp = client.post("/api/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
def _bearer(token):
return {"Authorization": f"Bearer {token}"}
# ═══════════════════════════════════════════════════════════════════
# /api/admin/stats
# ═══════════════════════════════════════════════════════════════════
class TestAdminStats:
EXPECTED_KEYS = {
"cpu_pct", "mem_used_mb", "mem_total_mb",
"disk_used_gb", "disk_total_gb",
"uptime_seconds", "active_sessions",
}
def test_stats_ok_as_admin(self, admin_client):
token = _login(admin_client)
resp = admin_client.get("/api/admin/stats", headers=_bearer(token))
assert resp.status_code == 200
data = resp.json()
missing = self.EXPECTED_KEYS - set(data.keys())
assert not missing, f"Missing keys: {missing}"
# Numeric sanity (allow 0 for any metric — depending on platform)
for key in ("cpu_pct", "mem_used_mb", "mem_total_mb",
"disk_used_gb", "disk_total_gb", "uptime_seconds",
"active_sessions"):
assert isinstance(data[key], (int, float)), f"{key} not numeric"
assert data[key] >= 0, f"{key} is negative"
def test_stats_requires_auth(self, admin_client):
resp = admin_client.get("/api/admin/stats")
assert resp.status_code in (401, 403)
def test_stats_requires_admin_role(self, admin_client):
token = _login(admin_client, username="normaluser", password="normal123")
resp = admin_client.get("/api/admin/stats", headers=_bearer(token))
assert resp.status_code == 403
# ═══════════════════════════════════════════════════════════════════
# /api/admin/audit
# ═══════════════════════════════════════════════════════════════════
class TestAdminAudit:
def _seed_audit(self, tmp_path_factory=None):
"""Append a few entries to the audit log so filtering has data."""
from backend.audit import _write_entry
from datetime import datetime, timezone
entries = [
{"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "file_save", "username": "alice", "vault": "TestVault",
"size": 100, "ip": "127.0.0.1"},
{"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "file_delete", "username": "bob", "vault": "TestVault",
"ip": "127.0.0.1"},
{"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "file_save", "username": "bob", "vault": "TestVault",
"size": 50, "ip": "127.0.0.1"},
]
for e in entries:
_write_entry(e)
def test_audit_ok(self, admin_client):
self._seed_audit()
token = _login(admin_client)
resp = admin_client.get("/api/admin/audit", headers=_bearer(token))
assert resp.status_code == 200
data = resp.json()
assert "entries" in data
assert "total" in data
# We just seeded at least 3 entries
assert data["total"] >= 3
def test_audit_filter_by_user(self, admin_client):
self._seed_audit()
token = _login(admin_client)
resp = admin_client.get("/api/admin/audit?user=bob", headers=_bearer(token))
assert resp.status_code == 200
data = resp.json()
assert all("bob" in str(e.get("username", "")).lower() for e in data["entries"])
def test_audit_filter_by_action(self, admin_client):
self._seed_audit()
token = _login(admin_client)
resp = admin_client.get("/api/admin/audit?action=file_delete", headers=_bearer(token))
assert resp.status_code == 200
data = resp.json()
assert all(e.get("action") == "file_delete" for e in data["entries"])
def test_audit_limit_param(self, admin_client):
self._seed_audit()
token = _login(admin_client)
resp = admin_client.get("/api/admin/audit?limit=2", headers=_bearer(token))
assert resp.status_code == 200
data = resp.json()
assert len(data["entries"]) <= 2
def test_audit_requires_admin(self, admin_client):
resp = admin_client.get("/api/admin/audit")
assert resp.status_code in (401, 403)
# ═══════════════════════════════════════════════════════════════════
# /api/admin/backup-stats
# ═══════════════════════════════════════════════════════════════════
class TestAdminBackupStats:
def _create_backup_files(self, tmp_path_factory=None):
"""Create a couple of fake .bak files in the default backup dir."""
from backend.indexer import vault_config
import time as _time
for vault_name, cfg in list(vault_config.items()):
vault_root = Path(cfg["path"])
backup_root = vault_root / ".obsigate-backup" / vault_name / "subdir"
backup_root.mkdir(parents=True, exist_ok=True)
ts1 = int(_time.time()) - 86400
ts2 = int(_time.time())
(backup_root / f"note.md.{ts1}.bak").write_text("old version")
(backup_root / f"note.md.{ts2}.bak").write_text("newer version with more content")
def test_backup_stats_ok(self, admin_client):
self._create_backup_files()
token = _login(admin_client)
resp = admin_client.get("/api/admin/backup-stats", headers=_bearer(token))
assert resp.status_code == 200
data = resp.json()
for k in ("total_backups", "total_size_mb", "oldest_age_days",
"newest_age_days", "by_vault"):
assert k in data, f"missing key {k}"
assert data["total_backups"] >= 2
assert data["total_size_mb"] >= 0
# We created one set per vault — at least one vault entry
assert isinstance(data["by_vault"], dict)
def test_backup_stats_empty(self, admin_client):
"""Even with no backups, endpoint returns 200 with zero counts."""
token = _login(admin_client)
resp = admin_client.get("/api/admin/backup-stats", headers=_bearer(token))
assert resp.status_code == 200
data = resp.json()
assert data["total_backups"] >= 0
# If no backups exist, both age fields are 0
if data["total_backups"] == 0:
assert data["newest_age_days"] == 0.0
assert data["oldest_age_days"] == 0.0
def test_backup_stats_requires_admin(self, admin_client):
resp = admin_client.get("/api/admin/backup-stats")
assert resp.status_code in (401, 403)
# ═══════════════════════════════════════════════════════════════════
# /api/admin/stream
# ═══════════════════════════════════════════════════════════════════
class TestAdminStream:
def test_stream_content_type(self, admin_client):
"""Verify SSE endpoint returns text/event-stream with valid first frame.
The /api/admin/stream endpoint is an infinite generator (yields every 5s).
We can't easily consume a streaming response from a sync TestClient
(the context manager blocks on entry for infinite responses). Instead,
we verify the endpoint contract from two angles:
1. Without auth → 401/403 (proves the endpoint is mounted and gated)
2. Direct invocation of the underlying generator yields a valid SSE
frame on the first iteration (proves the format contract).
"""
# 1) Endpoint is gated behind admin auth.
resp = admin_client.get("/api/admin/stream")
assert resp.status_code in (401, 403), (
f"unauthenticated should be rejected, got {resp.status_code}"
)
# 2) Direct generator check — read the first frame, then close.
import asyncio
from backend.admin import _stats_event_generator
async def _first_frame():
gen = _stats_event_generator()
return await gen.__anext__()
first = asyncio.run(_first_frame())
assert isinstance(first, str), f"expected str, got {type(first).__name__}"
assert first.startswith("event: stats"), f"unexpected frame: {first[:100]!r}"
assert "data: " in first
# The data line should be parseable JSON.
import json
data_line = [ln for ln in first.splitlines() if ln.startswith("data: ")][0]
payload = json.loads(data_line[len("data: "):])
assert isinstance(payload, dict)
assert "cpu_pct" in payload or "error" in payload
def test_stream_requires_admin(self, admin_client):
resp = admin_client.get("/api/admin/stream")
assert resp.status_code in (401, 403)
# ═══════════════════════════════════════════════════════════════
# Admin dashboard PAGE (/admin.html)
# ═══════════════════════════════════════════════════════════════
class TestAdminPage:
"""Regression for ROADMAP #71 — Admin menu bounced back to the main page.
Root cause: /admin.html had no explicit route, so the SPA catch-all
``/{full_path:path}`` served index.html. Guard the fix.
"""
def test_page_served_as_admin(self, admin_client):
token = _login(admin_client)
resp = admin_client.get("/admin.html", headers=_bearer(token))
assert resp.status_code == 200
body = resp.text
# Real admin page markers (NOT the main SPA index.html)
assert "admin-main" in body or "ObsiGate — Admin" in body or "admin.js" in body
# The regression: index.html markers must be absent
assert "boot-splash" not in body
# Asset paths must point to the actual static mount (/static), not /frontend
assert "/static/js/admin.js" in body
assert "/frontend/js/admin.js" not in body
def test_page_requires_auth(self, admin_client):
resp = admin_client.get("/admin.html")
assert resp.status_code in (401, 403)
def test_page_requires_admin_role(self, admin_client):
token = _login(admin_client, username="normaluser", password="normal123")
resp = admin_client.get("/admin.html", headers=_bearer(token))
assert resp.status_code == 403
+310
View File
@@ -0,0 +1,310 @@
"""Tests for the AI models listing endpoint + curated fallback lists (ROADMAP #74/#71).
Covers:
- GET /api/config/ai-models always returns a non-empty list for known providers,
even when the live API call fails (network, auth, etc.).
- The fallback list is curated with at least one model per provider.
- Gemini parsing strips the "models/" prefix.
- The default model is prepended if not already in the list.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from backend.main import _FALLBACK_MODELS, app
@pytest.fixture
def admin_client(tmp_path):
"""Minimal admin client for the /api/config/ai-models endpoint."""
from backend.auth.password import hash_password
import json
import os
from pathlib import Path
data_dir = tmp_path / "data"
data_dir.mkdir()
users = {
"version": 1,
"users": {
"admin": {
"id": "admin-1",
"username": "admin",
"display_name": "admin",
"password_hash": hash_password("chab30"),
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
},
},
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
import shutil
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
os.chdir(str(tmp_path))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = str(Path("test-vault").resolve())
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_ADMIN_USER"] = "admin"
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "chab30"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.indexer import build_index, index
import asyncio
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
client = TestClient(app)
yield client
client.close()
os.chdir(orig_cwd)
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED"]:
os.environ.pop(k, None)
def _login_admin(client):
resp = client.post("/api/auth/login",
json={"username": "admin", "password": "chab30"})
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
# ── Unit tests for the fallback table itself ─────────────────────────────
class TestFallbackTable:
def test_every_known_provider_has_fallback_list(self):
"""Every provider in the dropdown must have a non-empty fallback list."""
expected_providers = {
"deepseek", "openrouter", "gemini", "nvidia",
"qwencloud", "xiaomi", "mistral",
}
assert set(_FALLBACK_MODELS.keys()) >= expected_providers
for p in expected_providers:
assert _FALLBACK_MODELS[p], f"fallback list for {p!r} is empty"
assert all(isinstance(m, str) and m.strip() for m in _FALLBACK_MODELS[p]), \
f"fallback list for {p!r} contains invalid entries: {_FALLBACK_MODELS[p]}"
def test_fallback_lists_are_short_and_focused(self):
"""Fallbacks should be short (≤10 models) and well-known."""
for p, models in _FALLBACK_MODELS.items():
assert len(models) <= 10, f"too many fallbacks for {p}: {len(models)}"
def test_xiaomi_fallback_contains_mimo_models(self):
"""Xiaomi's MiMo models must be in the fallback list."""
mimos = [m for m in _FALLBACK_MODELS["xiaomi"] if "mimo" in m.lower()]
assert mimos, "no MiMo model in xiaomi fallback"
# ── Endpoint integration tests ────────────────────────────────────────────
class TestListModelsEndpoint:
"""Verify /api/config/ai-models?provider=X always returns a usable list."""
def test_unknown_provider_returns_empty(self, admin_client):
token = _login_admin(admin_client)
resp = admin_client.get(
"/api/config/ai-models",
params={"provider": "nonexistent-provider"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["models"] == []
assert "error" in data or "source" in data
def test_provider_without_key_returns_fallback(self, admin_client, monkeypatch):
"""When the provider has no API key configured, return the curated fallback list."""
# Force every provider key to be empty so the endpoint hits its
# 'no key configured' branch.
from backend import ai
monkeypatch.setattr(ai, "get_ai_key", lambda name: None)
token = _login_admin(admin_client)
for provider in ("xiaomi", "nvidia", "deepseek", "mistral"):
resp = admin_client.get(
"/api/config/ai-models",
params={"provider": provider},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, f"{provider}: {resp.status_code}"
data = resp.json()
assert data["models"], f"{provider}: fallback list is empty"
assert data.get("source") == "fallback", (
f"{provider}: expected source=fallback, got {data.get('source')!r}"
)
def test_provider_with_unreachable_api_returns_fallback(
self, admin_client, monkeypatch,
):
"""When the live API call fails (network/DNS), the fallback list is used.
This test patches both the key lookup AND the urlopen call so we
deterministically hit the network-failure branch.
"""
from backend import ai as aimod
# Fake key so we don't take the 'no key' short-circuit.
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key-for-test")
# Patch urllib.request.urlopen to always raise — simulating network down.
# NOTE: main.py imports urllib.request at module load, so we patch the
# symbol it actually uses (urllib.request.urlopen).
import urllib.request
def _boom(*args, **kwargs):
raise OSError("simulated network down")
monkeypatch.setattr(urllib.request, "urlopen", _boom)
token = _login_admin(admin_client)
# Pick a non-Gemini provider so we hit the network code path.
resp = admin_client.get(
"/api/config/ai-models",
params={"provider": "nvidia"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
# The response should be a usable list — either via fallback after
# network failure, or the 'no key' shortcut. Both are acceptable as
# long as models is non-empty.
assert data["models"], "model list should be non-empty"
assert data.get("source") in ("fallback",), (
f"unexpected source: {data.get('source')!r}"
)
def test_response_includes_source_field(self, admin_client, monkeypatch):
"""All successful responses must include a 'source' field for UI hinting."""
from backend import ai as aimod
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key")
token = _login_admin(admin_client)
resp = admin_client.get(
"/api/config/ai-models",
params={"provider": "gemini"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "source" in data
assert data["source"] in ("live", "fallback")
def test_xiaomi_uses_api_key_header_not_bearer(self, admin_client, monkeypatch):
"""Regression test: Xiaomi MiMo must use `api-key` header, NOT Authorization.
Without this, the live call always fails with 401 even with a valid key.
"""
# Fake key — patch BOTH the source module AND the imported reference
# in backend.main (which does `from backend.ai import ... get_ai_key`).
import backend.ai as aimod
import backend.main as bmain
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-xiaomi-key")
if hasattr(bmain, "get_ai_key"):
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-xiaomi-key")
captured = {}
def fake_Request(url, *args, **kwargs):
captured["url"] = url
captured["headers"] = dict(kwargs.get("headers") or {})
class FakeResp:
def __enter__(self): return self
def __exit__(self, *a): pass
def read(self):
return b'{"object":"list","data":[{"id":"mimo-v2.5-pro","object":"model","owned_by":"xiaomi"}]}'
captured["response"] = FakeResp()
return captured["response"]
def fake_urlopen(req, timeout=None):
return req
# Patch urllib.request at the point where backend.main imported it.
import urllib.request as global_urllib_mod
monkeypatch.setattr(global_urllib_mod, "Request", fake_Request, raising=True)
monkeypatch.setattr(global_urllib_mod, "urlopen", fake_urlopen, raising=True)
token = _login_admin(admin_client)
resp = admin_client.get(
"/api/config/ai-models",
params={"provider": "xiaomi"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, f"resp: {resp.text[:300]}"
# Verify the URL + headers used.
assert captured.get("url"), f"Request was not called (captured={captured!r})"
assert captured["url"].startswith("https://api.xiaomimimo.com/v1/models"), (
f"unexpected URL: {captured.get('url')!r}"
)
hdrs = {k.lower(): v for k, v in captured.get("headers", {}).items()}
assert "api-key" in hdrs, f"missing api-key header in {hdrs!r}"
assert hdrs["api-key"] == "fake-xiaomi-key"
assert "authorization" not in hdrs, f"Authorization leaked: {hdrs!r}"
def test_xiaomi_test_endpoint_uses_real_url(self, admin_client, monkeypatch):
"""The /api/config/ai-keys/test endpoint must also use api.xiaomimimo.com.
Regression: it previously used api.xiaomi.com which doesn't exist
(DNS error Name or service not known).
"""
# Patch BOTH the source module AND the imported reference in backend.main
import backend.ai as aimod
import backend.main as bmain
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key")
if hasattr(bmain, "get_ai_key"):
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-key")
import urllib.request as global_urllib_mod
captured_urls = []
captured_headers = []
def fake_urlopen(req, timeout=None):
captured_urls.append(req.full_url)
captured_headers.append(dict(req.headers))
raise OSError("simulated network error")
monkeypatch.setattr(global_urllib_mod, "urlopen", fake_urlopen, raising=True)
token = _login_admin(admin_client)
admin_client.post(
"/api/config/ai-keys/test",
headers={"Authorization": f"Bearer {token}"},
)
# Find the xiaomi URL among the captured calls.
xiaomi_idx = next(
(i for i, u in enumerate(captured_urls) if "xiaomi" in u.lower()),
None,
)
assert xiaomi_idx is not None, (
f"xiaomi URL not found in captured URLs: {captured_urls!r}"
)
xiaomi_url = captured_urls[xiaomi_idx]
# Must use the real MiMo endpoint, NOT the dead api.xiaomi.com.
assert "api.xiaomimimo.com" in xiaomi_url, (
f"xiaomi test URL is wrong: {xiaomi_url!r}"
)
# Must use api-key header, not Authorization. urllib.request
# normalizes header names to title-case ("Api-key"), but HTTP
# headers are case-insensitive on the wire — both forms are valid.
xiaomi_hdrs = {k.lower(): v for k, v in captured_headers[xiaomi_idx].items()}
assert "api-key" in xiaomi_hdrs, (
f"xiaomi api-key header missing: {xiaomi_hdrs!r}"
)
assert xiaomi_hdrs["api-key"] == "fake-key"
# Bearer prefix must NOT be in the api-key value
assert "Bearer" not in xiaomi_hdrs["api-key"]
+13
View File
@@ -15,6 +15,10 @@ class TestHealth:
assert "version" in data
assert data["vaults"] >= 1
assert data["total_files"] >= 1
# New enriched fields (ROADMAP #68)
assert "total_tokens" in data
assert "last_full_index_ts" in data
assert "uptime_seconds" in data
def test_health_has_vault_files(self, client):
resp = client.get("/api/health")
@@ -22,6 +26,15 @@ class TestHealth:
assert data["vaults"] == 1
assert data["total_files"] >= 4 # note1, note2, projet, café_crème + config.json
def test_health_detailed_requires_admin(self, admin_client):
"""Detailed health requires admin auth - uses admin_client fixture from conftest."""
# No auth -> 401/403
resp = admin_client.get("/api/health/detailed", headers={})
assert resp.status_code in (401, 403)
# Normal user -> 403 is tested in test_admin.py (which has normaluser fixture)
# This test focuses on the endpoint being protected
class TestVaults:
def test_list_vaults(self, client):
+46
View File
@@ -221,6 +221,52 @@ class TestAdmin:
data = resp.json()
assert data["username"] == "testuser"
def test_patch_user(self, auth_client):
"""PATCH /api/auth/admin/users/{username} updates fields."""
token = self._login_admin(auth_client)
# Create a user first, then patch it.
auth_client.post("/api/auth/admin/users", headers={
"Authorization": f"Bearer {token}",
}, json={
"username": "patchuser",
"password": "origpass",
"role": "user",
"vaults": ["TestVault"],
})
resp = auth_client.patch("/api/auth/admin/users/patchuser", headers={
"Authorization": f"Bearer {token}",
}, json={
"display_name": "Patched User",
"active": False,
})
assert resp.status_code == 200
data = resp.json()
assert data["display_name"] == "Patched User"
assert data["active"] is False
def test_delete_user(self, auth_client):
"""DELETE /api/auth/admin/users/{username} removes the user."""
token = self._login_admin(auth_client)
# Create a throwaway user, then delete it.
auth_client.post("/api/auth/admin/users", headers={
"Authorization": f"Bearer {token}",
}, json={
"username": "deleteuser",
"password": "delpass",
"role": "user",
"vaults": ["TestVault"],
})
resp = auth_client.delete("/api/auth/admin/users/deleteuser", headers={
"Authorization": f"Bearer {token}",
})
assert resp.status_code == 200
# Confirm it's gone via list.
list_resp = auth_client.get("/api/auth/admin/users", headers={
"Authorization": f"Bearer {token}",
})
usernames = [u["username"] for u in list_resp.json()]
assert "deleteuser" not in usernames
def test_logout(self, auth_client):
token = self._login_admin(auth_client)
resp = auth_client.post("/api/auth/logout", headers={
+748
View File
@@ -0,0 +1,748 @@
"""Tests for BooksLM — directory context collection, caching, and API routes."""
import asyncio
import json
import os
import shutil
import tempfile
from pathlib import Path
import pytest
# ── Unit tests: collect_directory_context ──────────────────────────────
class TestCollectDirectoryContext:
"""Tests for collect_directory_context()."""
def test_basic_collection(self, tmp_path):
"""Collect .md files from a simple directory."""
from backend.bookslm import collect_directory_context
vault = tmp_path / "vault"
vault.mkdir()
(vault / "note1.md").write_text("# Note 1\nHello world", encoding="utf-8")
(vault / "note2.md").write_text("# Note 2\nGoodbye world", encoding="utf-8")
(vault / "notemd.txt").write_text("Not markdown", encoding="utf-8")
result = collect_directory_context(vault, "")
assert result["file_count"] == 2
assert result["total_chars"] > 0
paths = [f["path"] for f in result["files"]]
assert "note1.md" in paths
assert "note2.md" in paths
assert "notemd.txt" not in paths
def test_subdirectory_collection(self, tmp_path):
"""Collect files recursively from subdirectories."""
from backend.bookslm import collect_directory_context
vault = tmp_path / "vault"
subdir = vault / "projects" / "code"
subdir.mkdir(parents=True)
(subdir / "readme.md").write_text("# Code project", encoding="utf-8")
(vault / "root.md").write_text("# Root", encoding="utf-8")
result = collect_directory_context(vault, "projects")
assert result["file_count"] == 1
assert result["files"][0]["path"] == "projects/code/readme.md"
def test_hidden_files_skipped(self, tmp_path):
"""Hidden files and special directories are skipped."""
from backend.bookslm import collect_directory_context
vault = tmp_path / "vault"
vault.mkdir()
(vault / ".hidden.md").write_text("Hidden", encoding="utf-8")
(vault / ".obsidian").mkdir()
(vault / ".obsidian" / "config.md").write_text("Config", encoding="utf-8")
(vault / "visible.md").write_text("Visible", encoding="utf-8")
result = collect_directory_context(vault, "")
assert result["file_count"] == 1
assert result["files"][0]["path"] == "visible.md"
def test_attachments_skipped(self, tmp_path):
"""_attachments/ directory is skipped."""
from backend.bookslm import collect_directory_context
vault = tmp_path / "vault"
attach = vault / "_attachments"
attach.mkdir(parents=True)
(attach / "image.md").write_text("Image doc", encoding="utf-8")
(vault / "real.md").write_text("Real content", encoding="utf-8")
result = collect_directory_context(vault, "")
assert result["file_count"] == 1
assert result["files"][0]["path"] == "real.md"
def test_max_files_limit(self, tmp_path):
"""Respects BOOKSLM_MAX_FILES limit."""
from backend.bookslm import collect_directory_context
import backend.bookslm as bookslm_mod
vault = tmp_path / "vault"
vault.mkdir()
old_max = bookslm_mod.BOOKSLM_MAX_FILES
bookslm_mod.BOOKSLM_MAX_FILES = 3
try:
for i in range(10):
(vault / f"note{i}.md").write_text(f"Content {i}", encoding="utf-8")
result = collect_directory_context(vault, "")
assert result["file_count"] == 3
finally:
bookslm_mod.BOOKSLM_MAX_FILES = old_max
def test_max_file_chars_truncation(self, tmp_path):
"""Files exceeding max chars are truncated with marker."""
from backend.bookslm import collect_directory_context
import backend.bookslm as bookslm_mod
vault = tmp_path / "vault"
vault.mkdir()
old_max = bookslm_mod.BOOKSLM_MAX_FILE_CHARS
bookslm_mod.BOOKSLM_MAX_FILE_CHARS = 50
try:
long_content = "x" * 200
(vault / "long.md").write_text(long_content, encoding="utf-8")
result = collect_directory_context(vault, "")
assert result["file_count"] == 1
assert "[... tronqué]" in result["files"][0]["content"]
assert len(result["files"][0]["content"]) <= 50 + 50 # truncated + marker
finally:
bookslm_mod.BOOKSLM_MAX_FILE_CHARS = old_max
def test_max_total_chars_limit(self, tmp_path):
"""Stops collecting when total chars limit is reached."""
from backend.bookslm import collect_directory_context
import backend.bookslm as bookslm_mod
vault = tmp_path / "vault"
vault.mkdir()
old_total = bookslm_mod.BOOKSLM_MAX_TOTAL_CHARS
old_file = bookslm_mod.BOOKSLM_MAX_FILE_CHARS
bookslm_mod.BOOKSLM_MAX_TOTAL_CHARS = 100
bookslm_mod.BOOKSLM_MAX_FILE_CHARS = 10000
try:
for i in range(10):
(vault / f"f{i}.md").write_text("a" * 50, encoding="utf-8")
result = collect_directory_context(vault, "")
# Should not collect all 10 files (10 * 50 = 500 > 100)
assert result["total_chars"] <= 100 + 50 # some margin for truncation marker
finally:
bookslm_mod.BOOKSLM_MAX_TOTAL_CHARS = old_total
bookslm_mod.BOOKSLM_MAX_FILE_CHARS = old_file
def test_readme_index_priority(self, tmp_path):
"""README and index files come first in results."""
from backend.bookslm import collect_directory_context
vault = tmp_path / "vault"
vault.mkdir()
(vault / "aaa.md").write_text("# AAA", encoding="utf-8")
(vault / "README.md").write_text("# README", encoding="utf-8")
(vault / "index.md").write_text("# Index", encoding="utf-8")
(vault / "zzz.md").write_text("# ZZZ", encoding="utf-8")
result = collect_directory_context(vault, "")
paths = [f["path"] for f in result["files"]]
# README and index should be before other files
readme_idx = paths.index("README.md")
index_idx = paths.index("index.md")
aaa_idx = paths.index("aaa.md")
assert readme_idx < aaa_idx
assert index_idx < aaa_idx
def test_empty_directory(self, tmp_path):
"""Empty directory returns empty result."""
from backend.bookslm import collect_directory_context
vault = tmp_path / "vault"
vault.mkdir()
result = collect_directory_context(vault, "")
assert result["file_count"] == 0
assert result["total_chars"] == 0
assert result["files"] == []
def test_nonexistent_directory(self, tmp_path):
"""Nonexistent directory returns empty result."""
from backend.bookslm import collect_directory_context
result = collect_directory_context(tmp_path, "nonexistent")
assert result["file_count"] == 0
def test_title_generation(self, tmp_path):
"""File titles are derived from stem with proper casing."""
from backend.bookslm import collect_directory_context
vault = tmp_path / "vault"
vault.mkdir()
(vault / "my-cool-note.md").write_text("Content", encoding="utf-8")
result = collect_directory_context(vault, "")
assert result["files"][0]["title"] == "My Cool Note"
def test_directory_tree(self, tmp_path):
"""Directory tree is included in result."""
from backend.bookslm import collect_directory_context
vault = tmp_path / "vault"
(vault / "sub").mkdir(parents=True)
(vault / "sub" / "file.md").write_text("Content", encoding="utf-8")
(vault / "root.md").write_text("Root", encoding="utf-8")
result = collect_directory_context(vault, "")
tree = result["directory_tree"]
assert "root.md" in tree
assert "sub/" in tree
assert "file.md" in tree
# ── Unit tests: build_system_prompt ────────────────────────────────────
class TestBuildSystemPrompt:
"""Tests for build_system_prompt()."""
def test_basic_prompt(self):
"""Prompt contains expected sections."""
from backend.bookslm import build_system_prompt
context = {
"files": [
{"path": "note.md", "title": "My Note", "content": "# Hello", "type": "markdown"},
],
"total_chars": 7,
"file_count": 1,
"directory_tree": "note.md",
}
prompt = build_system_prompt(context)
assert "assistant de recherche" in prompt
assert "note.md" in prompt
assert "My Note" in prompt
assert "# Hello" in prompt
assert "Cite tes sources" in prompt
def test_empty_context(self):
"""Empty context still produces valid prompt."""
from backend.bookslm import build_system_prompt
context = {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
prompt = build_system_prompt(context)
assert "0 fichier" in prompt
def test_token_warning(self):
"""Large context triggers token warning."""
from backend.bookslm import build_system_prompt
context = {
"files": [
{"path": "big.md", "title": "Big", "content": "x" * 500000, "type": "markdown"},
],
"total_chars": 500000,
"file_count": 1,
"directory_tree": "big.md",
}
prompt = build_system_prompt(context)
assert "⚠️" in prompt or "volumineux" in prompt
# ── Unit tests: caching ────────────────────────────────────────────────
class TestCaching:
"""Tests for cache behavior."""
def test_cache_hit(self, tmp_path):
"""Second call with same data returns cached result."""
from backend.bookslm import collect_directory_context, _cache
_cache.clear()
vault = tmp_path / "vault"
vault.mkdir()
(vault / "note.md").write_text("Content", encoding="utf-8")
result1 = collect_directory_context(vault, "")
result2 = collect_directory_context(vault, "")
assert result1["file_count"] == result2["file_count"]
assert result1["total_chars"] == result2["total_chars"]
def test_cache_invalidation_on_change(self, tmp_path):
"""Cache is invalidated when file content changes."""
from backend.bookslm import collect_directory_context, _cache
_cache.clear()
vault = tmp_path / "vault"
vault.mkdir()
(vault / "note.md").write_text("Original", encoding="utf-8")
result1 = collect_directory_context(vault, "")
assert result1["file_count"] == 1
# Modify file (change mtime)
import time
time.sleep(0.1)
(vault / "note.md").write_text("Modified content", encoding="utf-8")
result2 = collect_directory_context(vault, "")
assert result2["files"][0]["content"] == "Modified content"
def test_invalidate_cache(self, tmp_path):
"""invalidate_cache clears the cache."""
from backend.bookslm import collect_directory_context, invalidate_cache, _cache
_cache.clear()
vault = tmp_path / "vault"
vault.mkdir()
(vault / "note.md").write_text("Content", encoding="utf-8")
collect_directory_context(vault, "")
assert len(_cache) > 0
count = invalidate_cache()
assert count > 0
assert len(_cache) == 0
# ── Unit tests: secret redaction ──────────────────────────────────────
class TestRedaction:
"""Verify that secrets are redacted in collected content."""
def test_secrets_are_redacted(self, tmp_path):
"""API keys in file content are redacted."""
from backend.bookslm import collect_directory_context
vault = tmp_path / "vault"
vault.mkdir()
# The secret redactor looks for patterns like sk-..., AKIA..., etc.
(vault / "secrets.md").write_text(
"Config: api_key=AKIA1234567890ABCDEF and sk-abcdefghijklmnopqrstuvwxyz01234567890",
encoding="utf-8",
)
result = collect_directory_context(vault, "")
# The redactor should have processed this file
content = result["files"][0]["content"]
# At minimum the file should be collected (redaction is best-effort)
assert result["file_count"] == 1
# ── Integration tests: API endpoints ──────────────────────────────────
@pytest.fixture
def bookslm_client():
"""Create a TestClient with auth enabled, isolated temp data."""
tmp = Path(tempfile.mkdtemp())
data_dir = tmp / "data"
data_dir.mkdir()
# Create a test vault with some files
test_vault = tmp / "test-vault"
test_vault.mkdir()
(test_vault / "README.md").write_text("# Test Vault\nWelcome to the test vault.", encoding="utf-8")
(test_vault / "notes").mkdir()
(test_vault / "notes" / "note1.md").write_text("# Note 1\nFirst note content.", encoding="utf-8")
(test_vault / "notes" / "note2.md").write_text("# Note 2\nSecond note content.", encoding="utf-8")
from backend.auth.password import hash_password
pw_hash = hash_password("TestPass123!")
users = {
"version": 1,
"users": {
"testuser": {
"id": "testuser-1",
"username": "testuser",
"display_name": "Test User",
"password_hash": pw_hash,
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
}
}
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
os.chdir(str(tmp))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = str(test_vault)
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_ADMIN_USER"] = "testuser"
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "TestPass123!"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.main import app
from backend.indexer import build_index, index
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from backend.search import init_inverted_index
init_inverted_index()
from fastapi.testclient import TestClient
client = TestClient(app)
yield client
if hasattr(client, 'close'):
client.close()
loop.run_until_complete(asyncio.sleep(0))
os.chdir(orig_cwd)
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED"]:
os.environ.pop(k, None)
def _login_bookslm(client, username="testuser", password="TestPass123!"):
resp = client.post("/api/auth/login", json={"username": username, "password": password})
return resp.json().get("access_token"), resp
class TestBooksLMContextEndpoint:
"""Tests for POST /api/ai/bookslm/context."""
def test_context_returns_files(self, bookslm_client):
"""Context endpoint returns files from the directory."""
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"vault": "TestVault", "directory": "notes"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["file_count"] == 2
paths = [f["path"] for f in data["files"]]
assert "notes/note1.md" in paths
assert "notes/note2.md" in paths
def test_context_root_directory(self, bookslm_client):
"""Context endpoint works for root directory."""
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"vault": "TestVault", "directory": ""},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["file_count"] >= 1 # At least README.md
def test_context_requires_auth(self, bookslm_client):
"""Context endpoint requires authentication."""
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"vault": "TestVault", "directory": ""},
)
assert resp.status_code == 401
def test_context_vault_not_found(self, bookslm_client):
"""Context endpoint returns 404 for unknown vault."""
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"vault": "NonExistent", "directory": ""},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
def test_context_nonexistent_directory(self, bookslm_client):
"""Context endpoint returns empty for nonexistent directory."""
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"vault": "TestVault", "directory": "nonexistent"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["file_count"] == 0
class TestBooksLMChatEndpoint:
"""Tests for POST /api/ai/chat."""
def test_chat_requires_auth(self, bookslm_client):
"""Chat endpoint requires authentication."""
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={"vault": "TestVault", "directory": "", "message": "Hello"},
)
assert resp.status_code == 401
def test_chat_vault_not_found(self, bookslm_client):
"""Chat endpoint returns 404 for unknown vault."""
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={"vault": "NonExistent", "directory": "", "message": "Hello"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
def test_chat_empty_directory(self, bookslm_client):
"""Chat endpoint returns 404 for empty directory."""
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={"vault": "TestVault", "directory": "nonexistent", "message": "Hello"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
def test_chat_request_accepts_provider_and_model(self, bookslm_client):
"""The chat endpoint schema accepts provider + model fields without rejecting.
We don't actually call the AI (would need a live key) — we just verify
the request schema is wired correctly so a missing key is the only
failure mode, not a 422 validation error.
"""
token, _ = _login_bookslm(bookslm_client)
# Build a tiny valid directory so the chat endpoint doesn't 404.
from pathlib import Path
vault_dir = Path(os.environ.get("VAULT_1_PATH", "test-vault"))
sub = vault_dir / "for_chat_test"
sub.mkdir(exist_ok=True)
(sub / "note.md").write_text("# hello\n", encoding="utf-8")
try:
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={
"vault": "TestVault",
"directory": "for_chat_test",
"message": "ping",
"provider": "deepseek",
"model": "deepseek-chat",
},
headers={"Authorization": f"Bearer {token}"},
)
# Either 200 (if a real key is configured) or 500 (no key / quota).
# Must NOT be 422 — the schema must accept the fields.
assert resp.status_code in (200, 500), (
f"unexpected status {resp.status_code}: {resp.text[:200]}"
)
assert resp.status_code != 422, (
f"schema rejected provider/model fields: {resp.text[:300]}"
)
finally:
import shutil
shutil.rmtree(sub, ignore_errors=True)
# ── Unit tests: explicit documents context + general prompt ────────────
class TestCollectFilesContext:
"""Tests for collect_files_context() (Documents mode)."""
def test_collects_only_requested_files(self, tmp_path):
from backend.bookslm import collect_files_context
vault = tmp_path / "vault"
(vault / "sub").mkdir(parents=True)
(vault / "a.md").write_text("# A", encoding="utf-8")
(vault / "b.md").write_text("# B", encoding="utf-8")
(vault / "sub" / "c.md").write_text("# C", encoding="utf-8")
result = collect_files_context(vault, ["a.md", "sub/c.md"], scope="documents")
assert result["scope"] == "documents"
assert result["file_count"] == 2
assert [f["path"] for f in result["files"]] == ["a.md", "sub/c.md"]
assert result["directory_tree"] == ""
def test_ignores_paths_outside_vault(self, tmp_path):
from backend.bookslm import collect_files_context
vault = tmp_path / "vault"
vault.mkdir()
(vault / "ok.md").write_text("# ok", encoding="utf-8")
outside = tmp_path / "outside.md"
outside.write_text("# secret", encoding="utf-8")
result = collect_files_context(vault, ["../outside.md", "ok.md"])
assert result["file_count"] == 1
assert result["files"][0]["path"] == "ok.md"
def test_deduplicates_paths(self, tmp_path):
from backend.bookslm import collect_files_context
vault = tmp_path / "vault"
vault.mkdir()
(vault / "dup.md").write_text("# dup", encoding="utf-8")
result = collect_files_context(vault, ["dup.md", "dup.md"])
assert result["file_count"] == 1
def test_empty_context(self):
from backend.bookslm import empty_context
ctx = empty_context("general")
assert ctx["file_count"] == 0
assert ctx["files"] == []
assert ctx["scope"] == "general"
class TestGeneralPrompt:
def test_general_prompt_lists_vaults(self):
from backend.bookslm import build_general_system_prompt
prompt = build_general_system_prompt(["Alpha", "Beta"])
assert "Alpha" in prompt
assert "Beta" in prompt
assert "obsigate-action" in prompt
def test_documents_prompt_scope(self):
from backend.bookslm import build_system_prompt
context = {
"files": [{"path": "a.md", "title": "A", "content": "hello"}],
"file_count": 1,
"total_chars": 5,
"directory_tree": "",
}
prompt = build_system_prompt(context, scope="documents")
assert "Documents ouverts" in prompt
assert "hello" in prompt
class TestBooksLMContextModes:
"""Tests for the mode-aware /context endpoint."""
def test_general_mode_needs_no_vault(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"mode": "general"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["scope"] == "general"
assert data["file_count"] == 0
assert data["files"] == []
def test_documents_mode_returns_selected_files(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={
"mode": "documents",
"vault": "TestVault",
"context_files": ["notes/note1.md"],
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["scope"] == "documents"
assert data["file_count"] == 1
assert data["files"][0]["path"] == "notes/note1.md"
def test_documents_mode_without_files_degrades_to_general(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"mode": "documents", "vault": "TestVault", "context_files": []},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["scope"] == "general"
assert data["file_count"] == 0
def test_documents_mode_requires_vault(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"mode": "documents", "context_files": ["notes/note1.md"]},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 400
def test_unknown_mode_falls_back_to_directory(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"mode": "bogus", "vault": "TestVault", "directory": "notes"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["scope"] == "directory"
class TestBooksLMChatModes:
def test_general_chat_does_not_require_vault(self, bookslm_client):
"""General mode must not 400/404 on a missing vault (only 500 if no key)."""
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={"mode": "general", "message": "Bonjour"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
assert resp.status_code != 422
def test_documents_chat_accepts_context_files(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={
"mode": "documents",
"vault": "TestVault",
"context_files": ["notes/note1.md"],
"message": "Résume",
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
assert resp.status_code != 422
+96 -1
View File
@@ -2,7 +2,6 @@
import json
EXCALIDRAW_SKELETON = {
"type": "excalidraw",
"version": 2,
@@ -47,6 +46,34 @@ EXCALIDRAW_WITH_CONTENT = {
"files": {},
}
# ████████████████████████████████████████████████████████████████████████
# B5 — Indexation du contenu texte Excalidraw (recherche TF-IDF)
# ████████████████████████████████████████████████████████████████████████
# Fixture lz-string "compressToBase64" générée par le vrai paquet
# [email protected] (JS) en 2026-09-09, validée byte-pour-byte contre le port
# Python de `_decompress_excalidraw`. Contient du texte accentué.
LZ_COMPRESSED_DIAGRAM = (
"N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcmTGAWxkbBoQBtUHgQh0"
"ZcNDiIwKMJLnIFiABIdM9AAQBRNFjyES5evmwBzbIywAVeWPWZNu/TgLFJyBAEYADOQhWXwBf"
"UhFcMQAjRWkxJQVyeLEAJQBLmgBXLlStOVQAC0ZsAEcMmC0AMi1GejkBEzNLa0w7ZRT0rJy8"
"wpKyyuramHqQL3gAZn8QQPgAFhCwkFFEVBjYMXwYVDB0RnNOSUx0SI4xACF6ADu5LWwuKF"
"NtvjhyUYBWAIRX4IBdcnQoKAAZW2cgQoGsPDEABEhnRmKEQAAzbCcATwYDBYJAA"
)
_RAW_EXCALIDRAW_MD = """---
excalidraw-plugin: parsed
---
```compressed-json
{comp}
```
Ce texte hors bloc ne doit pas polluer l'index (on extrait la scène uniquement).
"""
def _make_excalidraw_md(compressed: str) -> str:
return _RAW_EXCALIDRAW_MD.format(comp=compressed)
class TestExcalidrawDetection:
"""Test that the /api/file endpoint correctly detects .excalidraw files."""
@@ -164,3 +191,71 @@ class TestExcalidrawDetection:
assert data["is_excalidraw"] is True
assert len(data["excalidraw_data"]["elements"]) == 1
assert data["excalidraw_data"]["elements"][0]["type"] == "ellipse"
class TestExcalidrawIndexableTextB5:
"""B5 — Indexation du texte des éléments (recherche TF-IDF)."""
def test_pure_json_extracts_element_text(self):
from backend.indexer import extract_excalidraw_indexable
out = extract_excalidraw_indexable(json.dumps(EXCALIDRAW_WITH_CONTENT))
assert "Hello Excalidraw" in out
def test_pure_json_skeleton_has_no_text(self):
from backend.indexer import extract_excalidraw_indexable
out = extract_excalidraw_indexable(json.dumps(EXCALIDRAW_SKELETON))
assert out.strip() == ""
def test_pure_json_binds_label_and_title(self):
from backend.indexer import extract_excalidraw_indexable
payload = {
"type": "excalidraw",
"version": 2,
"elements": [
{"id": "r", "type": "rectangle", "label": "Boite bleue"},
{"id": "t", "type": "text", "text": "texte simple",
"originalText": "texte simple"},
],
"appState": {},
"files": {},
}
out = extract_excalidraw_indexable(json.dumps(payload))
assert "Boite bleue" in out
assert "texte simple" in out
def test_lz_decompress_matches_js_fixture(self):
"""Le port Python décode un payload lz-string produit par le vrai JS."""
from backend.indexer import _decompress_excalidraw
data = _decompress_excalidraw(LZ_COMPRESSED_DIAGRAM)
assert data is not None
assert data["type"] == "excalidraw"
elements = data["elements"]
texts = {e.get("text") or e.get("label") for e in elements if isinstance(e, dict)}
assert "Hello Excalidraw" in texts
assert "Résumé technique & notes" in texts
assert "Boîte importante" in texts
def test_lz_decompress_rejects_garbage(self):
from backend.indexer import _decompress_excalidraw
assert _decompress_excalidraw("") is None
assert _decompress_excalidraw("@@@not-lz@@@") is None
assert _decompress_excalidraw("!!!") is None
def test_excalidraw_md_compressed_extracts_text(self):
""".excalidraw.md (format plugin Obsidian) → texte indexé."""
from backend.indexer import extract_excalidraw_indexable
out = extract_excalidraw_indexable(_make_excalidraw_md(LZ_COMPRESSED_DIAGRAM))
assert "Hello Excalidraw" in out
assert "Boîte importante" in out
def test_excalidraw_md_missing_block_returns_empty(self):
from backend.indexer import extract_excalidraw_indexable
out = extract_excalidraw_indexable("---\nexcalidraw-plugin: parsed\n---\n(pas de bloc)")
assert out.strip() == ""
+172
View File
@@ -0,0 +1,172 @@
# tests/test_export.py — Tests for multi-format export (HTML / MD bundle / ePub)
import io
import zipfile
from pathlib import Path
import pytest
from backend.export import (
ExportError,
export_epub,
export_html,
export_md_bundle,
)
# ═══════════════════════════════════════════════════════════════════
# export_html
# ═══════════════════════════════════════════════════════════════════
class TestExportHtml:
def test_valid_html(self, test_vault_dir):
vault = Path(test_vault_dir)
html = export_html(vault, vault / "note1.md")
assert isinstance(html, bytes)
text = html.decode("utf-8")
assert text.startswith("<!DOCTYPE html>")
assert "<html" in text
assert "</html>" in text
assert "<style>" in text # inlined CSS
def test_renders_content(self, test_vault_dir):
vault = Path(test_vault_dir)
html = export_html(vault, vault / "note1.md").decode("utf-8")
assert "Introduction" in html
assert "Python" in html
def test_uses_frontmatter_title(self, test_vault_dir):
vault = Path(test_vault_dir)
html = export_html(vault, vault / "note1.md").decode("utf-8")
assert "<title>Introduction à Python</title>" in html
def test_navigation_lists_other_notes(self, test_vault_dir):
vault = Path(test_vault_dir)
html = export_html(vault, vault / "note1.md").decode("utf-8")
assert "class=\"export-nav\"" in html
# note2 should appear as a sibling link
assert "note2.html" in html or "note2" in html
def test_wikilink_becomes_link(self, test_vault_dir):
vault = Path(test_vault_dir)
html = export_html(vault, vault / "note2.md").decode("utf-8")
assert "href=" in html
def test_missing_file_raises(self, test_vault_dir):
vault = Path(test_vault_dir)
with pytest.raises(ExportError):
export_html(vault, vault / "does_not_exist.md")
# ═══════════════════════════════════════════════════════════════════
# export_md_bundle
# ═══════════════════════════════════════════════════════════════════
class TestExportMdBundle:
def test_zip_contains_files(self, test_vault_dir):
vault = Path(test_vault_dir)
data = export_md_bundle(vault, vault)
assert isinstance(data, bytes)
zf = zipfile.ZipFile(io.BytesIO(data))
names = zf.namelist()
assert "note1.md" in names
assert "note2.md" in names
assert "config.json" in names
# Subdirectory preserved
assert any("Projets/projet.md" in n for n in names)
def test_zip_preserves_structure(self, test_vault_dir):
vault = Path(test_vault_dir)
data = export_md_bundle(vault, vault / "Projets")
zf = zipfile.ZipFile(io.BytesIO(data))
names = zf.namelist()
assert "projet.md" in names
def test_single_file_bundle(self, test_vault_dir):
vault = Path(test_vault_dir)
data = export_md_bundle(vault, vault / "note1.md")
zf = zipfile.ZipFile(io.BytesIO(data))
assert "note1.md" in zf.namelist()
def test_missing_path_raises(self, test_vault_dir):
vault = Path(test_vault_dir)
with pytest.raises(ExportError):
export_md_bundle(vault, vault / "nope")
# ═══════════════════════════════════════════════════════════════════
# export_epub
# ═══════════════════════════════════════════════════════════════════
class TestExportEpub:
def test_epub_is_valid_zip(self, test_vault_dir):
vault = Path(test_vault_dir)
data = export_epub(vault, vault / "note1.md")
assert isinstance(data, bytes)
zf = zipfile.ZipFile(io.BytesIO(data))
assert zf.testzip() is None # archive is intact
def test_epub_mimetype_first(self, test_vault_dir):
vault = Path(test_vault_dir)
data = export_epub(vault, vault / "note1.md")
zf = zipfile.ZipFile(io.BytesIO(data))
names = zf.namelist()
assert names[0] == "mimetype"
assert zf.read("mimetype") == b"application/epub+zip"
def test_epub_required_files(self, test_vault_dir):
vault = Path(test_vault_dir)
data = export_epub(vault, vault / "note1.md")
zf = zipfile.ZipFile(io.BytesIO(data))
names = set(zf.namelist())
assert "META-INF/container.xml" in names
assert "OEBPS/content.opf" in names
assert "OEBPS/toc.ncx" in names
assert "OEBPS/chapter1.xhtml" in names
def test_epub_content_rendered(self, test_vault_dir):
vault = Path(test_vault_dir)
data = export_epub(vault, vault / "note1.md")
zf = zipfile.ZipFile(io.BytesIO(data))
xhtml = zf.read("OEBPS/chapter1.xhtml").decode("utf-8")
assert "Introduction" in xhtml
assert "Python" in xhtml
def test_epub_missing_file_raises(self, test_vault_dir):
vault = Path(test_vault_dir)
with pytest.raises(ExportError):
export_epub(vault, vault / "nope.md")
# ═══════════════════════════════════════════════════════════════════
# API endpoints
# ═══════════════════════════════════════════════════════════════════
class TestExportApi:
def test_export_html_endpoint(self, client):
resp = client.get("/api/export/html", params={"vault": "TestVault", "path": "note1.md"})
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
assert "attachment" in resp.headers["content-disposition"]
assert resp.content.startswith(b"<!DOCTYPE html>")
def test_export_md_bundle_endpoint(self, client):
resp = client.get("/api/export/md-bundle", params={"vault": "TestVault", "path": ""})
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/zip"
zf = zipfile.ZipFile(io.BytesIO(resp.content))
assert "note1.md" in zf.namelist()
def test_export_epub_endpoint(self, client):
resp = client.get("/api/export/epub", params={"vault": "TestVault", "path": "note1.md"})
assert resp.status_code == 200
assert "epub" in resp.headers["content-type"]
zf = zipfile.ZipFile(io.BytesIO(resp.content))
assert zf.namelist()[0] == "mimetype"
def test_export_missing_file_returns_400(self, client):
resp = client.get("/api/export/html", params={"vault": "TestVault", "path": "nope.md"})
assert resp.status_code == 400
def test_export_unknown_vault_returns_404(self, client):
resp = client.get("/api/export/html", params={"vault": "NoVault", "path": "x.md"})
assert resp.status_code == 404
+353
View File
@@ -0,0 +1,353 @@
"""Tests for MFA (Multi-Factor Authentication) — TOTP + recovery codes."""
import asyncio
import json
import os
import shutil
import tempfile
import time
from pathlib import Path
import pyotp
import pytest
from backend.auth.mfa import (
TOTP_ISSUER,
generate_qr_uri,
generate_recovery_codes,
generate_secret,
hash_recovery_code,
verify_recovery_code,
verify_totp,
)
# ── Unit tests: TOTP ───────────────────────────────────────────────────
class TestTotpGeneration:
def test_generate_secret_returns_base32(self):
secret = generate_secret()
assert isinstance(secret, str)
assert len(secret) >= 16
import base64
base64.b32decode(secret, casefold=True)
def test_generate_secret_unique(self):
secrets = {generate_secret() for _ in range(100)}
assert len(secrets) == 100
def test_generate_qr_uri_format(self):
secret = generate_secret()
uri = generate_qr_uri(secret, "testuser")
assert uri.startswith("otpauth://totp/")
assert "ObsiGate" in uri
assert "testuser" in uri
assert "secret=" in uri
def test_generate_qr_uri_custom_issuer(self):
secret = generate_secret()
uri = generate_qr_uri(secret, "testuser", issuer="CustomIssuer")
assert "CustomIssuer" in uri
def test_verify_totp_valid(self):
secret = generate_secret()
totp = pyotp.TOTP(secret)
code = totp.now()
assert verify_totp(secret, code) is True
def test_verify_totp_invalid(self):
secret = generate_secret()
assert verify_totp(secret, "000000") is False
def test_verify_totp_wrong_secret(self):
secret1 = generate_secret()
secret2 = generate_secret()
totp1 = pyotp.TOTP(secret1)
code = totp1.now()
assert verify_totp(secret2, code) is False
def test_verify_totp_window_tolerance(self):
secret = generate_secret()
totp = pyotp.TOTP(secret)
current_time = time.time()
code = totp.at(int(current_time))
assert verify_totp(secret, str(code).zfill(6)) is True
# ── Unit tests: Recovery codes ─────────────────────────────────────────
class TestRecoveryCodes:
def test_generate_count(self):
codes = generate_recovery_codes()
assert len(codes) == 8
def test_generate_custom_count(self):
codes = generate_recovery_codes(n=12)
assert len(codes) == 12
def test_code_format(self):
codes = generate_recovery_codes()
for code in codes:
assert len(code) == 9 # XXXX-XXXX
assert code[4] == "-"
assert code[:4].isalnum()
assert code[5:].isalnum()
def test_codes_unique(self):
codes = generate_recovery_codes(n=20)
assert len(set(codes)) == 20
def test_codes_uppercase(self):
codes = generate_recovery_codes()
for code in codes:
assert code == code.upper()
def test_hash_recovery_code_deterministic(self):
code = "ABCD-1234"
h1 = hash_recovery_code(code)
h2 = hash_recovery_code(code)
assert h1 == h2
def test_hash_case_insensitive(self):
h1 = hash_recovery_code("abcd-1234")
h2 = hash_recovery_code("ABCD-1234")
assert h1 == h2
def test_hash_different_for_different_codes(self):
h1 = hash_recovery_code("AAAA-AAAA")
h2 = hash_recovery_code("BBBB-BBBB")
assert h1 != h2
def test_verify_recovery_code_match(self):
codes = generate_recovery_codes()
hashed = [hash_recovery_code(c) for c in codes]
for i, code in enumerate(codes):
idx = verify_recovery_code(code, hashed)
assert idx == i
def test_verify_recovery_code_case_insensitive(self):
codes = ["ABCD-1234"]
hashed = [hash_recovery_code(c) for c in codes]
assert verify_recovery_code("abcd-1234", hashed) == 0
def test_verify_recovery_code_invalid(self):
codes = generate_recovery_codes()
hashed = [hash_recovery_code(c) for c in codes]
assert verify_recovery_code("ZZZZ-ZZZZ", hashed) is None
def test_verify_recovery_code_empty_list(self):
assert verify_recovery_code("AAAA-AAAA", []) is None
def test_recovery_code_single_use(self):
codes = generate_recovery_codes(n=3)
hashed = [hash_recovery_code(c) for c in codes]
idx = verify_recovery_code(codes[0], hashed)
assert idx == 0
hashed.pop(idx)
assert verify_recovery_code(codes[0], hashed) is None
idx2 = verify_recovery_code(codes[1], hashed)
assert idx2 == 0
# ── Integration tests: MFA API endpoints ───────────────────────────────
@pytest.fixture
def mfa_client():
"""Create a TestClient with auth enabled, isolated temp data."""
tmp = Path(tempfile.mkdtemp())
data_dir = tmp / "data"
data_dir.mkdir()
from backend.auth.password import hash_password
pw_hash = hash_password("TestPass123!")
users = {
"version": 1,
"users": {
"testuser": {
"id": "testuser-1",
"username": "testuser",
"display_name": "Test User",
"password_hash": pw_hash,
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
}
}
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
test_vault_path = os.path.abspath("test-vault")
os.chdir(str(tmp))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = test_vault_path
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_ADMIN_USER"] = "testuser"
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "TestPass123!"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.main import app
from backend.indexer import build_index, index
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from backend.search import init_inverted_index
init_inverted_index()
from fastapi.testclient import TestClient
client = TestClient(app)
yield client
if hasattr(client, 'close'):
client.close()
loop.run_until_complete(asyncio.sleep(0))
os.chdir(orig_cwd)
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED"]:
os.environ.pop(k, None)
def _login(client, username="testuser", password="TestPass123!"):
resp = client.post("/api/auth/login", json={"username": username, "password": password})
return resp.json().get("access_token"), resp
def _auth_headers(token):
return {"Authorization": f"Bearer {token}"}
class TestMfaApiEndpoints:
def test_mfa_status_initially_disabled(self, mfa_client):
token, _ = _login(mfa_client)
resp = mfa_client.get("/api/auth/mfa/status", headers=_auth_headers(token))
assert resp.status_code == 200
assert resp.json()["mfa_enabled"] is False
def test_mfa_setup_generates_secret(self, mfa_client):
token, _ = _login(mfa_client)
resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=_auth_headers(token))
assert resp.status_code == 200
data = resp.json()
assert "secret" in data
assert "otpauth_uri" in data
assert "otpauth://totp/" in data["otpauth_uri"]
assert len(data["secret"]) >= 16
def test_mfa_enable_flow(self, mfa_client):
token, _ = _login(mfa_client)
headers = _auth_headers(token)
# Setup
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
secret = setup_resp.json()["secret"]
totp = pyotp.TOTP(secret)
# Enable
enable_resp = mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={
"code": totp.now(),
})
assert enable_resp.status_code == 200
data = enable_resp.json()
assert "recovery_codes" in data
assert len(data["recovery_codes"]) == 8
assert data["mfa_enabled"] is True
def test_mfa_enable_invalid_code(self, mfa_client):
token, _ = _login(mfa_client)
headers = _auth_headers(token)
mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
resp = mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={
"code": "000000",
})
assert resp.status_code in (400, 401)
def test_mfa_login_defers_to_totp(self, mfa_client):
token, _ = _login(mfa_client)
headers = _auth_headers(token)
# Enable MFA
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
secret = setup_resp.json()["secret"]
totp = pyotp.TOTP(secret)
mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()})
# Login again — should require MFA
login_resp = mfa_client.post("/api/auth/login", json={
"username": "testuser", "password": "TestPass123!",
})
assert login_resp.status_code == 200
data = login_resp.json()
assert data.get("mfa_required") is True
assert data.get("mfa_method") == "totp"
assert "access_token" not in data
def test_mfa_verify_issues_token(self, mfa_client):
token, _ = _login(mfa_client)
headers = _auth_headers(token)
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
secret = setup_resp.json()["secret"]
totp = pyotp.TOTP(secret)
mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()})
# Login → MFA challenge
mfa_client.post("/api/auth/login", json={"username": "testuser", "password": "TestPass123!"})
# Verify TOTP
verify_resp = mfa_client.post("/api/auth/mfa/totp/verify", json={
"username": "testuser", "code": totp.now(),
})
assert verify_resp.status_code == 200
data = verify_resp.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_mfa_recovery_login(self, mfa_client):
token, _ = _login(mfa_client)
headers = _auth_headers(token)
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
secret = setup_resp.json()["secret"]
totp = pyotp.TOTP(secret)
enable_resp = mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()})
recovery_codes = enable_resp.json()["recovery_codes"]
# Login → MFA challenge
mfa_client.post("/api/auth/login", json={"username": "testuser", "password": "TestPass123!"})
# Use recovery code
recover_resp = mfa_client.post("/api/auth/mfa/recovery", json={
"username": "testuser", "recovery_code": recovery_codes[0],
})
assert recover_resp.status_code == 200
assert "access_token" in recover_resp.json()
def test_mfa_disable_flow(self, mfa_client):
token, _ = _login(mfa_client)
headers = _auth_headers(token)
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
secret = setup_resp.json()["secret"]
totp = pyotp.TOTP(secret)
mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()})
# Disable
disable_resp = mfa_client.post("/api/auth/mfa/totp/disable", headers=headers, json={
"password": "TestPass123!", "code": totp.now(),
})
assert disable_resp.status_code == 200
# Verify disabled
status_resp = mfa_client.get("/api/auth/mfa/status", headers=headers)
assert status_resp.json()["mfa_enabled"] is False
def test_login_without_mfa_still_works(self, mfa_client):
login_resp = mfa_client.post("/api/auth/login", json={
"username": "testuser", "password": "TestPass123!",
})
assert login_resp.status_code == 200
data = login_resp.json()
assert "access_token" in data
assert data.get("mfa_required") is None
+243
View File
@@ -0,0 +1,243 @@
"""Tests for the enriched OpenAPI 3.1 documentation (#72).
These tests validate the *documentation contract*: the schema is valid
OpenAPI 3.1, every operation is tagged, examples/security/error responses are
present, the representative endpoints expose response schemas, and the
interactive documentation pages are reachable.
"""
import json
import pytest
from backend.main import app
from backend.openapi_docs import TAGS_METADATA, canonical_tag, tag_for_path
@pytest.fixture(scope="module")
def schema() -> dict:
"""The enriched OpenAPI schema (cached by FastAPI)."""
return app.openapi()
def _operations(schema: dict):
for path, ops in schema.get("paths", {}).items():
for method, op in ops.items():
if isinstance(op, dict):
yield path, method, op
# ---------------------------------------------------------------------------
# Schema validity & metadata
# ---------------------------------------------------------------------------
def test_openapi_is_version_3_1(schema):
assert schema["openapi"] == "3.1.0"
def test_info_metadata(schema):
info = schema["info"]
assert info["title"] == "ObsiGate API"
assert info.get("description")
assert info.get("contact")
assert info.get("license")
def test_servers_and_external_docs(schema):
assert schema.get("servers")
assert schema.get("externalDocs", {}).get("url")
# ---------------------------------------------------------------------------
# Tags
# ---------------------------------------------------------------------------
def test_tags_metadata_declared(schema):
declared = [t["name"] for t in schema["tags"]]
assert declared == [t["name"] for t in TAGS_METADATA]
assert "Files" in declared
assert "Search" in declared
assert "AI" in declared
assert "Backups" in declared
assert "Auth" in declared
def test_every_operation_has_a_tag(schema):
untagged = [(m, p) for p, m, op in _operations(schema) if not op.get("tags")]
assert untagged == []
def test_used_tags_are_declared(schema):
declared = {t["name"] for t in schema["tags"]}
used = {tag for _, _, op in _operations(schema) for tag in op.get("tags", [])}
assert used <= declared, f"undeclared tags: {used - declared}"
def test_router_tags_are_canonicalised(schema):
# Routers declare lowercase tags (e.g. tags=["auth"]); the enrichment must
# map them to the documented, capitalised names.
auth_ops = [op for p, _, op in _operations(schema) if p.startswith("/api/auth")]
assert auth_ops
assert all(op["tags"] == ["Auth"] for op in auth_ops)
@pytest.mark.parametrize(
"path,expected",
[
("/api/file/{vault_name}/pdf/stream", "PDF"),
("/api/file/{vault_name}/backups", "Backups"),
("/api/file/{vault_name}", "Files"),
("/api/search/advanced", "Search"),
("/api/config/ai-models", "AI"),
("/api/ai/bookslm/chat", "BooksLM"),
("/api/vaults/status", "Vaults"),
("/api/recent", "Bookmarks"),
("/api/webhooks", "Webhooks"),
("/s/{token}", "Sharing"),
("/api/conflicts", "Conflicts"),
("/api/health", "System"),
],
)
def test_tag_for_path_rules(path, expected):
assert tag_for_path(path) == expected
def test_canonical_tag_helper():
assert canonical_tag("auth") == "Auth"
assert canonical_tag("booksLM") == "BooksLM"
assert canonical_tag("unknown") == "unknown"
# ---------------------------------------------------------------------------
# Security, errors & examples
# ---------------------------------------------------------------------------
def test_security_schemes(schema):
schemes = schema["components"]["securitySchemes"]
assert schemes["bearerAuth"]["scheme"] == "bearer"
assert schemes["cookieAuth"]["in"] == "cookie"
def test_common_error_responses_documented(schema):
# Most authenticated operations document the standard error envelope.
with_401 = [op for _, _, op in _operations(schema) if "401" in op.get("responses", {})]
with_404 = [op for _, _, op in _operations(schema) if "404" in op.get("responses", {})]
assert len(with_401) > 50
assert len(with_404) > 30
def test_key_endpoint_examples_present(schema):
improve = schema["paths"]["/api/ai/improve"]["post"]
example = improve["requestBody"]["content"]["application/json"]["example"]
assert example["provider"] == "deepseek"
assert improve["responses"]["200"]["content"]["application/json"]["example"]["result"]
create = schema["paths"]["/api/file/{vault_name}"]["post"]
assert "content" in create["requestBody"]["content"]["application/json"]["example"]
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
_RESPONSE_MODEL_PATHS = [
("/api/recent", "get"),
("/api/bookmarks", "get"),
("/api/bookmarks/toggle", "post"),
("/api/saved-searches", "get"),
("/api/saved-searches", "post"),
("/api/file/{vault_name}/backups", "get"),
("/api/file/{vault_name}/diff", "get"),
("/api/file/{vault_name}/restore", "post"),
("/api/file/{vault_name}/backlinks", "get"),
("/api/file/{vault_name}/pdf/info", "get"),
("/api/search/replace", "post"),
("/api/index/reload/{vault_name}", "get"),
("/api/vaults/add", "post"),
("/api/vaults/status", "get"),
("/api/attachments/stats", "get"),
("/api/vaults/{vault_name}/settings", "get"),
("/api/vault/{vault_name}/files", "get"),
("/api/vaults/settings/all", "get"),
("/api/backups", "get"),
("/api/backups/content", "get"),
("/api/config", "get"),
("/api/config/ai-models", "get"),
("/api/diagnostics", "get"),
("/api/dashboard", "get"),
("/api/webhooks", "get"),
("/api/shares", "get"),
("/api/conflicts", "get"),
("/api/conflicts/resolve", "post"),
]
@pytest.mark.parametrize("path,method", _RESPONSE_MODEL_PATHS)
def test_json_response_schema_present(schema, path, method):
op = schema["paths"][path][method]
content = op["responses"]["200"].get("content", {})
assert "application/json" in content, f"{method.upper()} {path} has no JSON response schema"
assert "schema" in content["application/json"]
def test_binary_endpoints_do_not_claim_json(schema):
for path in ["/api/file/{vault_name}/download", "/api/file/{vault_name}/pdf/stream"]:
op = schema["paths"][path]["get"]
content = op["responses"]["200"].get("content", {})
assert "application/json" not in content
def test_error_response_component(schema):
assert "ErrorResponse" in schema["components"]["schemas"]
# ---------------------------------------------------------------------------
# Interactive documentation endpoints
# ---------------------------------------------------------------------------
def test_api_landing_page(client):
resp = client.get("/api")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
assert "/docs" in body
assert "/redoc" in body
assert "/openapi.json" in body
def test_openapi_json_endpoint(client):
resp = client.get("/openapi.json")
assert resp.status_code == 200
data = resp.json()
assert data["openapi"] == "3.1.0"
assert data["info"]["title"] == "ObsiGate API"
def test_swagger_and_redoc_reachable(client):
assert client.get("/docs").status_code == 200
assert client.get("/redoc").status_code == 200
def test_schema_is_json_serialisable(schema):
# Guards against non-serialisable objects leaking into the schema.
json.dumps(schema)
# ---------------------------------------------------------------------------
# BooksLM context limits (used by the UI progress bar)
# ---------------------------------------------------------------------------
def test_bookslm_context_exposes_limits(test_vault_dir):
from backend.bookslm import collect_directory_context
from pathlib import Path
ctx = collect_directory_context(Path(test_vault_dir), "")
assert "max_total_chars" in ctx
assert "max_files" in ctx
assert ctx["max_total_chars"] > 0
+329
View File
@@ -0,0 +1,329 @@
"""Tests for PDF support in ObsiGate (ROADMAP #74).
Covers:
- backend/pdf_reader.py: text/metadata/TOC extraction with pypdf + pymupdf
- backend/main.py: api_pdf_stream endpoint, is_pdf detection in api_file_view
- backend/indexer.py: .pdf in SUPPORTED_EXTENSIONS, index_document handles PDFs
- backend/search.py: filter `ext:pdf` returns only PDFs (already implemented)
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from pathlib import Path
import pytest
# Skip the whole module if neither PDF library is available.
try:
import pypdf # noqa: F401
HAS_PDF_LIB = True
except ImportError:
try:
import fitz # noqa: F401 # pymupdf
HAS_PDF_LIB = True
except ImportError:
HAS_PDF_LIB = False
pytestmark = pytest.mark.skipif(
not HAS_PDF_LIB, reason="Neither pypdf nor pymupdf is installed"
)
# ── Fixtures: generate a real PDF on disk ──────────────────────────────────
def make_simple_pdf(path: Path, *, pages: int = 2, title: str = "", author: str = "") -> Path:
"""Create a PDF with `pages` pages, each page containing a unique sentence."""
try:
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
except ImportError:
pytest.skip("reportlab not available — cannot generate test PDF fixture")
c = canvas.Canvas(str(path), pagesize=letter)
if title:
c.setTitle(title)
if author:
c.setAuthor(author)
for i in range(pages):
c.drawString(72, 720, f"ObsiGate test PDF — page {i + 1} uniqueword{i}")
c.showPage()
c.save()
return path
@pytest.fixture
def pdf_dir(tmp_path: Path) -> Path:
"""A temp directory with a few PDFs of different shapes."""
d = tmp_path / "pdfs"
d.mkdir()
make_simple_pdf(d / "simple.pdf", pages=2, title="Simple Test", author="Bruno")
make_simple_pdf(d / "long.pdf", pages=3)
make_simple_pdf(d / "single.pdf", pages=1)
return d
# ── backend/pdf_reader.py ──────────────────────────────────────────────────
class TestPdfReader:
def test_extract_text_returns_text_with_keywords(self, pdf_dir: Path):
from backend.pdf_reader import extract_pdf_text
text = extract_pdf_text(pdf_dir / "simple.pdf")
assert "ObsiGate test PDF" in text
assert "uniqueword0" in text
assert "uniqueword1" in text
def test_extract_text_truncates_at_max_chars(self, pdf_dir: Path):
from backend.pdf_reader import extract_pdf_text
# tight max_chars truncates after the first page
text = extract_pdf_text(pdf_dir / "long.pdf", max_chars=10)
assert len(text) <= 50 # allow some slack; first page may have ~30 chars
def test_extract_text_missing_file_returns_empty(self, tmp_path: Path):
from backend.pdf_reader import extract_pdf_text
result = extract_pdf_text(tmp_path / "does-not-exist.pdf")
assert result == ""
def test_extract_text_corrupt_file_returns_empty(self, tmp_path: Path):
from backend.pdf_reader import extract_pdf_text
junk = tmp_path / "junk.pdf"
junk.write_bytes(b"not a real pdf, just some bytes %PDF-1.4 but no xref")
result = extract_pdf_text(junk)
# Should not raise; returns "" on failure
assert isinstance(result, str)
def test_extract_metadata_returns_pages_title_author(self, pdf_dir: Path):
from backend.pdf_reader import extract_pdf_metadata
info = extract_pdf_metadata(pdf_dir / "simple.pdf")
assert info["pages"] == 2
assert info["title"] in ("Simple Test", "") # metadata may be empty on some readers
assert isinstance(info["author"], str)
def test_extract_metadata_missing_file_returns_zeros(self, tmp_path: Path):
from backend.pdf_reader import extract_pdf_metadata
info = extract_pdf_metadata(tmp_path / "missing.pdf")
assert info == {"pages": 0, "title": "", "author": ""}
def test_extract_toc_returns_list(self, pdf_dir: Path):
from backend.pdf_reader import extract_pdf_toc
# simple PDFs (no outline) → empty list, no exception
toc = extract_pdf_toc(pdf_dir / "simple.pdf")
assert isinstance(toc, list)
class TestPdfIncrementalIndexing:
"""_index_single_file_sync (watcher/update path) must handle binary PDFs.
Regression: it used to read_text() every file, producing garbage for PDFs.
"""
def test_index_single_file_sync_extracts_pdf_text(self, pdf_dir: Path):
from backend.indexer import _index_single_file_sync
info = _index_single_file_sync("v", str(pdf_dir), str(pdf_dir / "simple.pdf"))
assert info is not None
assert info["extension"] == ".pdf"
assert "ObsiGate test PDF" in info["content"]
assert "uniqueword0" in info["content"]
def test_index_single_file_sync_pdf_title_from_metadata(self, pdf_dir: Path):
from backend.indexer import _index_single_file_sync
info = _index_single_file_sync("v", str(pdf_dir), str(pdf_dir / "simple.pdf"))
# title metadata was set at generation time
assert info["title"] == "Simple Test"
class TestPdfSizeLimit:
def test_oversized_pdf_skips_text_extraction(self, pdf_dir: Path, monkeypatch):
import backend.pdf_reader as pr
monkeypatch.setattr(pr, "PDF_MAX_SIZE_MB", 0) # everything is "too large"
text = pr.extract_pdf_text(pdf_dir / "simple.pdf")
assert text == ""
def test_normal_pdf_within_limit_extracts(self, pdf_dir: Path, monkeypatch):
import backend.pdf_reader as pr
monkeypatch.setattr(pr, "PDF_MAX_SIZE_MB", 50)
text = pr.extract_pdf_text(pdf_dir / "simple.pdf")
assert "ObsiGate test PDF" in text
# ── API: /pdf/stream (206 Range) + /pdf/info ───────────────────────────────
@pytest.fixture
def pdf_client():
"""TestClient (auth disabled) over a vault containing one generated PDF."""
tmp = Path(tempfile.mkdtemp())
vault = tmp / "PdfVault"
vault.mkdir()
make_simple_pdf(vault / "doc.pdf", pages=2, title="Doc Test", author="Bruno")
os.environ["VAULT_1_NAME"] = "PdfVault"
os.environ["VAULT_1_PATH"] = str(vault)
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.indexer import build_index, index
for key in list(index.keys()):
del index[key]
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from fastapi.testclient import TestClient
client = TestClient(backend.main.app)
yield client
client.close()
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED", "OBSIGATE_WATCHER_ENABLED"]:
os.environ.pop(k, None)
class TestPdfStreamApi:
def test_stream_returns_200_application_pdf(self, pdf_client):
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf")
assert r.status_code == 200
assert r.headers["content-type"] == "application/pdf"
assert r.headers.get("accept-ranges") == "bytes"
assert r.content[:4] == b"%PDF"
def test_stream_full_range_returns_whole_file(self, pdf_client):
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf",
headers={"Range": "bytes=0-99999999"})
assert r.status_code == 206
assert r.headers["content-range"].startswith("bytes 0-")
assert r.content[:4] == b"%PDF"
def test_stream_partial_range(self, pdf_client):
full = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf").content
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf",
headers={"Range": "bytes=10-19"})
assert r.status_code == 206
assert r.content == full[10:20]
assert len(r.content) == 10
def test_stream_open_ended_range(self, pdf_client):
full = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf").content
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf",
headers={"Range": "bytes=100-"})
assert r.status_code == 206
assert r.content == full[100:]
def test_stream_bad_range_returns_416(self, pdf_client):
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf",
headers={"Range": "bytes=999999999-"})
assert r.status_code == 416
assert "bytes */" in r.headers.get("content-range", "")
def test_stream_rejects_non_pdf(self, pdf_client):
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.md")
assert r.status_code == 404 or r.status_code == 400
class TestPdfInfoApi:
def test_info_returns_metadata_without_content(self, pdf_client):
r = pdf_client.get("/api/file/PdfVault/pdf/info?path=doc.pdf")
assert r.status_code == 200
body = r.json()
assert body["pages"] == 2
assert body["title"] in ("Doc Test", "doc.pdf")
assert body["size_bytes"] > 0
assert "path" in body and body["vault"] == "PdfVault"
# no heavy content in the payload
assert "html" not in body
def test_info_missing_file_404(self, pdf_client):
r = pdf_client.get("/api/file/PdfVault/pdf/info?path=nope.pdf")
assert r.status_code == 404
def test_info_missing_non_pdf_404(self, pdf_client):
r = pdf_client.get("/api/file/PdfVault/pdf/info?path=readme.txt")
assert r.status_code == 404 # missing file checked before extension
# ── backend/indexer.py ─────────────────────────────────────────────────────
class TestPdfIndexing:
def test_pdf_in_supported_extensions(self):
from backend.indexer import SUPPORTED_EXTENSIONS
assert ".pdf" in SUPPORTED_EXTENSIONS
def test_scan_vault_picks_up_pdf_files(self, pdf_dir: Path, tmp_path: Path):
"""When a vault directory is scanned with .pdf files, they appear in files list.
Uses the public _scan_vault() helper directly — no global state needed.
"""
from backend.indexer import _scan_vault
vault_root = tmp_path / "vault"
vault_root.mkdir()
shutil.copy2(pdf_dir / "simple.pdf", vault_root / "a.pdf")
shutil.copy2(pdf_dir / "single.pdf", vault_root / "b.pdf")
result = _scan_vault("test-vault", str(vault_root), {"name": "test-vault", "path": str(vault_root)})
names = {f["path"] for f in result["files"]}
assert "a.pdf" in names
assert "b.pdf" in names
# The PDF content should have been extracted.
a_file = next(f for f in result["files"] if f["path"] == "a.pdf")
assert "ObsiGate test PDF" in (a_file.get("content") or "")
assert "uniqueword0" in (a_file.get("content") or "")
# ── Search filter `ext:` ───────────────────────────────────────────────────
class TestExtFilter:
"""The `ext:` filter is parsed in backend/search.py and applied in the
search pipeline. These tests verify the parsing + filter logic in isolation
so we don't depend on the full index state.
"""
def test_parse_ext_token(self):
from backend.search import _parse_advanced_query
parsed = _parse_advanced_query("hello ext:pdf world")
assert parsed["ext"] == "pdf"
assert "hello" in parsed["terms"]
assert "world" in parsed["terms"]
def test_parse_ext_token_with_dot(self):
from backend.search import _parse_advanced_query
parsed = _parse_advanced_query("ext:.md")
assert parsed["ext"] == "md"
def test_parse_no_ext_token(self):
from backend.search import _parse_advanced_query
parsed = _parse_advanced_query("hello world")
# ext key is initialized to None and stays None if no ext: token.
assert parsed.get("ext") in (None, "")
def test_parse_ext_token_lowercased(self):
from backend.search import _parse_advanced_query
parsed = _parse_advanced_query("ext:PDF")
assert parsed["ext"] == "pdf"
+62
View File
@@ -0,0 +1,62 @@
# tests/test_pdf_stream.py — Regression tests for BUG-001 (PDF stream HTTP 500)
#
# BUG-001: opening a PDF whose filename contains accented characters
# (e.g. "Bière blonde envoyé par Desja.pdf") returned HTTP 500 because the raw
# Unicode name was injected into the Content-Disposition header, producing an
# invalid (non-ASCII) header value. /pdf/info worked because it sets no
# filename header.
#
# Fix: RFC 5987 header encoding (ASCII `filename` fallback + `filename*=UTF-8''…`).
import os
from pathlib import Path
# Minimal valid PDF so pypdf/stream treats the bytes as a real PDF.
MIN_PDF = (
b"%PDF-1.4\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
b"xref\n0 4\n0000000000 65535 f \n"
b"trailer<</Size 4/Root 1 0 R>>\nstartxref\n0\n%%EOF\n"
)
def _create_pdf_with_unicode_name():
vault = Path(os.environ["VAULT_1_PATH"])
sub = vault / "98_Boite_Outils" / "98.2_Attachments"
sub.mkdir(parents=True, exist_ok=True)
pdf = sub / "Bière blonde envoyé par Desja.pdf"
pdf.write_bytes(MIN_PDF)
return "98_Boite_Outils/98.2_Attachments/Bière blonde envoyé par Desja.pdf"
class TestPdfStreamUnicodeFilename:
def test_full_stream_ok(self, client):
rel = _create_pdf_with_unicode_name()
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": rel})
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "application/pdf"
cd = resp.headers.get("content-disposition", "")
# Content-Disposition must be RFC 5987 encoded (ASCII-safe)
assert "filename*=UTF-8''" in cd, cd
# UTF-8 percent-encoded accented name present (é -> %C3%A8)
assert "%C3%A8re%20blonde" in cd
def test_range_request_ok(self, client):
rel = _create_pdf_with_unicode_name()
resp = client.get(
"/api/file/TestVault/pdf/stream",
params={"path": rel},
headers={"Range": "bytes=0-99"},
)
assert resp.status_code == 206
assert resp.headers.get("content-range", "").startswith("bytes 0-99/")
def test_disposition_ascii_safe(self, client):
rel = _create_pdf_with_unicode_name()
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": rel})
cd = resp.headers.get("content-disposition", "")
# Every header field value must be ASCII-encodable (no UnicodeEncodeError)
cd.encode("ascii")
# ASCII fallback filename present
assert 'filename="' in cd
+461
View File
@@ -0,0 +1,461 @@
"""Tests for the ObsiGate plugin system (backend/plugins.py)."""
import json
import pytest
from backend.plugins import (
ALLOWED_HOOKS,
ALLOWED_PERMISSIONS,
MANIFEST_FILENAME,
MAX_PLUGINS_PER_VAULT,
PLUGINS_DIR_NAME,
PluginManager,
PluginManifest,
PluginRegistry,
_validate_plugin_directory,
_validate_plugin_zip,
)
# ═══════════════════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.fixture
def valid_manifest():
"""A valid plugin manifest dict."""
return {
"name": "test-plugin",
"version": "1.0.0",
"description": "A test plugin",
"author": "test-author",
"main": "index.js",
"hooks": {
"onFileRender": "renderFile",
"onSearchFilter": "filterSearch",
},
"permissions": ["read_files"],
"min_obsigate_version": "2.1.0",
"license": "MIT",
}
@pytest.fixture
def minimal_manifest():
"""Minimal valid manifest (only required fields)."""
return {
"name": "minimal",
"version": "0.1.0",
"description": "Minimal plugin",
"author": "author",
"main": "main.js",
}
@pytest.fixture
def temp_vault(tmp_path):
"""Create a temporary vault directory."""
vault = tmp_path / "test-vault"
vault.mkdir()
return vault
@pytest.fixture
def plugins_dir(temp_vault):
"""Create the plugins directory inside a temp vault."""
pd = temp_vault / PLUGINS_DIR_NAME
pd.mkdir()
return pd
@pytest.fixture
def installed_plugin(plugins_dir, valid_manifest):
"""A plugin already installed on disk."""
plugin_dir = plugins_dir / "test-plugin"
plugin_dir.mkdir()
(plugin_dir / MANIFEST_FILENAME).write_text(json.dumps(valid_manifest))
(plugin_dir / "index.js").write_text("export function renderFile(ctx) { return ctx; }")
return plugin_dir
# ═══════════════════════════════════════════════════════════════════════════════
# B5 — Manifest Validation
# ═══════════════════════════════════════════════════════════════════════════════
class TestManifestValidation:
"""Test PluginManifest validation logic."""
def test_valid_manifest_parses(self, valid_manifest):
m = PluginManifest.from_dict(valid_manifest)
assert m.name == "test-plugin"
assert m.version == "1.0.0"
assert m.main == "index.js"
assert "onFileRender" in m.hooks
assert "onSearchFilter" in m.hooks
assert m.permissions == ["read_files"]
def test_minimal_manifest(self, minimal_manifest):
m = PluginManifest.from_dict(minimal_manifest)
assert m.name == "minimal"
assert m.hooks == {}
assert m.permissions == []
def test_missing_required_field(self):
data = {"name": "x", "version": "1.0.0", "description": "d", "author": "a"}
with pytest.raises(ValueError, match="Missing required field: main"):
PluginManifest.from_dict(data)
def test_invalid_name_uppercase(self, valid_manifest):
valid_manifest["name"] = "MyPlugin"
with pytest.raises(ValueError, match="lowercase alphanumeric"):
PluginManifest.from_dict(valid_manifest)
def test_invalid_name_special_chars(self, valid_manifest):
valid_manifest["name"] = "my_plugin!"
with pytest.raises(ValueError, match="lowercase alphanumeric"):
PluginManifest.from_dict(valid_manifest)
def test_invalid_name_single_hyphen(self, valid_manifest):
valid_manifest["name"] = "-bad-"
with pytest.raises(ValueError, match="lowercase alphanumeric"):
PluginManifest.from_dict(valid_manifest)
def test_valid_single_char_name(self):
d = {"name": "a", "version": "1.0.0", "description": "d", "author": "a", "main": "a.js"}
m = PluginManifest.from_dict(d)
assert m.name == "a"
def test_invalid_version_no_patch(self, valid_manifest):
valid_manifest["version"] = "1.0"
with pytest.raises(ValueError, match="semantic version"):
PluginManifest.from_dict(valid_manifest)
def test_invalid_version_letters(self, valid_manifest):
valid_manifest["version"] = "1.0.0-beta.1"
# Should work — pre-release tags are allowed
m = PluginManifest.from_dict(valid_manifest)
assert m.version == "1.0.0-beta.1"
def test_invalid_version_not_semver(self, valid_manifest):
valid_manifest["version"] = "v1.0.0"
with pytest.raises(ValueError, match="semantic version"):
PluginManifest.from_dict(valid_manifest)
def test_unknown_hook(self, valid_manifest):
valid_manifest["hooks"]["onMagic"] = "handleMagic"
with pytest.raises(ValueError, match="Unknown hook"):
PluginManifest.from_dict(valid_manifest)
def test_unknown_permission(self, valid_manifest):
valid_manifest["permissions"] = ["evil_access"]
with pytest.raises(ValueError, match="Unknown permission"):
PluginManifest.from_dict(valid_manifest)
def test_all_allowed_hooks(self):
hooks = {h: f"handle_{h}" for h in ALLOWED_HOOKS}
d = {"name": "full-hooks", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js", "hooks": hooks}
m = PluginManifest.from_dict(d)
assert len(m.hooks) == len(ALLOWED_HOOKS)
def test_all_allowed_permissions(self):
d = {"name": "full-perms", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js", "permissions": list(ALLOWED_PERMISSIONS)}
m = PluginManifest.from_dict(d)
assert len(m.permissions) == len(ALLOWED_PERMISSIONS)
def test_empty_name_rejected(self):
d = {"name": "", "version": "1.0.0", "description": "d", "author": "a", "main": "x.js"}
with pytest.raises(ValueError, match="Missing required field: name"):
PluginManifest.from_dict(d)
def test_empty_version_rejected(self):
d = {"name": "ok", "version": "", "description": "d", "author": "a", "main": "x.js"}
with pytest.raises(ValueError, match="Missing required field: version"):
PluginManifest.from_dict(d)
# ═══════════════════════════════════════════════════════════════════════════════
# B6 — Plugin Manager (install / uninstall / enable / disable)
# ═══════════════════════════════════════════════════════════════════════════════
class TestPluginManager:
"""Test PluginManager installation and lifecycle."""
def test_install_plugin_from_dict(self, plugins_dir, valid_manifest):
manager = PluginManager(plugins_dir)
result = manager.install(valid_manifest, "export function renderFile() {}")
assert result["name"] == "test-plugin"
assert result["version"] == "1.0.0"
assert result["enabled"] is True
assert (plugins_dir / "test-plugin" / MANIFEST_FILENAME).exists()
assert (plugins_dir / "test-plugin" / "index.js").exists()
def test_install_duplicate_rejected(self, plugins_dir, valid_manifest):
manager = PluginManager(plugins_dir)
manager.install(valid_manifest, "code")
with pytest.raises(ValueError, match="already installed"):
manager.install(valid_manifest, "code2")
def test_uninstall_plugin(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
manager.uninstall("test-plugin")
assert not (plugins_dir / "test-plugin").exists()
def test_uninstall_nonexistent(self, plugins_dir):
manager = PluginManager(plugins_dir)
with pytest.raises(ValueError, match="not found"):
manager.uninstall("no-such-plugin")
def test_enable_disable(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
manager.disable("test-plugin")
assert manager.is_disabled("test-plugin")
manager.enable("test-plugin")
assert not manager.is_disabled("test-plugin")
def test_list_plugins(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
plugins = manager.list_plugins()
assert len(plugins) == 1
assert plugins[0]["name"] == "test-plugin"
assert plugins[0]["enabled"] is True
def test_get_plugin(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
plugin = manager.get_plugin("test-plugin")
assert plugin is not None
assert plugin["manifest"]["name"] == "test-plugin"
def test_get_nonexistent(self, plugins_dir):
manager = PluginManager(plugins_dir)
assert manager.get_plugin("nope") is None
def test_max_plugins_limit(self, plugins_dir):
manager = PluginManager(plugins_dir)
for i in range(MAX_PLUGINS_PER_VAULT):
m = {"name": f"plug-{i}", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js"}
manager.install(m, "code")
# Next one should fail
m = {"name": "plug-extra", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js"}
with pytest.raises(ValueError, match="Maximum"):
manager.install(m, "code")
def test_disabled_marker_file(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
manager.disable("test-plugin")
marker = plugins_dir / "test-plugin" / ".disabled"
assert marker.exists()
manager.enable("test-plugin")
assert not marker.exists()
# ═══════════════════════════════════════════════════════════════════════════════
# B7 — Plugin Registry (enabled plugins by vault)
# ═══════════════════════════════════════════════════════════════════════════════
class TestPluginRegistry:
"""Test PluginRegistry aggregation across vaults."""
def test_get_enabled_plugins(self, plugins_dir, installed_plugin):
registry = PluginRegistry()
plugins = registry.get_enabled_plugins(plugins_dir)
assert len(plugins) == 1
assert plugins[0]["name"] == "test-plugin"
def test_disabled_plugin_excluded(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
manager.disable("test-plugin")
registry = PluginRegistry()
plugins = registry.get_enabled_plugins(plugins_dir)
assert len(plugins) == 0
def test_get_plugins_by_hook(self, plugins_dir, installed_plugin):
registry = PluginRegistry()
plugins = registry.get_plugins_by_hook(plugins_dir, "onFileRender")
assert len(plugins) == 1
def test_get_plugins_by_wrong_hook(self, plugins_dir, installed_plugin):
registry = PluginRegistry()
plugins = registry.get_plugins_by_hook(plugins_dir, "onVaultMount")
assert len(plugins) == 0
# ═══════════════════════════════════════════════════════════════════════════════
# C4 — ZIP Validation
# ═══════════════════════════════════════════════════════════════════════════════
class TestZipValidation:
"""Test ZIP upload validation (C4)."""
def test_valid_zip(self, tmp_path):
import zipfile
zip_path = tmp_path / "good-plugin.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", json.dumps({
"name": "zip-plugin", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
zf.writestr("index.js", "export default {}")
result = _validate_plugin_zip(zip_path)
assert result["name"] == "zip-plugin"
def test_missing_manifest(self, tmp_path):
import zipfile
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("index.js", "code")
with pytest.raises(ValueError, match="plugin.json"):
_validate_plugin_zip(zip_path)
def test_missing_entry_point(self, tmp_path):
import zipfile
zip_path = tmp_path / "bad2.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", json.dumps({
"name": "x", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
with pytest.raises(ValueError, match="index.js"):
_validate_plugin_zip(zip_path)
def test_path_traversal_rejected(self, tmp_path):
import zipfile
zip_path = tmp_path / "traversal.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", json.dumps({
"name": "x", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
zf.writestr("index.js", "ok")
zf.writestr("../../../etc/passwd", "evil")
with pytest.raises(ValueError, match=r"(?i)path traversal"):
_validate_plugin_zip(zip_path)
def test_too_many_files_rejected(self, tmp_path):
import zipfile
zip_path = tmp_path / "toomany.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", json.dumps({
"name": "x", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
zf.writestr("index.js", "ok")
for i in range(101):
zf.writestr(f"file{i}.js", f"// {i}")
with pytest.raises(ValueError, match="too many files"):
_validate_plugin_zip(zip_path)
def test_invalid_json_rejected(self, tmp_path):
import zipfile
zip_path = tmp_path / "badjson.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("plugin.json", "NOT JSON {{{")
zf.writestr("index.js", "ok")
with pytest.raises(ValueError, match="Invalid JSON"):
_validate_plugin_zip(zip_path)
# ═══════════════════════════════════════════════════════════════════════════════
# C5 — Directory Validation
# ═══════════════════════════════════════════════════════════════════════════════
class TestDirectoryValidation:
"""Test directory-based plugin validation (C5)."""
def test_valid_directory(self, tmp_path):
plugin_dir = tmp_path / "good"
plugin_dir.mkdir()
(plugin_dir / MANIFEST_FILENAME).write_text(json.dumps({
"name": "dir-plugin", "version": "1.0.0",
"description": "d", "author": "a", "main": "index.js",
}))
(plugin_dir / "index.js").write_text("export default {}")
result = _validate_plugin_directory(plugin_dir)
assert result["name"] == "dir-plugin"
def test_missing_manifest_file(self, tmp_path):
plugin_dir = tmp_path / "no-manifest"
plugin_dir.mkdir()
(plugin_dir / "index.js").write_text("code")
with pytest.raises(ValueError, match="plugin.json"):
_validate_plugin_directory(plugin_dir)
def test_missing_entry_point(self, tmp_path):
plugin_dir = tmp_path / "no-entry"
plugin_dir.mkdir()
(plugin_dir / MANIFEST_FILENAME).write_text(json.dumps({
"name": "x", "version": "1.0.0",
"description": "d", "author": "a", "main": "missing.js",
}))
with pytest.raises(ValueError, match="missing.js"):
_validate_plugin_directory(plugin_dir)
# ═══════════════════════════════════════════════════════════════════════════════
# F1 — Plugin Loading / Execution (Web Worker sandbox API)
# ═══════════════════════════════════════════════════════════════════════════════
class TestPluginLoading:
"""Test loading plugin code for sandbox execution."""
def test_get_plugin_code(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
code = manager.get_plugin_code("test-plugin", "index.js")
assert "renderFile" in code
def test_get_plugin_code_nonexistent(self, plugins_dir):
manager = PluginManager(plugins_dir)
with pytest.raises(ValueError, match="not found"):
manager.get_plugin_code("nope", "index.js")
def test_get_plugin_code_nonexistent_file(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
with pytest.raises(ValueError, match="not found"):
manager.get_plugin_code("test-plugin", "missing.js")
def test_get_hooks_for_plugin(self, plugins_dir, installed_plugin):
manager = PluginManager(plugins_dir)
hooks = manager.get_hooks("test-plugin")
assert "onFileRender" in hooks
assert hooks["onFileRender"] == "renderFile"
def test_install_plugin_validates_manifest_on_install(self, plugins_dir):
manager = PluginManager(plugins_dir)
bad = {"name": "UPPERCASE", "version": "1.0.0", "description": "d",
"author": "a", "main": "x.js"}
with pytest.raises(ValueError, match="lowercase"):
manager.install(bad, "code")
class TestPluginsAPI:
"""Exercises the mounted /api/plugins/* router via TestClient (auth off)."""
def test_plugin_list_empty(self, client):
resp = client.get("/api/plugins?vault=TestVault")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
def test_plugin_template_returns_code(self, client):
resp = client.get("/api/plugins/template")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
assert "code" in data
assert "manifest" in data
assert data["manifest"]["name"] == "my-plugin"
def test_plugin_hooks_endpoint(self, client):
# /api/plugins/hooks doesn't exist; test a valid endpoint shape
# /api/plugins/{name}/hooks requires a plugin name - test 404
resp = client.get("/api/plugins/nonexistent/hooks?vault=TestVault")
assert resp.status_code == 404
+190
View File
@@ -0,0 +1,190 @@
"""Tests for the Push Notifications endpoints (ROADMAP #67).
Covers:
- GET /api/push/vapid-public-key
- POST /api/push/subscribe (auth + vault permission)
- DELETE /api/push/subscribe
- GET /api/push/subscriptions
- send_push_notification() internals
"""
import json
import os
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _reset_push_state():
"""Reset backend.push module globals between tests to avoid cross-test pollution."""
import backend.push as push
push._push_subscriptions = []
push._vapid_keys = {}
# Remove any persisted subscription/vapid files from previous tests
for f in (push.PUSH_SUBSCRIPTIONS_FILE, push.VAPID_KEYS_FILE):
try:
if f.exists():
f.unlink()
except OSError:
pass
yield
push._push_subscriptions = []
push._vapid_keys = {}
def _login(client, username="admin", password="chab30"):
resp = client.post(
"/api/auth/login",
json={"username": username, "password": password},
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
def _bearer(token):
return {"Authorization": f"Bearer {token}"}
# ═════════════════════════════════════════════════════════════════════
# /api/push/vapid-public-key
# ═════════════════════════════════════════════════════════════════════
class TestVapidPublicKey:
def test_returns_public_key(self, admin_client):
resp = admin_client.get("/api/push/vapid-public-key")
assert resp.status_code == 200
data = resp.json()
assert "public_key" in data
assert data["public_key"] # non-empty
def test_no_auth_required(self, admin_client):
# Public endpoint should work without a bearer token
resp = admin_client.get("/api/push/vapid-public-key")
assert resp.status_code == 200
assert "public_key" in resp.json()
# ═════════════════════════════════════════════════════════════════════
# /api/push/subscribe
# ═════════════════════════════════════════════════════════════════════
class TestSubscribe:
VALID_SUB = {
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/test123",
"keys": {
"p256dh": "BGO6V2xE5U_bL5vZcGpZQpWRJ1Oea9QwkrfGzBVAqBiY",
"auth": "cHVsU2hpbmVzQXV0aEtleQ",
},
},
"vault": "*",
}
def test_subscribe_ok(self, admin_client):
token = _login(admin_client)
resp = admin_client.post(
"/api/push/subscribe",
json=self.VALID_SUB,
headers=_bearer(token),
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["subscription_id"]
def test_subscribe_requires_auth(self, admin_client):
resp = admin_client.post("/api/push/subscribe", json=self.VALID_SUB)
assert resp.status_code in (401, 403)
def test_subscribe_idempotent_same_endpoint(self, admin_client):
token = _login(admin_client)
resp1 = admin_client.post(
"/api/push/subscribe", json=self.VALID_SUB, headers=_bearer(token)
)
resp2 = admin_client.post(
"/api/push/subscribe", json=self.VALID_SUB, headers=_bearer(token)
)
assert resp1.json()["subscription_id"] == resp2.json()["subscription_id"]
def test_subscribe_no_vault_access(self, admin_client):
token = _login(admin_client, username="normaluser", password="normal123")
sub = {
"subscription": {
"endpoint": "https://fcm.google.com/fcm/send/nope",
"keys": {"p256dh": "abcd", "auth": "efgh"},
},
"vault": "OtherVault",
}
resp = admin_client.post(
"/api/push/subscribe", json=sub, headers=_bearer(token)
)
assert resp.status_code == 403
# ═════════════════════════════════════════════════════════════════════
# /api/push/subscriptions (list)
# ═════════════════════════════════════════════════════════════════════
class TestListSubscriptions:
def test_list_empty_first(self, admin_client):
token = _login(admin_client)
resp = admin_client.get("/api/push/subscriptions", headers=_bearer(token))
assert resp.status_code == 200
assert resp.json()["subscriptions"] == []
def test_list_after_subscribe(self, admin_client):
token = _login(admin_client)
sub = {
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/listme",
"keys": {"p256dh": "abcd", "auth": "efgh"},
},
"vault": "*",
}
admin_client.post(
"/api/push/subscribe", json=sub, headers=_bearer(token)
)
resp = admin_client.get("/api/push/subscriptions", headers=_bearer(token))
assert resp.status_code == 200
data = resp.json()
assert len(data["subscriptions"]) == 1
assert data["subscriptions"][0]["vault"] == "*"
# endpoint truncated for privacy
assert data["subscriptions"][0]["endpoint"].endswith("...")
# ═════════════════════════════════════════════════════════════════════
# DELETE /api/push/subscribe
# ═════════════════════════════════════════════════════════════════════
class TestUnsubscribe:
def test_unsubscribe_ok(self, admin_client):
token = _login(admin_client)
sub = {
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/bye",
"keys": {"p256dh": "abcd", "auth": "efgh"},
},
"vault": "*",
}
admin_client.post("/api/push/subscribe", json=sub, headers=_bearer(token))
resp = admin_client.delete(
"/api/push/subscribe",
params={"endpoint": sub["subscription"]["endpoint"]},
headers=_bearer(token),
)
assert resp.status_code == 200
assert resp.json()["success"] is True
# Now list should be empty
resp = admin_client.get("/api/push/subscriptions", headers=_bearer(token))
assert resp.json()["subscriptions"] == []
def test_unsubscribe_unknown(self, admin_client):
token = _login(admin_client)
resp = admin_client.delete(
"/api/push/subscribe",
params={"endpoint": "https://fcm.google/fcm/send/unknown"},
headers=_bearer(token),
)
assert resp.status_code == 200
assert resp.json()["success"] is False
+208
View File
@@ -0,0 +1,208 @@
"""Tests for the Custom Themes feature (#65).
Validates:
- Theme JSON structure validity
- All 15 built-in themes have required keys and valid CSS colors
- High-contrast and sepia mode generators produce valid CSS vars
- Import/export round-trip
- AVAILABLE_MODES includes all 4 modes
"""
import json
import re
import pytest
# ── CSS color validation ─────────────────────────────────────────────
_CSS_COLOR_RE = re.compile(
r"^("
r"#[0-9a-fA-F]{3,8}" # hex
r"|rgba?\([^)]+\)" # rgb/rgba
r"|hsla?\([^)]+\)" # hsl/hsla
r"|[a-zA-Z]+" # named color
r")$"
)
REQUIRED_VARS = [
'--bg-primary', '--bg-secondary', '--bg-sidebar', '--bg-hover', '--border',
'--text-primary', '--text-secondary', '--text-muted', '--accent',
'--accent-green', '--tag-bg', '--tag-text', '--code-bg', '--search-bg',
'--scrollbar', '--resize-handle', '--overlay-bg',
'--danger', '--danger-bg', '--success', '--success-bg',
'--accent-card', '--accent-bg', '--accent-text',
'--green', '--green-bg', '--purple', '--purple-bg',
'--surface', '--surface2', '--surface3',
'--border-md', '--text', '--text-2', '--text-3',
'--ok', '--warn', '--ai', '--err',
]
AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']
# ── Theme data extracted from themes.js ──────────────────────────────
# We parse the JS file to extract theme definitions for validation.
def _parse_themes_from_js():
"""Read themes.js and build a dict of theme definitions using regex."""
import os
js_path = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'js', 'themes.js')
with open(js_path, encoding='utf-8') as f:
content = f.read()
return content
def _extract_theme_names(content):
"""Extract top-level theme keys from THEMES dict."""
return re.findall(r"'([a-z0-9-]+)':\s*\{", content.split('var THEMES')[1].split('// ── Theme Engine')[0])
def _count_css_vars_in_block(content, keyword, section_start):
"""Count CSS variable assignments starting from a position."""
return len(re.findall(r"'--[a-z]", content[section_start:section_start + 2000]))
class TestThemeStructure:
"""Validate theme data from themes.js."""
@pytest.fixture(autouse=True)
def load_js(self):
self.content = _parse_themes_from_js()
def test_themes_file_exists(self):
"""themes.js should exist and be readable."""
assert len(self.content) > 1000
def test_has_available_modes(self):
"""AVAILABLE_MODES should define all 4 modes."""
assert "AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']" in self.content
def test_has_mode_generators(self):
"""Mode generators for high-contrast and sepia should exist."""
assert '_genHighContrast' in self.content
assert '_genSepia' in self.content
def test_theme_count(self):
"""Should have 15 built-in themes."""
names = _extract_theme_names(self.content)
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}"
def test_required_export_functions(self):
"""All required functions should be exported."""
required_exports = [
'THEMES', 'AVAILABLE_MODES',
'applyTheme', 'toggleThemeMode',
'getCurrentTheme', 'getCurrentMode',
'setTheme', 'listThemes', 'listModes',
'exportTheme', 'exportAllThemes', 'importTheme',
'deleteCustomTheme', 'loadCustomThemes',
'initThemes',
]
export_block = self.content.split('export {')[1].split('}')[0]
for fn in required_exports:
assert fn in export_block, f"Missing export: {fn}"
def test_high_contrast_vars_count(self):
"""High-contrast generator should define 30+ CSS vars."""
hc_start = self.content.index('_genHighContrast')
hc_section = self.content[hc_start:hc_start + 2000]
var_count = len(re.findall(r"'--[a-z]", hc_section))
assert var_count >= 30, f"High-contrast defines {var_count} vars, expected >= 30"
def test_sepia_vars_count(self):
"""Sepia generator should define 30+ CSS vars."""
sepia_start = self.content.index('_genSepia')
sepia_section = self.content[sepia_start:sepia_start + 2000]
var_count = len(re.findall(r"'--[a-z]", sepia_section))
assert var_count >= 30, f"Sepia defines {var_count} vars, expected >= 30"
def test_high_contrast_colors_valid(self):
"""High-contrast mode should use valid CSS colors."""
hc_start = self.content.index('_genHighContrast')
hc_section = self.content[hc_start:hc_start + 2000]
colors = re.findall(r":\s*'([^']+)'", hc_section)
for color in colors:
assert _CSS_COLOR_RE.match(color), f"Invalid CSS color in high-contrast: {color}"
def test_sepia_colors_valid(self):
"""Sepia mode should use valid CSS colors."""
sepia_start = self.content.index('_genSepia')
sepia_section = self.content[sepia_start:sepia_start + 2000]
colors = re.findall(r":\s*'([^']+)'", sepia_section)
for color in colors:
assert _CSS_COLOR_RE.match(color), f"Invalid CSS color in sepia: {color}"
def test_getvars_handles_all_modes(self):
"""_getVars should handle dark, light, high-contrast, and sepia."""
assert "mode === 'high-contrast'" in self.content
assert "mode === 'sepia'" in self.content
def test_import_theme_validates_input(self):
"""importTheme should validate JSON structure."""
assert "missing modes" in self.content
assert "at least dark or light" in self.content
def test_localstorage_keys(self):
"""Custom themes should use proper localStorage key prefix."""
assert "obsigate-custom-" in self.content
assert "obsigate-theme" in self.content
assert "obsigate-theme-mode" in self.content
def test_each_theme_has_dark_and_light(self):
"""Each built-in theme should define dark and light modes."""
names = _extract_theme_names(self.content)
for name in names:
pattern = re.compile(
rf"'{re.escape(name)}':\s*\{{.*?modes:\s*\{{.*?dark:\s*\{{",
re.DOTALL
)
assert pattern.search(self.content), f"Theme '{name}' missing dark mode"
def test_toggle_cycles_all_modes(self):
"""toggleThemeMode should cycle through all 4 modes."""
assert '% AVAILABLE_MODES.length' in self.content
class TestI18nKeys:
"""Validate that all theme i18n keys exist in both locales."""
@pytest.fixture(autouse=True)
def load_locales(self):
import os
base = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'locales')
with open(os.path.join(base, 'en.json'), encoding='utf-8') as f:
self.en = json.load(f)
with open(os.path.join(base, 'fr.json'), encoding='utf-8') as f:
self.fr = json.load(f)
def test_theme_keys_en(self):
"""English locale should have all theme keys."""
required = [
'theme.dark', 'theme.light', 'theme.high_contrast', 'theme.sepia',
'theme.title', 'theme.change',
'theme.export_all', 'theme.import',
'theme.import_success', 'theme.import_error',
'theme.delete_confirm',
]
for key in required:
assert key in self.en, f"Missing EN key: {key}"
def test_theme_keys_fr(self):
"""French locale should have all theme keys."""
required = [
'theme.dark', 'theme.light', 'theme.high_contrast', 'theme.sepia',
'theme.title', 'theme.change',
'theme.export_all', 'theme.import',
'theme.import_success', 'theme.import_error',
'theme.delete_confirm',
]
for key in required:
assert key in self.fr, f"Missing FR key: {key}"
def test_import_success_has_placeholder(self):
"""import_success key should accept a {count} parameter."""
assert '{count}' in self.en['theme.import_success']
assert '{count}' in self.fr['theme.import_success']
def test_key_parity(self):
"""EN and FR should have the same set of theme.* keys."""
en_theme = {k for k in self.en if k.startswith('theme.')}
fr_theme = {k for k in self.fr if k.startswith('theme.')}
assert en_theme == fr_theme, f"Key mismatch: EN-only={en_theme - fr_theme}, FR-only={fr_theme - en_theme}"
+74
View File
@@ -74,6 +74,80 @@ class TestIsRelevant:
assert handler._is_relevant("/vault/Makefile") is True
# ═══════════════════════════════════════════════════════════════════
# Network-mount detection (NFS/SMB → polling watcher required)
# ═══════════════════════════════════════════════════════════════════
class TestNetworkMountDetection:
MOUNTS = [
"overlay / overlay rw 0 0",
"server:/export/NFS/OBSIDIAN_DOC /vaults/Obsidian_IT nfs4 rw 0 0",
"server:/export/home/bruno /vaults/bruno nfs4 rw 0 0",
"dev/sda1 /data ext4 rw 0 0",
"srv:/share /mnt/smb cifs rw 0 0",
]
def test_nfs_mount_detected(self):
from backend.watcher import find_mount_fstype
ft = find_mount_fstype("/vaults/Obsidian_IT/note.md", self.MOUNTS)
assert ft == "nfs4"
def test_nested_nfs_mount(self):
from backend.watcher import find_mount_fstype
ft = find_mount_fstype("/vaults/bruno/sub/dir", self.MOUNTS)
assert ft == "nfs4"
def test_smb_mount_detected(self):
from backend.watcher import find_mount_fstype
ft = find_mount_fstype("/mnt/smb/file.md", self.MOUNTS)
assert ft == "cifs"
def test_local_fs_not_network(self):
from backend.watcher import find_mount_fstype
ft = find_mount_fstype("/data/file.md", self.MOUNTS)
assert ft == "ext4"
def test_unmatched_path_returns_empty(self):
from backend.watcher import find_mount_fstype
assert find_mount_fstype("/nowhere/file.md", self.MOUNTS) == ""
def test_longest_prefix_wins(self):
from backend.watcher import find_mount_fstype
lines = self.MOUNTS + ["tmpfs /vaults/Obsidian_IT/tmp tmpfs rw 0 0"]
assert find_mount_fstype("/vaults/Obsidian_IT/tmp/x", lines) == "tmpfs"
assert find_mount_fstype("/vaults/Obsidian_IT/other", lines) == "nfs4"
def test_escaped_space_in_mount_point(self):
from backend.watcher import find_mount_fstype
lines = ["srv:/x /vaults/My\\040Vault nfs rw 0 0"]
assert find_mount_fstype("/vaults/My Vault/a.md", lines) == "nfs"
def test_is_network_mount_false_on_windows(self, monkeypatch):
import backend.watcher as w
monkeypatch.setattr(w.os, "name", "nt")
assert w.is_network_mount("/vaults/x") is False
def test_watch_vault_uses_polling_for_network_mount(self, monkeypatch):
"""_watch_vault must pick PollingObserver when is_network_mount is True."""
import asyncio
from unittest.mock import MagicMock
import backend.watcher as w
watcher = w.VaultWatcher(on_file_change=MagicMock())
monkeypatch.setattr(w, "is_network_mount", lambda p: True)
mock_poll = MagicMock()
monkeypatch.setattr(w, "PollingObserver", mock_poll)
with tempfile.TemporaryDirectory() as tmpdir:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(
watcher._watch_vault("NetVault", tmpdir, loop)
)
mock_poll.assert_called_once()
assert "NetVault" in watcher.observers
# ═══════════════════════════════════════════════════════════════════
# VaultWatcher (unit tests with mocks)
# ═══════════════════════════════════════════════════════════════════
+339
View File
@@ -0,0 +1,339 @@
# tests/test_webauthn.py
# WebAuthn MFA tests (ROADMAP #64).
# Uses a virtual authenticator (EC P-256, packed-free 'none' attestation, raw
# CBOR via cbor2) to exercise the real verification path end-to-end.
from __future__ import annotations
import hashlib
import json
import os
import shutil
import struct
import tempfile
from pathlib import Path
import cbor2
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from webauthn.helpers import bytes_to_base64url
def _b64url(data: bytes) -> str:
return bytes_to_base64url(data)
class VirtualAuthenticator:
"""Minimal WebAuthn authenticator: generates a P-256 key, produces
'none'-attestation registration responses and ES256 assertion responses."""
RP_ID = "localhost"
ORIGIN = "http://localhost"
def __init__(self):
self.key = ec.generate_private_key(ec.SECP256R1())
self.credential_id = os.urandom(32)
self.sign_count = 0
# ── COSE public key (ES256) ──
def _cose_key(self) -> bytes:
pub = self.key.public_key().public_numbers()
x = pub.x.to_bytes(32, "big")
y = pub.y.to_bytes(32, "big")
return cbor2.dumps({1: 2, 3: -7, -1: 1, -2: x, -3: y}, canonical=True)
def _rp_id_hash(self) -> bytes:
return hashlib.sha256(self.RP_ID.encode()).digest()
def _client_data(self, typ: str, challenge_b64: str) -> bytes:
return json.dumps({
"type": typ,
"challenge": challenge_b64,
"origin": self.ORIGIN,
"crossOrigin": False,
}).encode()
def make_registration(self, options: dict) -> dict:
challenge = options["challenge"]
auth_data = bytearray(self._rp_id_hash())
auth_data += bytes([0x41]) # UP + AT
auth_data += struct.pack(">I", 0)
aaguid = b"\x00" * 16
auth_data += aaguid
auth_data += struct.pack(">H", len(self.credential_id))
auth_data += self.credential_id
auth_data += self._cose_key()
attestation_object = cbor2.dumps(
{"fmt": "none", "attStmt": {}, "authData": bytes(auth_data)},
canonical=True,
)
client_data = self._client_data("webauthn.create", challenge)
return {
"id": _b64url(self.credential_id),
"rawId": _b64url(self.credential_id),
"type": "public-key",
"response": {
"clientDataJSON": _b64url(client_data),
"attestationObject": _b64url(attestation_object),
},
}
def make_assertion(self, options: dict) -> dict:
challenge = options["challenge"]
auth_data = bytearray(self._rp_id_hash())
auth_data += bytes([0x01]) # UP
self.sign_count += 1
auth_data += struct.pack(">I", self.sign_count)
client_data = self._client_data("webauthn.get", challenge)
signed = bytes(auth_data) + hashlib.sha256(client_data).digest()
# WebAuthn spec: ECDSA signatures are ASN.1 DER (not raw r||s like U2F)
der_sig = self.key.sign(signed, ec.ECDSA(hashes.SHA256()))
return {
"id": _b64url(self.credential_id),
"rawId": _b64url(self.credential_id),
"type": "public-key",
"response": {
"clientDataJSON": _b64url(client_data),
"authenticatorData": _b64url(bytes(auth_data)),
"signature": _b64url(der_sig),
"userHandle": "",
},
}
from cryptography.hazmat.primitives import hashes # noqa: E402 (used above)
# ── Unit tests: webauthn_mfa module ──────────────────────────────────
class TestWebauthnModule:
def test_rp_config_defaults(self, monkeypatch):
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
assert w.rp_id() == "localhost"
monkeypatch.setenv("OBSIGATE_WEBAUTHN_RP_ID", "obs.example.com")
assert w.rp_id() == "obs.example.com"
def test_challenge_is_single_use(self):
import backend.auth.webauthn_mfa as w
w._pending.clear()
ch = w._store_challenge("u1:register")
assert isinstance(ch, bytes) and len(ch) == 32
assert w._take_challenge("u1:register") == ch
assert w._take_challenge("u1:register") is None # popped
def test_take_challenge_expired(self):
import time as _t
import backend.auth.webauthn_mfa as w
w._pending.clear()
w._store_challenge("u2:register")
key = "u2:register"
ch, _ = w._pending[key]
w._pending[key] = (ch, _t.time() - 1)
assert w._take_challenge(key) is None
def test_full_registration_and_authentication_roundtrip(self):
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
)
import backend.auth.webauthn_mfa as w
w._pending.clear()
auth = VirtualAuthenticator()
reg_opts = generate_registration_options(
rp_id="localhost", rp_name="ObsiGate",
user_name="alice", user_id=b"1", user_display_name="Alice",
challenge=w._store_challenge("alice:register"),
)
cred = auth.make_registration(json.loads(options_to_json(reg_opts)))
verified = w.complete_registration("alice", cred)
assert verified["credential_id"] == cred["id"]
assert verified["public_key"]
auth_opts = generate_authentication_options(
rp_id="localhost",
challenge=w._store_challenge("alice:login"),
)
assertion = auth.make_assertion(json.loads(options_to_json(auth_opts)))
new_count = w.complete_authentication(
"alice", assertion,
{"public_key": verified["public_key"], "sign_count": 0})
assert new_count == 1
# ── Integration: API endpoints ───────────────────────────────────────
@pytest.fixture
def wa_client(monkeypatch):
"""Auth-enabled client with a user, WebAuthn RP configured for localhost."""
tmp = Path(tempfile.mkdtemp())
data_dir = tmp / "data"
data_dir.mkdir()
from backend.auth.password import hash_password
users = {"version": 1, "users": {"testuser": {
"id": "t-1", "username": "testuser", "display_name": "Test",
"password_hash": hash_password("TestPass123!"), "role": "admin",
"vaults": ["*"], "active": True,
}}}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
monkeypatch.setattr("backend.auth.user_store.USERS_FILE", data_dir / "users.json")
monkeypatch.setenv("OBSIGATE_WEBAUTHN_RP_ID", "localhost")
monkeypatch.setenv("OBSIGATE_WEBAUTHN_ORIGINS", "http://localhost")
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = os.path.abspath("test-vault")
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from fastapi.testclient import TestClient
client = TestClient(backend.main.app)
yield client
client.close()
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_WATCHER_ENABLED"]:
os.environ.pop(k, None)
def _login_headers(client):
r = client.post("/api/auth/login",
json={"username": "testuser", "password": "TestPass123!"})
token = r.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
class TestWebauthnApi:
def test_register_requires_auth(self, wa_client):
r = wa_client.post("/api/auth/mfa/webauthn/register/options")
assert r.status_code == 401
def test_registration_flow_enables_mfa(self, wa_client):
headers = _login_headers(wa_client)
r = wa_client.post("/api/auth/mfa/webauthn/register/options", headers=headers)
assert r.status_code == 200
options = r.json()["options"]
auth = VirtualAuthenticator()
cred = auth.make_registration(options)
r2 = wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
json={"credential": cred, "label": "YubiKey 5"})
assert r2.status_code == 200, r2.text
body = r2.json()
assert body["mfa_enabled"] is True
assert len(body["recovery_codes"]) == 8
assert body["credentials"][0]["label"] == "YubiKey 5"
# status reflects webauthn
r3 = wa_client.get("/api/auth/mfa/status", headers=headers)
st = r3.json()
assert st["mfa_enabled"] is True
assert st["webauthn_credentials"] == 1
assert st["totp_enabled"] is False
def test_login_with_webauthn_assertion(self, wa_client):
headers = _login_headers(wa_client)
options = wa_client.post("/api/auth/mfa/webauthn/register/options",
headers=headers).json()["options"]
auth = VirtualAuthenticator()
cred = auth.make_registration(options)
wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
json={"credential": cred, "label": "Key"})
# Fresh login → MFA required via webauthn
r = wa_client.post("/api/auth/login",
json={"username": "testuser", "password": "TestPass123!"})
body = r.json()
assert body["mfa_required"] is True
assert body["mfa_method"] == "webauthn"
opts_r = wa_client.post("/api/auth/mfa/webauthn/options",
json={"username": "testuser"})
assert opts_r.status_code == 200
assertion = auth.make_assertion(opts_r.json()["options"])
v = wa_client.post("/api/auth/mfa/webauthn/verify",
json={"username": "testuser", "credential": assertion})
assert v.status_code == 200, v.text
assert "access_token" in v.json()
def test_login_with_wrong_credential_rejected(self, wa_client):
headers = _login_headers(wa_client)
options = wa_client.post("/api/auth/mfa/webauthn/register/options",
headers=headers).json()["options"]
auth = VirtualAuthenticator()
cred = auth.make_registration(options)
wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
json={"credential": cred, "label": "Key"})
opts_r = wa_client.post("/api/auth/mfa/webauthn/options",
json={"username": "testuser"})
# Impostor key signs the challenge
impostor = VirtualAuthenticator()
bad = impostor.make_assertion(opts_r.json()["options"])
v = wa_client.post("/api/auth/mfa/webauthn/verify",
json={"username": "testuser", "credential": bad})
assert v.status_code == 401
def test_challenge_single_use(self, wa_client):
headers = _login_headers(wa_client)
options = wa_client.post("/api/auth/mfa/webauthn/register/options",
headers=headers).json()["options"]
auth = VirtualAuthenticator()
cred = auth.make_registration(options)
wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
json={"credential": cred, "label": "K"})
opts_r = wa_client.post("/api/auth/mfa/webauthn/options",
json={"username": "testuser"})
assertion = auth.make_assertion(opts_r.json()["options"])
v1 = wa_client.post("/api/auth/mfa/webauthn/verify",
json={"username": "testuser", "credential": assertion})
assert v1.status_code == 200
# replay the same credential → challenge already consumed
v2 = wa_client.post("/api/auth/mfa/webauthn/verify",
json={"username": "testuser", "credential": assertion})
assert v2.status_code == 401
def test_login_options_enumeration_safe(self, wa_client):
# Unknown user / no MFA -> always 200 with totp fallback, no 404/400 leak
r = wa_client.post("/api/auth/mfa/webauthn/options",
json={"username": "ghost-user"})
assert r.status_code == 200
assert r.json() == {"mfa_method": "totp", "options": None}
def test_remove_key_disables_mfa(self, wa_client):
headers = _login_headers(wa_client)
options = wa_client.post("/api/auth/mfa/webauthn/register/options",
headers=headers).json()["options"]
auth = VirtualAuthenticator()
cred = auth.make_registration(options)
wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
json={"credential": cred, "label": "K"})
cred_id = cred["id"]
r = wa_client.post("/api/auth/mfa/webauthn/credentials/remove",
headers=headers,
json={"credential_id": cred_id, "password": "wrong"})
assert r.status_code == 400
r2 = wa_client.post("/api/auth/mfa/webauthn/credentials/remove",
headers=headers,
json={"credential_id": cred_id, "password": "TestPass123!"})
assert r2.status_code == 200
st = wa_client.get("/api/auth/mfa/status", headers=headers).json()
assert st["mfa_enabled"] is False