Compare commits

...
17 Commits
Author SHA1 Message Date
bruno e2153435dc chore(version): passer la version a 2.2.1
CI / lint (push) Successful in 56s
CI / security (push) Successful in 38s
CI / test (push) Successful in 1m11s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m20s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Defaut de build Docker et docs alignes sur 2.2.1.
2026-09-11 10:15:40 -04:00
bruno f307ecb380 fix(mobile): relever la boite d'edition de l'assistant au-dessus de la barre
Sur mobile, le textarea et le bouton envoyer de l'assistant AI etaient masques par la barre de navigation fixe en bas. Ajout d'un padding-bottom au panneau bookslm pour degager la barre.
2026-09-11 10:15:36 -04:00
bruno 50b823e76d feat(mobile): bouton assistant AI central dans la barre du bas
CI / lint (push) Successful in 57s
CI / security (push) Successful in 39s
CI / test (push) Successful in 1m16s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 19m16s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Sur mobile, la pastille flottante etait masquee par la barre d'outils.

- Masque .ai-fab sur mobile
- Ajoute un bouton rond sureleve #mt-ai au centre de la barre (5 items)
- Extrait openAssistant() dans ai-fab.js (reutilise par la FAB et le bouton mobile)
- mobile-toolbar.js : branche le bouton sur openAssistant()
2026-09-11 09:58:04 -04:00
bruno 84bda90065 fix(version): afficher 2.2.0 au lieu de 2.1.0 dans l'UI
Les 3 affichages (badge header, A propos, pied de l'aide) lisaient la version bakee dans l'image Docker, qui retombait sur l'ARG VERSION=2.1.0 car build.sh/build.ps1 ne transmettaient pas le tag git.

- Dockerfile / docker-compose.yml : defaut porte a 2.2.0
- build.sh : export VERSION pour substitution par docker compose
- build.ps1 : version derivee de git describe puis exportee
- CHANGELOG / ROADMAP alignes sur 2.2.0
2026-09-11 09:57:59 -04:00
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
68 changed files with 8827 additions and 648 deletions
+1
View File
@@ -15,6 +15,7 @@ docker-compose.yml
.env.*
*.log
.obsigate-backup
backend/VERSION
.pytest_cache
htmlcov
.coverage
+12 -2
View File
@@ -38,15 +38,21 @@ jobs:
- name: Frontend unit tests
run: node tests/frontend/unit.test.mjs
- name: Frontend JSDOM tests (PaneManager)
- 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 ─────────────────────────────────────────────────────────
@@ -188,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: |
@@ -205,4 +215,4 @@ jobs:
- name: Cleanup
if: always()
run: docker rm -f obsigate-e2e
run: docker rm -f obsigate-e2e
+131 -2
View File
@@ -6,11 +6,131 @@ 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.1.0](#210--2026-09-07). La dernière version publiée est **2.0.0**.
> [Unreleased](#unreleased). La dernière version publiée est **2.2.1**.
---
## [2.1.0] — 2026-09-07
## [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é
@@ -27,6 +147,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **#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é
+5 -4
View File
@@ -31,10 +31,11 @@ RUN apt-get update \
COPY backend/ ./backend/
COPY frontend/ ./frontend/
# Bake version: build.sh/CI pre-generate backend/VERSION (copied above);
# plain `docker compose build` has no such file (gitignored) — fall back
# to the VERSION build arg so the image always carries a version string.
ARG VERSION=0.0.0-dev
# 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.2.1
RUN test -f backend/VERSION || echo "$VERSION" > backend/VERSION
# Create non-root user for security + data directory for auth persistence
+8
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
@@ -623,6 +624,13 @@ Les métadonnées (pages, titre, auteur) sont disponibles via `GET /api/file/{va
**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 |
+9 -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
@@ -740,7 +741,7 @@ 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, `ext:pdf` for PDF documents (text-extracted content is indexed).
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
@@ -752,6 +753,13 @@ PDF metadata (pages, title, author) is available via `GET /api/file/{vault}/pdf/
**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.
### Keyboard Shortcuts
+2 -1
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.
+160 -8
View File
@@ -165,6 +165,9 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any
"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
@@ -173,6 +176,103 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any
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] = []
@@ -192,11 +292,12 @@ def _build_directory_tree(target_dir: Path, vault_root: Path) -> str:
return "\n".join(lines)
def build_system_prompt(context: dict[str, Any]) -> str:
"""Build a system prompt for directory-scoped AI chat.
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().
context: Output of collect_directory_context()/collect_files_context().
scope: "directory" (whole folder) or "documents" (open files).
Returns:
System prompt string with file contents.
@@ -211,12 +312,26 @@ def build_system_prompt(context: dict[str, Any]) -> str:
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 = (
"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."
f"\n\n📚 Contexte : {file_count} fichier(s) ({total_chars:,} caractères)"
f"{role}"
f"\n\n{context_label} : {file_count} fichier(s) ({total_chars:,} caractères)"
f"{token_warning}\n"
)
@@ -235,6 +350,43 @@ def build_system_prompt(context: dict[str, Any]) -> str:
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.
+88 -32
View File
@@ -9,24 +9,43 @@ 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_system_prompt, collect_directory_context
from backend.indexer import get_vault_data
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 = Field(description="Vault name")
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 = Field(description="Vault name")
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,
@@ -43,57 +62,94 @@ class BooksLMChatRequest(BaseModel):
)
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")
@router.post("/context", response_model=BooksLMContextResponse)
async def api_bookslm_context(
req: BooksLMContextRequest,
current_user=Depends(require_auth),
):
"""Collect directory context for BooksLM.
"""Collect context for the AI assistant.
Returns file list, content, and metadata for the specified directory.
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).
"""
if not check_vault_access(req.vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
mode = _normalize_mode(req.mode)
if mode == "general":
return empty_context("general")
vault_data = get_vault_data(req.vault)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{req.vault}' not found")
vault_path = Path(vault_data["path"])
context = collect_directory_context(vault_path, req.directory)
return context
_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")
@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 AI about directory contents (BooksLM).
"""Chat with the AI assistant.
Builds context from the directory, then sends the user message
with a system prompt containing all file contents to the AI provider.
Returns an SSE stream with the response.
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.
"""
if not check_vault_access(req.vault, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{req.vault}'")
mode = _normalize_mode(req.mode)
vault_data = get_vault_data(req.vault)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{req.vault}' not found")
vault_path = Path(vault_data["path"])
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 = collect_directory_context(vault_path, req.directory)
if context["file_count"] == 0:
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
context = _build_context(mode, vault_path, req.directory, req.context_files)
effective_mode = context.get("scope", mode)
# Build system prompt
system_prompt = build_system_prompt(context)
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
+191
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)
@@ -556,6 +742,11 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
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()
+321 -60
View File
@@ -56,6 +56,47 @@ from backend.indexer import (
remove_vault_from_index,
update_single_file,
)
from backend.openapi_docs import (
API_DESCRIPTION,
TAGS_METADATA,
enrich_openapi_schema,
render_api_landing,
)
from backend.schemas import (
AIKeyDeleteResponse,
AIKeysResponse,
AIModelsResponse,
AITestResponse,
AllVaultSettingsResponse,
AppConfigResponse,
AttachmentRescanResponse,
AttachmentStatsResponse,
BacklinksResponse,
BackupContentResponse,
BackupsAutoResponse,
BackupsCompressResponse,
BackupsDeletedResponse,
BackupsListResponse,
BackupsResponse,
BookmarksResponse,
BookmarkToggleResponse,
ConflictResolveResponse,
ConflictsResponse,
DashboardResponse,
DiagnosticsResponse,
PdfInfoResponse,
RecentResponse,
ReplaceResponse,
SavedSearch,
ShareModel,
StatusResponse,
VaultActionResponse,
VaultFilesResponse,
VaultSettingsResponse,
VaultsStatusResponse,
VaultStatsResponse,
WebhookModel,
)
from backend.search import (
advanced_search,
get_all_tags,
@@ -290,6 +331,9 @@ class HealthResponse(BaseModel):
version: str = Field(description="Application version (x.y.z — latest release tag)")
vaults: int = Field(description="Number of configured vaults")
total_files: int = Field(description="Total indexed files across all vaults")
total_tokens: int = Field(description="Total indexed tokens (approx.) across all vaults", default=0)
last_full_index_ts: str = Field(description="ISO timestamp of last full index rebuild", default="")
uptime_seconds: int = Field(description="Server uptime in seconds", default=0)
git_describe: str = Field(default="", description="Full git describe string (commits beyond tag), empty if no git")
git_commit: str = Field(default="", description="Short HEAD commit hash, empty if no git")
@@ -611,6 +655,22 @@ async def lifespan(app: FastAPI):
loop = asyncio.get_running_loop()
await loop.run_in_executor(_search_executor, init_inverted_index)
# Scan for plugins in all vaults
logger.info("Scanning for plugins...")
from backend.indexer import vault_config
from backend.plugins import get_plugin_registry
registry = get_plugin_registry()
for vault_name, cfg in vault_config.items():
vault_path = cfg.get("path")
if vault_path:
try:
plugins = registry.scan_vault(vault_name, vault_path)
logger.info(f"Vault '{vault_name}': found {len(plugins)} plugin(s)")
from backend.plugins import emit_vault_mounted
emit_vault_mounted(vault_name, vault_path)
except Exception as e:
logger.warning(f"Plugin scan failed for vault '{vault_name}': {e}")
# Start file watcher
config = _load_config()
watcher_enabled = config.get("watcher_enabled", True)
@@ -649,7 +709,33 @@ async def lifespan(app: FastAPI):
from backend.version import get_git_commit, get_git_describe, get_version
app = FastAPI(title="ObsiGate", version=get_version(), lifespan=lifespan)
app = FastAPI(
title="ObsiGate API",
version=get_version(),
lifespan=lifespan,
description=API_DESCRIPTION.strip(),
openapi_tags=TAGS_METADATA,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
contact={"name": "ObsiGate", "url": "https://git.dracodev.net/Projets/ObsiGate"},
license_info={"name": "MIT"},
)
# Enrich the auto-generated OpenAPI 3.1 schema (#72): tags per category,
# examples, security schemes and documented error responses.
_original_openapi = app.openapi
def _custom_openapi():
if app.openapi_schema:
return app.openapi_schema
schema = _original_openapi()
app.openapi_schema = enrich_openapi_schema(schema)
return app.openapi_schema
app.openapi = _custom_openapi
# GZip compression — reduces bandwidth by ~70% for text responses
# Custom wrapper: skip compression for SSE streams (/api/events)
@@ -727,14 +813,57 @@ try:
except ImportError as e:
logger.warning(f"Could not load admin dashboard router: {e}")
# Push Notifications endpoints (Web Push API + VAPID)
try:
from backend.push import router as push_router
app.include_router(push_router)
logger.info("Push notifications router mounted at /api/push/*")
except ImportError as e:
logger.warning(f"Could not load push notifications router: {e}")
# Plugins system endpoints
try:
from backend.plugins import router as plugins_router
app.include_router(plugins_router)
logger.info("Plugins router mounted at /api/plugins/*")
except ImportError as e:
logger.warning(f"Could not load plugins router: {e}")
# Resolve frontend path relative to this file
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
# ---------------------------------------------------------------------------
# API documentation landing page (#72)
# ---------------------------------------------------------------------------
@app.get("/api", include_in_schema=False, response_class=HTMLResponse)
@app.get("/api/", include_in_schema=False, response_class=HTMLResponse)
async def api_docs_landing():
"""Human-friendly API documentation landing page (links to /docs, /redoc)."""
return HTMLResponse(render_api_landing(get_version()))
# ---------------------------------------------------------------------------
# Path safety helper
# ---------------------------------------------------------------------------
def _content_disposition(disposition: str, filename: str) -> str:
"""Build a header-safe Content-Disposition value.
HTTP header values must be ASCII. Unicode filenames are sent per
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
the filename contains accented characters (e.g. 'Bière blonde…pdf').
"""
from urllib.parse import quote
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
ext = Path(filename).suffix
if ext and not Path(ascii_name).suffix:
ascii_name = ascii_name + ext
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
"""Resolve a relative path safely within the vault root.
@@ -1026,16 +1155,115 @@ async def api_health():
Application status, version, vault count and total file count.
"""
total_files = sum(len(v["files"]) for v in index.values())
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values()) # rough approx
import time
from backend.indexer import _last_full_index_ts
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0
return {
"status": "ok",
"version": app.version,
"vaults": len(index),
"total_files": total_files,
"total_tokens": total_tokens,
"last_full_index_ts": _last_full_index_ts,
"uptime_seconds": uptime,
"git_describe": get_git_describe(),
"git_commit": get_git_commit(),
}
@app.get("/api/health/detailed", response_model=HealthResponse)
async def api_health_detailed(current_user=Depends(require_admin)):
"""Detailed health check — admin only.
Returns enriched metrics including memory, disk, SSE connections, and backup stats.
"""
import psutil
from backend.admin import _count_active_sessions, _get_disk_stats
from backend.indexer import _last_full_index_ts, index
total_files = sum(len(v["files"]) for v in index.values())
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values())
import time
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0
# Memory
vm = psutil.virtual_memory()
mem_used_mb = round(vm.used / (1024 ** 2), 1)
mem_total_mb = round(vm.total / (1024 ** 2), 1)
mem_pct = round(vm.percent, 1)
# CPU
cpu_pct = psutil.cpu_percent(interval=None)
# Disk
disk_used_gb, disk_total_gb = _get_disk_stats()
disk_free_gb = round(disk_total_gb - disk_used_gb, 2)
disk_pct = round((disk_used_gb / disk_total_gb * 100) if disk_total_gb > 0 else 0, 1)
# SSE connections (approximation)
active_sessions = _count_active_sessions()
# Backups
from backend.admin import _scan_backups
backup_rows = _scan_backups()
total_backups = len(backup_rows)
total_backup_size_mb = round(sum(r["size"] for r in backup_rows) / (1024 ** 2), 2)
oldest_backup_age_days = 0.0
if backup_rows:
now_ts = int(time.time())
oldest_ts = min(r["timestamp"] for r in backup_rows)
oldest_backup_age_days = round((now_ts - oldest_ts) / 86400, 2)
# Index details
index_detail = {}
for name, data in index.items():
index_detail[name] = {
"file_count": len(data["files"]),
"tag_count": len(data["tags"]),
"token_count_approx": len(data.get("files", [])) * 1000,
}
return {
"status": "ok",
"version": app.version,
"vaults": len(index),
"total_files": total_files,
"total_tokens": total_tokens,
"last_full_index_ts": _last_full_index_ts,
"uptime_seconds": uptime,
"git_describe": get_git_describe(),
"git_commit": get_git_commit(),
# Enriched fields
"memory": {
"used_mb": mem_used_mb,
"total_mb": mem_total_mb,
"percent": mem_pct,
},
"cpu": {
"percent": cpu_pct,
},
"disk": {
"used_gb": disk_used_gb,
"total_gb": disk_total_gb,
"free_gb": disk_free_gb,
"percent": disk_pct,
},
"connections": {
"active_sse": active_sessions,
},
"backups": {
"total_count": total_backups,
"total_size_mb": total_backup_size_mb,
"oldest_age_days": oldest_backup_age_days,
},
"index": index_detail,
}
@app.get("/api/vaults", response_model=list[VaultInfo])
async def api_vaults(current_user=Depends(require_auth)):
"""List configured vaults the user has access to.
@@ -1070,7 +1298,7 @@ def humanize_mtime(mtime: float) -> str:
return datetime.fromtimestamp(mtime).strftime("%d %b %Y")
@app.get("/api/recent")
@app.get("/api/recent", response_model=RecentResponse)
async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
config = _load_config()
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
@@ -1164,7 +1392,7 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query(
}
@app.get("/api/bookmarks")
@app.get("/api/bookmarks", response_model=BookmarksResponse)
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
username = current_user.get("username")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
@@ -1212,7 +1440,7 @@ class BookmarkToggleRequest(BaseModel):
path: str
title: str | None = None
@app.post("/api/bookmarks/toggle")
@app.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse)
async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
@@ -1246,7 +1474,7 @@ async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(r
return {"bookmarked": is_now_bookmarked}
@app.get("/api/saved-searches")
@app.get("/api/saved-searches", response_model=list[SavedSearch])
async def api_saved_searches(current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
@@ -1254,7 +1482,7 @@ async def api_saved_searches(current_user=Depends(require_auth)):
return get_saved(username)
@app.post("/api/saved-searches")
@app.post("/api/saved-searches", response_model=SavedSearch)
async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
@@ -1262,7 +1490,7 @@ async def api_save_search(body: dict = Body(...), current_user=Depends(require_a
return save_search(username, body)
@app.delete("/api/saved-searches/{search_id}")
@app.delete("/api/saved-searches/{search_id}", response_model=StatusResponse)
async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)):
username = current_user.get("username")
if not username:
@@ -1400,7 +1628,7 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
return {"vault": vault_name, "path": path, "raw": raw}
@app.get("/api/file/{vault_name}/download")
@app.get("/api/file/{vault_name}/download", response_class=FileResponse)
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a file as an attachment.
@@ -1433,7 +1661,11 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
)
@app.get("/api/file/{vault_name}/pdf")
@app.get(
"/api/file/{vault_name}/pdf",
response_class=Response,
responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}},
)
async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a markdown file as PDF."""
if generate_pdf is None:
@@ -1481,7 +1713,11 @@ def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tu
return vault_root, target
@app.get("/api/export/html")
@app.get(
"/api/export/html",
response_class=Response,
responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}},
)
async def api_export_html(
vault: str = Query(..., description="Vault name"),
path: str = Query(..., description="Relative path to file"),
@@ -1502,7 +1738,11 @@ async def api_export_html(
)
@app.get("/api/export/md-bundle")
@app.get(
"/api/export/md-bundle",
response_class=Response,
responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}},
)
async def api_export_md_bundle(
vault: str = Query(..., description="Vault name"),
path: str = Query(..., description="Relative path to directory or file"),
@@ -1522,7 +1762,11 @@ async def api_export_md_bundle(
)
@app.get("/api/export/epub")
@app.get(
"/api/export/epub",
response_class=Response,
responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}},
)
async def api_export_epub(
vault: str = Query(..., description="Vault name"),
path: str = Query(..., description="Relative path to file"),
@@ -1652,6 +1896,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
"vault": vault_name,
"path": path,
})
from backend.plugins import emit_file_deleted
emit_file_deleted(vault_name, path)
# Remove from recent files
remove_recent(current_user["username"], vault_name, path)
@@ -1946,6 +2193,8 @@ async def api_file_create(
"path": body.path,
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": body.path})
from backend.plugins import emit_file_created
emit_file_created(vault_name, body.path)
return {"success": True, "path": body.path}
except PermissionError:
@@ -2224,7 +2473,7 @@ def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]:
return backups
@app.get("/api/file/{vault_name}/backups")
@app.get("/api/file/{vault_name}/backups", response_model=BackupsResponse)
async def api_file_backups(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
@@ -2261,7 +2510,7 @@ async def api_file_backups(
return {"vault": vault_name, "path": path, "backups": backups}
@app.get("/api/file/{vault_name}/diff")
@app.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
async def api_file_diff(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
@@ -2349,7 +2598,7 @@ async def api_file_diff(
raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {e!s}")
@app.post("/api/file/{vault_name}/restore")
@app.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
async def api_file_restore(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
@@ -2431,7 +2680,7 @@ async def api_file_restore(
raise HTTPException(status_code=500, detail=f"Error restoring file: {e!s}")
@app.get("/api/file/{vault_name}/backlinks")
@app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
async def api_file_backlinks(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
@@ -2726,7 +2975,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
}
@app.get("/api/file/{vault_name}/pdf/stream")
@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
async def api_pdf_stream(
request: Request,
vault_name: str,
@@ -2801,16 +3050,16 @@ async def api_pdf_stream(
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Disposition": f'inline; filename="{file_path.name}"',
"Content-Disposition": _content_disposition("inline", file_path.name),
},
)
return FileResponse(str(file_path), media_type="application/pdf", headers={
"Accept-Ranges": "bytes",
"Content-Disposition": f'inline; filename="{file_path.name}"'})
"Content-Disposition": _content_disposition("inline", file_path.name)})
@app.get("/api/file/{vault_name}/pdf/info")
@app.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
async def api_pdf_info(
vault_name: str,
path: str = Query(..., description="Relative path to PDF file"),
@@ -2989,7 +3238,7 @@ async def api_advanced_search(
)
@app.post("/api/search/replace")
@app.post("/api/search/replace", response_model=ReplaceResponse)
async def api_search_replace(
body: dict = Body(...),
current_user=Depends(require_auth),
@@ -3336,7 +3585,7 @@ def _add_wikilink_edges(nodes: list, edges: list, node_ids: set, vault_name: str
break
@app.get("/api/index/reload/{vault_name}")
@app.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse)
async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)):
"""Force a re-index of a single vault.
@@ -3362,7 +3611,11 @@ async def api_reload_vault(vault_name: str, current_user=Depends(require_admin))
# SSE endpoint — Server-Sent Events stream
# ---------------------------------------------------------------------------
@app.get("/api/events")
@app.get(
"/api/events",
response_class=StreamingResponse,
responses={200: {"content": {"text/event-stream": {}}, "description": "Server-Sent Events stream"}},
)
async def api_events(current_user=Depends(require_auth)):
"""SSE stream for real-time index update notifications.
@@ -3405,7 +3658,7 @@ async def api_events(current_user=Depends(require_auth)):
# Dynamic vault management endpoints
# ---------------------------------------------------------------------------
@app.post("/api/vaults/add")
@app.post("/api/vaults/add", response_model=VaultStatsResponse)
async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)):
"""Add a new vault dynamically without restarting.
@@ -3435,7 +3688,7 @@ async def api_add_vault(body: dict = Body(...), current_user=Depends(require_adm
return {"status": "ok", "vault": name, "stats": stats}
@app.delete("/api/vaults/{vault_name}")
@app.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse)
async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)):
"""Remove a vault from the index and stop watching it.
@@ -3454,7 +3707,7 @@ async def api_remove_vault(vault_name: str, current_user=Depends(require_admin))
return {"status": "ok", "vault": vault_name}
@app.get("/api/vaults/status")
@app.get("/api/vaults/status", response_model=VaultsStatusResponse)
async def api_vaults_status(current_user=Depends(require_auth)):
"""Detailed status of all vaults including watcher state.
@@ -3476,7 +3729,11 @@ async def api_vaults_status(current_user=Depends(require_auth)):
}
@app.get("/api/image/{vault_name}")
@app.get(
"/api/image/{vault_name}",
response_class=Response,
responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}},
)
async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)):
"""Serve an image file with proper MIME type.
@@ -3516,7 +3773,7 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
@app.post("/api/attachments/rescan/{vault_name}")
@app.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse)
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
"""Rescan attachments for a specific vault.
@@ -3537,7 +3794,7 @@ async def api_rescan_attachments(vault_name: str, current_user=Depends(require_a
return {"status": "ok", "vault": vault_name, "attachment_count": count}
@app.get("/api/attachments/stats")
@app.get("/api/attachments/stats", response_model=AttachmentStatsResponse)
async def api_attachment_stats(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Get attachment statistics for vaults.
@@ -3555,7 +3812,7 @@ async def api_attachment_stats(vault: str | None = Query(None, description="Vaul
# Vault Settings API — Display preferences
# ---------------------------------------------------------------------------
@app.get("/api/vaults/{vault_name}/settings")
@app.get("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
async def api_get_vault_settings(vault_name: str, current_user=Depends(require_auth)):
"""Get UI display settings for a specific vault.
@@ -3580,7 +3837,7 @@ async def api_get_vault_settings(vault_name: str, current_user=Depends(require_a
return settings
@app.post("/api/vaults/{vault_name}/settings")
@app.post("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
async def api_update_vault_settings(vault_name: str, body: dict = Body(...), current_user=Depends(require_admin)):
"""Update UI display settings for a specific vault.
@@ -3623,7 +3880,7 @@ async def api_update_vault_settings(vault_name: str, body: dict = Body(...), cur
return updated
@app.get("/api/vault/{vault_name}/files")
@app.get("/api/vault/{vault_name}/files", response_model=VaultFilesResponse)
async def api_vault_recent_files(
vault_name: str,
dir: str = Query("", description="Directory path within the vault (empty = root)"),
@@ -3734,7 +3991,7 @@ async def api_vault_recent_files(
}
@app.get("/api/vaults/settings/all")
@app.get("/api/vaults/settings/all", response_model=AllVaultSettingsResponse)
async def api_get_all_vault_settings(current_user=Depends(require_auth)):
"""Get UI display settings for all vaults.
@@ -3759,7 +4016,7 @@ async def api_get_all_vault_settings(current_user=Depends(require_auth)):
# Backup Management API
# ---------------------------------------------------------------------------
@app.get("/api/backups")
@app.get("/api/backups", response_model=BackupsListResponse)
async def api_backups_list(
vault: str | None = Query(None, description="Filter by vault name"),
current_user=Depends(require_auth),
@@ -3811,7 +4068,7 @@ async def api_backups_list(
raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}")
@app.post("/api/backups/delete")
@app.post("/api/backups/delete", response_model=BackupsDeletedResponse)
async def api_backups_delete(
body: dict = Body(...),
current_user=Depends(require_auth),
@@ -3837,7 +4094,7 @@ async def api_backups_delete(
return {"deleted": deleted}
@app.post("/api/backups/purge")
@app.post("/api/backups/purge", response_model=BackupsDeletedResponse)
async def api_backups_purge(
body: dict = Body(...),
current_user=Depends(require_auth),
@@ -3887,7 +4144,7 @@ async def api_backups_purge(
@app.get("/api/backups/content")
@app.get("/api/backups/content", response_model=BackupContentResponse)
async def api_backups_content(
path: str = Query(..., description="Full path to backup file"),
current_user=Depends(require_auth),
@@ -3910,7 +4167,7 @@ async def api_backups_content(
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/backups/compress")
@app.post("/api/backups/compress", response_model=BackupsCompressResponse)
async def api_backups_compress(
body: dict = Body(...),
current_user=Depends(require_auth),
@@ -3966,7 +4223,7 @@ async def api_backups_compress(
return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run}
@app.post("/api/backups/auto")
@app.post("/api/backups/auto", response_model=BackupsAutoResponse)
async def api_backups_auto(
body: dict = Body(...),
current_user=Depends(require_auth),
@@ -4056,13 +4313,13 @@ def _save_config(config: dict) -> None:
raise HTTPException(status_code=500, detail=f"Failed to save config: {e}")
@app.get("/api/config")
@app.get("/api/config", response_model=AppConfigResponse)
async def api_get_config(current_user=Depends(require_auth)):
"""Return current configuration with defaults for missing keys."""
return _load_config()
@app.post("/api/config")
@app.post("/api/config", response_model=AppConfigResponse)
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
"""Update configuration. Only known keys are accepted.
@@ -4102,7 +4359,7 @@ def _write_ai_keys(data: dict):
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(AI_KEYS_FILE)
@app.get("/api/config/ai-keys")
@app.get("/api/config/ai-keys", response_model=AIKeysResponse)
async def api_get_ai_keys(current_user=Depends(require_admin)):
"""Return stored AI keys (values masked)."""
keys = _read_ai_keys()
@@ -4115,7 +4372,7 @@ async def api_get_ai_keys(current_user=Depends(require_admin)):
masked[k] = ""
return masked
@app.post("/api/config/ai-keys")
@app.post("/api/config/ai-keys", response_model=StatusResponse)
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
keys = _read_ai_keys()
@@ -4127,7 +4384,7 @@ async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_a
return {"status": "ok"}
@app.delete("/api/config/ai-keys/{provider_env}")
@app.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse)
async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)):
"""Delete a specific AI provider key from storage."""
allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY",
@@ -4145,7 +4402,7 @@ async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admi
return {"status": "deleted", "key": key_name}
@app.post("/api/config/ai-keys/test")
@app.post("/api/config/ai-keys/test", response_model=AITestResponse)
async def api_test_ai_keys(current_user=Depends(require_admin)):
"""Test which AI providers are configured.
@@ -4190,7 +4447,7 @@ async def api_test_ai_keys(current_user=Depends(require_admin)):
# AI Models — list available models per provider
# ---------------------------------------------------------------------------
@app.get("/api/config/ai-models")
@app.get("/api/config/ai-models", response_model=AIModelsResponse)
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
"""List available models for a given AI provider.
@@ -4337,7 +4594,7 @@ _FALLBACK_MODELS: dict[str, list[str]] = {
# Diagnostics API
# ---------------------------------------------------------------------------
@app.get("/api/diagnostics")
@app.get("/api/diagnostics", response_model=DiagnosticsResponse)
async def api_diagnostics(current_user=Depends(require_admin)):
"""Return index statistics and system diagnostics.
@@ -4395,7 +4652,7 @@ async def api_diagnostics(current_user=Depends(require_admin)):
# Dashboard endpoint (aggregated stats)
# ---------------------------------------------------------------------------
@app.get("/api/dashboard")
@app.get("/api/dashboard", response_model=DashboardResponse)
async def api_dashboard(current_user=Depends(require_auth)):
"""Aggregated dashboard statistics across all accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
@@ -4424,12 +4681,12 @@ async def api_dashboard(current_user=Depends(require_auth)):
# Webhook CRUD endpoints
# ---------------------------------------------------------------------------
@app.get("/api/webhooks")
@app.get("/api/webhooks", response_model=list[WebhookModel])
async def api_webhooks_list(current_user=Depends(require_admin)):
return get_webhooks()
@app.post("/api/webhooks")
@app.post("/api/webhooks", response_model=WebhookModel)
async def api_webhooks_create(body: dict = Body(...), current_user=Depends(require_admin)):
name = body.get("name", "Unnamed")
url = body.get("url", "")
@@ -4440,7 +4697,7 @@ async def api_webhooks_create(body: dict = Body(...), current_user=Depends(requi
return create_webhook(name, url, events, secret)
@app.patch("/api/webhooks/{webhook_id}")
@app.patch("/api/webhooks/{webhook_id}", response_model=WebhookModel)
async def api_webhooks_update(webhook_id: str, body: dict = Body(...), current_user=Depends(require_admin)):
result = update_webhook(webhook_id, body)
if not result:
@@ -4448,7 +4705,7 @@ async def api_webhooks_update(webhook_id: str, body: dict = Body(...), current_u
return result
@app.delete("/api/webhooks/{webhook_id}")
@app.delete("/api/webhooks/{webhook_id}", response_model=StatusResponse)
async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admin)):
if not delete_webhook(webhook_id):
raise HTTPException(404, "Webhook not found")
@@ -4459,7 +4716,7 @@ async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admi
# Share (public document) endpoints
# ---------------------------------------------------------------------------
@app.post("/api/share/{vault_name}")
@app.post("/api/share/{vault_name}", response_model=ShareModel)
async def api_share_create(
vault_name: str,
body: dict = Body(...),
@@ -4498,7 +4755,7 @@ async def api_share_create(
return share
@app.get("/api/shares")
@app.get("/api/shares", response_model=list[ShareModel])
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
"""List all shares (optionally filtered by vault)."""
shares = list_shares(vault)
@@ -4507,13 +4764,17 @@ async def api_shares_list(vault: str | None = Query(None), current_user=Depends(
return shares
@app.delete("/api/share/{share_id}")
@app.delete("/api/share/{share_id}", response_model=StatusResponse)
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
if not revoke_share(share_id):
raise HTTPException(404, "Share not found")
return {"status": "revoked"}
@app.get("/s/{token}/pdf")
@app.get(
"/s/{token}/pdf",
response_class=Response,
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
)
async def public_share_pdf_download(token: str):
"""Download shared document as real PDF via WeasyPrint."""
if generate_pdf is None:
@@ -4547,7 +4808,7 @@ async def public_share_pdf_download(token: str):
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
@app.get("/s/{token}/raw")
@app.get("/s/{token}/raw", response_class=FileResponse)
async def public_share_raw(token: str):
"""Download the raw (original) shared document."""
share = get_share_by_token(token)
@@ -4564,7 +4825,7 @@ async def public_share_raw(token: str):
return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream")
@app.get("/s/{token}")
@app.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(token: str):
"""Public share view — no authentication required."""
share = get_share_by_token(token)
@@ -4684,7 +4945,7 @@ function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").t
# Syncthing conflict endpoints
# ---------------------------------------------------------------------------
@app.get("/api/conflicts")
@app.get("/api/conflicts", response_model=ConflictsResponse)
async def api_conflicts(current_user=Depends(require_auth)):
"""List sync-conflict files across accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
@@ -4694,7 +4955,7 @@ async def api_conflicts(current_user=Depends(require_auth)):
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
@app.post("/api/conflicts/resolve")
@app.post("/api/conflicts/resolve", response_model=ConflictResolveResponse)
async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)):
"""Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original)."""
vault_name = body.get("vault")
+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)
+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
+1
View File
@@ -16,3 +16,4 @@ 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'")
+8
View File
@@ -106,6 +106,14 @@ Ce vault est utilisé pour les tests de développement d'ObsiGate.
}
}
# ----- Resolve version from git before Docker build -----
$Version = (git describe --tags --dirty 2>$null) -replace '^v',''
if (-not $Version) { $Version = "0.0.0" }
$Version | Out-File -Encoding ascii -NoNewline -FilePath "backend\VERSION"
# Export so docker compose substitutes ${VERSION} in the build arg
$env:VERSION = $Version
Write-Info "Version : $Version"
# ----- Build the image -----
$BuildArgs = @("-f", $ComposeFile)
if (-not $UseCache) {
+2
View File
@@ -196,6 +196,8 @@ fi
# Generate VERSION file from git before Docker build
VERSION=$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo "0.0.0-dev")
echo "$VERSION" > backend/VERSION
# Export so docker compose substitutes ${VERSION} in the build arg
export VERSION
info "Version: $VERSION"
$COMPOSE_CMD -f "$COMPOSE_FILE" build "${BUILD_FLAGS[@]}" "${BUILD_ARGS[@]}"
+1
View File
@@ -2646,6 +2646,7 @@ dependencies = [
"tauri-plugin-updater",
"tempfile",
"tokio",
"windows",
]
[[package]]
+11
View File
@@ -29,6 +29,17 @@ 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"]);
}
}
+14
View File
@@ -5,6 +5,8 @@
#![windows_subsystem = "windows"]
mod jumplist;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
@@ -579,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) {
@@ -872,8 +878,14 @@ fn build_tray_menu(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
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();
@@ -973,6 +985,7 @@ mod tests {
#[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());
@@ -980,6 +993,7 @@ mod tests {
#[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
+5 -3
View File
@@ -12,9 +12,11 @@ services:
build:
context: .
args:
# VERSION est injecte par build.sh/CI via `git describe` ;
# sans variable d'env, l'image tombe sur 0.0.0-dev (fallback Dockerfile).
VERSION: ${VERSION:-0.0.0-dev}
# 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.2.1) 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.2.1}
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.
+89 -50
View File
@@ -1,7 +1,7 @@
# ObsiGate — Roadmap
> **Version :** 2.1.0-dev | **Dernière mise à jour :** 2026-09-07
> Revue de cohérence roadmap ↔ code : cases cochées selon l'état réel vérifié dans le dépôt (commit c066b2c).
> **Version :** 2.2.1-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)
---
@@ -160,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 :** 🟢
@@ -245,7 +251,7 @@
- [x] Endpoints : `GET /api/export/html`, `GET /api/export/md-bundle`, `GET /api/export/epub`
### 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)
- **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.
@@ -288,7 +294,7 @@
##### D. Frontend — Arborescence de fichiers (0.5 jour)
- [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.
- [x] **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`.
- [ ] **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)
@@ -306,7 +312,7 @@
- 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
- [x] **E4. Thème (optionnel, 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).
- [ ] **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)
@@ -325,7 +331,7 @@
- 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
@@ -333,7 +339,7 @@
##### I. Documentation utilisateur (inclus dans l'effort)
- [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 ? »
- [ ] **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
@@ -345,9 +351,9 @@
---
### 75. Éditeur multi-panneaux (Split View) — ✅ TERMINÉ (reste I3 : tests E2E split view)
### 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 :**
@@ -406,21 +412,42 @@
##### ✅ 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) — **FAIT** : `tests/frontend/pane-manager.test.mjs` (9 tests, 100% verts)
- [ ] **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) — ✅ 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 :**
@@ -436,7 +463,7 @@
- [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.
- [x] **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... »).
- [ ] **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) — ✅ 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.
@@ -507,7 +534,7 @@
### 78. Éditeur Excalidraw — Ouverture et édition de fichiers .excalidraw
- **Effort :** 3-4 jours | **Impact :** 🟡 | **Statut :** 🟡 ~90% livré (2026-09 — éditeur iframe complet, détection, création, autosave, support `.excalidraw.md`. Reste : B5 extraction texte pour recherche, C8 menu contextuel, F2/F3 tests, vérif BUG-002)
- **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.
@@ -551,7 +578,7 @@
```
Version épinglée (`@0.18.0`) pour la stabilité. Mise à jour manuelle testée.
- [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).
- [ ] **A4. Initialisation React** : Excalidraw nécessite React + ReactDOM. Les importer depuis esm.sh également :
- [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";
@@ -582,7 +609,7 @@
- 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
- [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.
- [ ] **B5. Indexation du contenu texte** (NON FAIT) : extraire `element.text` des éléments pour la recherche TF-IDF — les fichiers sont indexés comme JSON brut.
- [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":{}}
@@ -613,11 +640,11 @@
- 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 (NON FAIT — la modale « Nouveau fichier » suffit)** : 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)
- [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.
- [ ] **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] **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).
@@ -625,7 +652,7 @@
- [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: []`.
- [ ] **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 ».
- [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.
@@ -633,11 +660,11 @@
- [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 (non retenus)** :
- [ ] **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) (NON FAITS)** :
- [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
@@ -652,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 »
---
@@ -745,8 +772,20 @@
- 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.
@@ -759,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 :** 🟢
@@ -875,13 +914,13 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #63-66, #71, #74, #75*, #76 (58 E2E, 59 offline, 63 i18n, 64 MFA TOTP+WebAuthn, 65 thèmes, 66 export, 71 admin, 74 PDF, 76 BooksLM) | ~75 jours réalisés |
| ✅ 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 | #61 Plugins système (4-5j) · #62 Collaboration Yjs (5-7j) · #78 Excalidraw finitions (B5 recherche, C8, F3 E2E, BUG-002) (~1-1.5j) | ~10-13.5 jours |
| ⚪ P4 restant | #67 Push (2j) · #68 Health enrichi (1j) · #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #72 OpenAPI (1-2j) · #73 Sync (6-8j) | 16-21 jours |
| **Total restant** | **9 items + finitions** | **~28-37 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 : 100% fonctionnel, il ne reste que les tests E2E Playwright de la sous-tâche I3.
#75 : E2E matriciels 37/37 verts (split-view.spec.js + split-view-matrix.spec.js) — 2026-09-08.
---
@@ -890,5 +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.
- #59 (hors-ligne), #63 (i18n), #64 (MFA), #65-66, #71, #74, #75, #76 sont livrés — les P3 restants à plus fort rapport effort/valeur : #78 finitions (recherche texte Excalidraw) et #68 (health check, 1j).
- #78 a un bug ouvert référencé dans docs/ISSUES_TODOLIST.md (BUG-002, loading infini Excalidraw) — fixés par les commits a4ea322/185d603, à revalider sur poste client avant de cocher F3.
- #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.
+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,
+200 -9
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"
@@ -1449,6 +1473,8 @@
<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>
@@ -2185,11 +2211,41 @@
</div>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<!-- 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"
@@ -2772,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
@@ -2783,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>
@@ -2831,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">
@@ -3428,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
@@ -3435,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>
@@ -4725,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
@@ -4739,6 +4921,7 @@ curl -X POST https://votre-serveur.com/webhook \
>.
</p>
</div>
</div>
</div>
</div>
@@ -4858,6 +5041,14 @@ curl -X POST https://votre-serveur.com/webhook \
<i data-lucide="search" style="width: 20px; height: 20px"></i>
<span class="mt-label" data-i18n="settings.search">Recherche</span>
</button>
<button
class="mt-btn mt-btn-ai"
id="mt-ai"
title="Assistant AI"
aria-label="Ouvrir l'assistant AI"
>
<i data-lucide="bot" style="width: 24px; height: 24px"></i>
</button>
<button
class="mt-btn mt-btn-primary"
id="mt-palette"
@@ -4950,7 +5141,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">—</span>
<span class="about-version" id="about-version">—</span>
</div>
<div class="about-body">
<div class="about-stats">
+20 -36
View File
@@ -1,26 +1,14 @@
// ObsiGate — Floating Action Button (FAB) to open/close the BooksLM AI sidebar.
// Always visible in the bottom-right corner; hides while BooksLM is open.
// 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 { state } from './state.js';
import { showToast, TabManager } from './ui.js';
import { t } from './i18n.js';
import { safeCreateIcons } from './utils.js';
let _fab = null;
// Mirror palette.js getCurrentFile(): prefer the active tab, then fall back to state.
function getCurrentFile() {
const id = TabManager._activeTabId;
if (id) {
const tab = TabManager._tabs.find((x) => x.id === id);
if (tab) return { vault: tab.vault, path: tab.path };
}
if (state.currentVault && state.currentPath) return { vault: state.currentVault, path: state.currentPath };
return null;
}
// The BooksLM panel is considered visible when it has `.open` without `.hidden`
// (the header toggle collapses it off-screen via `.hidden`).
// 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'));
@@ -35,26 +23,22 @@ function syncVisibility() {
_fab.setAttribute('aria-label', label);
}
// Shared entry point used by the floating FAB and the mobile toolbar button.
// Reopens a collapsed panel, otherwise opens (or re-focuses) the assistant
// for the current application context.
export async function openAssistant() {
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();
}
async function onClick() {
const f = getCurrentFile();
if (!f) {
showToast(t('toast.no_open_file'), 'error');
return;
}
const sameDir = booksLM._isOpen && booksLM._vault === f.vault && booksLM._directory === f.path;
if (sameDir) {
// Already open for this directory: re-show a collapsed panel, or close it.
const panel = document.querySelector('.bookslm-panel');
if (panel && panel.classList.contains('hidden')) {
booksLM._toggleSidebar();
} else {
booksLM.close();
}
return;
}
await booksLM.open(f.vault, f.path);
await openAssistant();
}
export function initAIFab() {
+110 -33
View File
@@ -376,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;
@@ -424,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;
@@ -445,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' }) },
@@ -470,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',
@@ -494,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());
@@ -577,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) {
@@ -587,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;
}
@@ -604,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',
@@ -637,4 +713,5 @@ export {
_writePicker,
PICKER_STORAGE_KEY,
_buildPickerUI as buildAIPickerUI,
_promptRewriteInstruction,
};
+21 -14
View File
@@ -20,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
@@ -30,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();
@@ -62,7 +70,9 @@ async function init() {
UI.TabManager.init();
PaneManager.init();
initCommandPalette();
initMobileToolbar();
initMobileToolbar();
initPush();
initPlugins();
if (authOk) {
Legacy.initSyncStatus();
@@ -103,26 +113,23 @@ async function init() {
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 — always visible, fallback before the async fetch
const versionBadge = document.getElementById('version-badge');
if (versionBadge) {
versionBadge.textContent = window.__OBSIGATE_VERSION || '…';
}
fetch('/api/health')
.then(r => r.json())
.then(d => {
const badge = document.getElementById('version-badge');
if (badge && d.version) badge.textContent = d.version;
})
.catch(() => {});
populateVersions();
fetch('/api/health')
.then(r => r.json())
.then(d => {
if (d && d.version) populateVersions(d.version);
})
.catch(() => {});
});
+434 -164
View File
@@ -1,13 +1,62 @@
// BooksLM — Directory-scoped AI chat panel (style NotebookLM)
// 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;
@@ -18,101 +67,121 @@ class BooksLM {
// ── Public API ──────────────────────────────────────────────────────
async open(vault, directory) {
if (this._isOpen && this._vault === vault && this._directory === directory) {
this.close();
/**
* 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;
// Restore history
this._loadHistory();
// Build panel if needed
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();
// Open with animation
requestAnimationFrame(() => {
this._panel.classList.add('open');
let hidden = false;
// Apply persisted hidden state (user toggled off in a previous session)
try {
const savedHidden = localStorage.getItem('obsigate-bookslm-hidden');
if (savedHidden === 'true') {
this._panel.classList.add('hidden');
this._updateToggleIcon();
hidden = true;
}
} catch { /* */ }
document.dispatchEvent(new CustomEvent(hidden ? 'bookslm:closed' : 'bookslm:opened', { detail: { vault, directory } }));
document.dispatchEvent(new CustomEvent('bookslm:opened', {
detail: { mode, vault, directory, documents },
}));
});
this._isOpen = true;
// Load context
try {
const resp = await fetch('/api/ai/bookslm/context', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vault, directory })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this._contextFiles = data.files || [];
this._updateStatus(data);
this._showSuggestions();
} catch (e) {
console.warn('BooksLM: context load failed', e);
this._contextFiles = [];
this._updateStatus({ error: e.message });
}
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'));
// Abort any in-flight request
if (this._abortCtrl) {
this._abortCtrl.abort();
this._abortCtrl = null;
}
}
/**
* Toggle the BooksLM sidebar visibility (collapsed/hidden to the right edge
* while the panel stays mounted). The state is persisted in localStorage so
* the user choice survives page reloads — same UX as the left sidebar toggle.
*
* The panel stays in the DOM (no teardown); only a CSS transform is applied,
* which is what makes this fast and matches the right-sidebar pattern in
* ObsiGate.
*/
_toggleSidebar() {
_setHidden(hidden) {
if (!this._panel) return;
const isHidden = this._panel.classList.toggle('hidden');
this._panel.classList.toggle('hidden', hidden);
try {
localStorage.setItem('obsigate-bookslm-hidden', isHidden ? 'true' : 'false');
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'));
}
/**
* Swap the toggle icon between "panel-right-close" (visible) and
* "panel-right-open" (hidden) so the button visually reflects the state.
*/
_updateToggleIcon() {
if (!this._panel) return;
const btn = this._panel.querySelector('.bookslm-btn-toggle');
@@ -131,14 +200,12 @@ class BooksLM {
this._renderMessages();
this._showSuggestions();
}
if (!this._isOpen && this._vault && this._directory) {
this.open(this._vault, this._directory);
}
}
exportConversation() {
if (!this._messages.length) return;
let md = `# BooksLM — ${this._directory}\n\n`;
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`;
@@ -146,17 +213,79 @@ class BooksLM {
const blob = new Blob([md], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `bookslm-${this._directory.replace(/\//g, '_')}.md`;
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 }
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() {
@@ -167,30 +296,41 @@ class BooksLM {
<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>📚</span>
<span class="bookslm-title"></span>
<span class="bookslm-picker-host" style="margin-left:auto"></span>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}">✨</button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}">📥</button>
<button class="bookslm-btn-fullscreen" title="⛶">⛶</button>
<button class="bookslm-btn-close" title="✕">✕</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"></textarea>
<button class="bookslm-btn-send">${t('bookslm.send')}</button>
<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>
`;
// Inject the provider/model picker asynchronously (depends on /api/ai/status)
// 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) pickerHost.replaceWith(picker);
if (!picker || !pickerHost) return;
pickerHost.replaceWith(picker);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}).catch(() => { /* ignore */ });
// Wire events
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());
@@ -198,22 +338,36 @@ class BooksLM {
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();
}
});
// Send button
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
// Textarea auto-grow + Ctrl+Enter
const textarea = panel.querySelector('textarea');
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
});
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
if (e.key !== 'Enter') return;
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd+Enter → insert a newline at the caret.
e.preventDefault();
this._sendMessage();
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;
@@ -221,12 +375,14 @@ class BooksLM {
_updateHeader() {
if (!this._panel) return;
const meta = this._contextMeta();
const title = this._panel.querySelector('.bookslm-title');
if (title) {
title.textContent = this._directory
? `📚 ${this._directory.split('/').pop() || this._vault}`
: t('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) {
@@ -235,36 +391,56 @@ class BooksLM {
if (!status) return;
if (data && data.error) {
status.innerHTML = `<span style="color:#f87171">⚠ ${t('bookslm.no_context')}</span>`;
status.innerHTML = `<span class="bookslm-status-error">⚠ ${t('bookslm.no_context')}</span>`;
return;
}
if (data && data.files) {
if (data && Array.isArray(data.files)) {
const count = data.files.length;
const chars = data.total_chars || 0;
const pct = Math.min(100, Math.round((chars / 100000) * 100));
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>${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}</span>
<div class="bookslm-context-bar"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
<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 = '⏳ ...';
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;
if (!this._contextFiles.length && !this._isLoading) return;
const suggestions = [
t('bookslm.suggestion_summary'),
t('bookslm.suggestion_themes'),
t('bookslm.suggestion_contradictions')
];
for (const s of suggestions) {
for (const s of this._suggestionsForContext()) {
const btn = document.createElement('button');
btn.className = 'bookslm-suggestion';
btn.textContent = s;
@@ -290,8 +466,14 @@ class BooksLM {
bubble.className = `bookslm-bubble ${msg.role}`;
if (msg.role === 'assistant') {
bubble.innerHTML = this._renderMarkdown(msg.content || '');
// Source badges
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';
@@ -312,10 +494,86 @@ class BooksLM {
container.appendChild(bubble);
}
// Scroll to bottom
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() {
@@ -326,28 +584,28 @@ class BooksLM {
textarea.value = '';
textarea.style.height = 'auto';
// Hide suggestions
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (sugEl) sugEl.innerHTML = '';
// Add user message
// 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();
// Add assistant placeholder
const assistantMsg = { role: 'assistant', content: '', sources: [] };
this._messages.push(assistantMsg);
this._renderMessages();
this._isLoading = true;
// Disable send button
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
try {
// Read provider/model from the picker (localStorage-backed)
let provider = null;
let model = null;
try {
@@ -356,77 +614,92 @@ class BooksLM {
model = picker.model || null;
} catch { /* */ }
const resp = await fetch('/api/ai/bookslm/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vault: this._vault,
directory: this._directory,
// The backend expects `message` + `conversation_history`,
// not a generic `messages` array. Convert before sending.
message: this._messages.length
? this._messages[this._messages.length - 1].content
: '',
conversation_history: this._messages
.slice(0, -1)
.filter((m) => m.content && m.content.trim())
.map((m) => ({ role: m.role, content: m.content })),
context_files: this._contextFiles.map((f) => f.path || f),
provider,
model,
}),
signal: this._abortCtrl.signal
});
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,
};
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
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) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6);
if (payload === '[DONE]') continue;
try {
const data = JSON.parse(payload);
if (data.content) {
assistantMsg.content += data.content;
}
if (data.sources) {
assistantMsg.sources = data.sources;
}
} catch {
// Raw text token
assistantMsg.content += payload;
}
this._renderMessages();
}
for (const line of lines) handleLine(line);
this._renderMessages();
}
if (buffer) handleLine(buffer);
// Extract sources from context files if mentioned
if (!assistantMsg.sources.length) {
if (!assistantMsg.sources.length && this._contextFiles.length) {
const contentLower = assistantMsg.content.toLowerCase();
assistantMsg.sources = this._contextFiles
.filter(f => {
.filter((f) => {
const name = (f.path || f || '').split('/').pop().toLowerCase();
return name && contentLower.includes(name);
})
.map(f => f.path || f);
.map((f) => f.path || f);
}
} catch (e) {
if (e.name !== 'AbortError') {
assistantMsg.content = `⚠ Error: ${e.message}`;
console.warn('BooksLM chat error:', e);
console.warn('AI assistant chat error:', e);
}
}
@@ -441,23 +714,19 @@ class BooksLM {
_renderMarkdown(text) {
if (!text) return '';
// Escape HTML first so model output cannot inject markup.
let html = text
// Code blocks
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
// Unordered lists
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
// Headers
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
return `<p>${html}</p>`;
@@ -466,21 +735,22 @@ class BooksLM {
// ── History persistence ─────────────────────────────────────────────
_historyKey() {
return `bookslm-history-${this._vault}-${this._directory}`;
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 {}
} catch { /* */ }
}
_loadHistory() {
try {
const data = localStorage.getItem(this._historyKey());
if (data) {
this._messages = JSON.parse(data);
}
this._messages = data ? JSON.parse(data) : [];
} catch {
this._messages = [];
}
+37 -15
View File
@@ -1483,8 +1483,24 @@ 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) {
@@ -1652,21 +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 + version from health endpoint (single source of truth)
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 || '—';
// Clean x.y.z version (not hardcoded) + git detail
var versionEl = document.getElementById('about-version');
if (versionEl && d.version) versionEl.textContent = d.version;
var commitEl = document.getElementById('about-commit');
if (commitEl) commitEl.textContent = d.git_describe || d.git_commit || '—';
})
.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
@@ -2,6 +2,7 @@
Phases 1-5: fixed bar, vault home, file palette, commands, tab switcher */
import { state } from './state.js';
import { TabManager } from './ui.js';
import { openAssistant } from './ai-fab.js';
// ── Open file palette (search files) ──
function openFilePalette() {
@@ -109,6 +110,7 @@ function toggleExplorer() {
export function initMobileToolbar() {
const explorerBtn = document.getElementById('mt-explorer');
const searchBtn = document.getElementById('mt-search');
const aiBtn = document.getElementById('mt-ai');
const paletteBtn = document.getElementById('mt-palette');
const tabsBtn = document.getElementById('mt-tabs');
const sheetClose = document.getElementById('mt-sheet-close');
@@ -117,6 +119,7 @@ export function initMobileToolbar() {
if (explorerBtn) explorerBtn.addEventListener('click', toggleExplorer);
if (searchBtn) searchBtn.addEventListener('click', openFilePalette);
if (aiBtn) aiBtn.addEventListener('click', () => { openAssistant(); });
if (paletteBtn) paletteBtn.addEventListener('click', openCommandPalette);
if (tabsBtn) tabsBtn.addEventListener('click', openTabSwitcher);
if (sheetClose) sheetClose.addEventListener('click', closeTabSwitcher);
+3 -3
View File
@@ -58,9 +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');}} },
// BooksLM commands
{ id:'bookslm-open', label:t('palette.bookslm_open'), desc:'Open BooksLM AI chat for current directory', cat:'AI', act:async()=>{close();const f=getCurrentFile();if(f){const m=await import('./bookslm.js');m.default.open(f.vault,f.path);}else{showToast(t('toast.no_open_file'),'error');}} },
{ id:'bookslm-new', label:t('palette.bookslm_new'), desc:'Reset BooksLM conversation and open fresh', cat:'AI', act:async()=>{close();const f=getCurrentFile();if(f){const m=await import('./bookslm.js');m.default.newConversation();m.default.open(f.vault,f.path);}else{showToast(t('toast.no_open_file'),'error');}} },
// 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);
+22 -4
View File
@@ -216,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() {
@@ -755,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>
@@ -804,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: / \\ : * ? " < > |';
@@ -1695,6 +1708,7 @@ 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();
@@ -1775,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);
},
+24
View File
@@ -12,6 +12,7 @@ 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
@@ -877,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 */ });
}
}
+100 -2
View File
@@ -78,8 +78,20 @@
"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",
@@ -87,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",
@@ -95,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",
@@ -109,20 +124,31 @@
"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",
@@ -130,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",
@@ -459,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",
@@ -485,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?",
@@ -620,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",
@@ -1071,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 😅",
@@ -1337,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",
@@ -1590,6 +1626,7 @@
"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?",
@@ -1598,7 +1635,9 @@
"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)",
@@ -1613,5 +1652,64 @@
"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."
}
"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"
}
+100 -2
View File
@@ -78,8 +78,20 @@
"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é",
@@ -87,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é",
@@ -95,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",
@@ -109,20 +124,31 @@
"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",
@@ -130,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é",
@@ -459,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",
@@ -485,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 ?",
@@ -620,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",
@@ -1071,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 😅",
@@ -1337,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",
@@ -1590,6 +1626,7 @@
"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 ?",
@@ -1598,7 +1635,9 @@
"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)",
@@ -1613,5 +1652,64 @@
"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."
}
"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"
}
+154 -9
View File
@@ -2675,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;
@@ -7704,6 +7786,37 @@ body.popup-mode .content-area {
color: var(--accent);
}
/* Hide the floating AI pastille on mobile — the assistant lives in the
centre of the bottom toolbar instead (below). */
.ai-fab {
display: none !important;
}
/* AI assistant — raised central button, stands out from the 4 other items */
.mt-btn-ai {
flex: 0 0 auto;
width: 56px;
height: 56px;
min-height: 56px;
margin: -18px 2px 0;
border-radius: 50%;
background: var(--accent);
color: #fff;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35), 0 0 0 4px var(--bg-secondary);
z-index: 2;
}
.mt-btn-ai:active {
background: var(--accent);
transform: scale(0.94);
}
.mt-btn-ai svg {
width: 26px;
height: 26px;
}
.mt-btn-ai .mt-label {
display: none;
}
/* Tab count badge */
.mt-badge {
position: absolute;
@@ -9048,18 +9161,30 @@ body.popup-mode .content-area {
}
.bookslm-btn-toggle:hover { color: var(--accent); background: var(--surface2); }
.bookslm-header { display: flex; align-items: center; gap: 8px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
font-size: 14px; font-weight: 600; }
.bookslm-header .bookslm-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookslm-header .bookslm-picker-host,
.bookslm-header .ai-picker,
.bookslm-header button { flex-shrink: 0; }
.bookslm-header .ai-picker { z-index: 1; }
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); font-size: 16px; padding: 4px 8px; border-radius: 4px; }
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; }
@@ -9083,12 +9208,32 @@ body.popup-mode .content-area {
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%; }
.bookslm-panel {
width: 100%;
/* Clear the fixed bottom toolbar so the input box and the send button
are not hidden behind it. The panel background still fills the area
behind the bar. */
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px));
}
}
/* ── AI Floating Action Button ───────────────────────── */
+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('/'));
}
});
});
+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);
+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);
+4 -3
View File
@@ -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) {
@@ -194,7 +195,7 @@ 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+['"]\.\/state\.js['"]/.test(content), 'ai-fab.js must import from ./state.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');
}
+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):
+172
View File
@@ -574,3 +574,175 @@ class TestBooksLMChatEndpoint:
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() == ""
+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
+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