Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2153435dc | ||
|
|
f307ecb380 | ||
|
|
50b823e76d | ||
|
|
84bda90065 | ||
|
|
d6fdcef4d4 | ||
|
|
62023acd4d | ||
|
|
75ef597ece | ||
|
|
0cbbbf260a | ||
|
|
4f99d5ce61 | ||
|
|
f96c4ccd64 | ||
|
|
ffa07b7df4 | ||
|
|
e81dd9a7ec | ||
|
|
30f2df3004 | ||
|
|
ac16fc1f0e | ||
|
|
e4aca3b31e | ||
|
|
212753f311 | ||
|
|
ccfcb58ed1 |
@@ -15,6 +15,7 @@ docker-compose.yml
|
||||
.env.*
|
||||
*.log
|
||||
.obsigate-backup
|
||||
backend/VERSION
|
||||
.pytest_cache
|
||||
htmlcov
|
||||
.coverage
|
||||
|
||||
+12
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 <token></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)
|
||||
@@ -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
@@ -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
|
||||
@@ -16,3 +16,4 @@ pypdf>=4.0
|
||||
pyotp>=2.10.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
Generated
+1
@@ -2646,6 +2646,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 @@
|
||||
{"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"]}}
|
||||
@@ -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)."
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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('/'));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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=$!
|
||||
|
||||
@@ -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":{}}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
excalidraw-plugin: parsed
|
||||
---
|
||||
```compressed-json
|
||||
N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcmTGAWxkbBoQBtUHgQh0ZcNDiIwKMJLnIFiABIdM9AAQBRNFjyES5evmwBzbIywAVeWPWZNu/TgLFJyBAEYADOQhWXwBfUhFcMQAjRWkxJQVyeLEAJQBLmgBXLlStOVQAC0ZsAEcMmC0AMi1GejkBEzNLa0w7ZRT0rJy8wpKyyuramHqQL3gAZn8QQPgAFhCwkFFEVBjYMXwYVDB0RnNOSUx0SI4xACF6ADu5LWwuKFNtvjhyUYBWAIRX4IBdcnQoKAAZW2cgQoGsPDEABEhnRmKEQAAzbCcATwYDBYJAA
|
||||
```
|
||||
|
||||
Ce texte ne doit pas polluer l'index.
|
||||
+92
-1
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() == ""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user