Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6fdcef4d4 | ||
|
|
62023acd4d | ||
|
|
75ef597ece | ||
|
|
0cbbbf260a | ||
|
|
4f99d5ce61 | ||
|
|
f96c4ccd64 | ||
|
|
ffa07b7df4 | ||
|
|
e81dd9a7ec | ||
|
|
30f2df3004 | ||
|
|
ac16fc1f0e | ||
|
|
e4aca3b31e | ||
|
|
212753f311 | ||
|
|
ccfcb58ed1 | ||
|
|
aa3df74fc1 | ||
|
|
f9d2c0d2d4 | ||
|
|
c066b2c82a | ||
|
|
ab795ec9e0 | ||
|
|
7042307955 | ||
|
|
f37d5fda3c | ||
|
|
c5b92eabe1 | ||
|
|
01453bce5f | ||
|
|
bd86ba36e2 | ||
|
|
88ab8db88d | ||
|
|
11406034c9 | ||
|
|
eef8671912 | ||
|
|
72383b1634 | ||
|
|
2d70be0ae2 | ||
|
|
5350738c22 | ||
|
|
0d29dd00fd | ||
|
|
d441481930 | ||
|
|
7ff9b854b6 | ||
|
|
46be24f6a3 | ||
|
|
3908e1601a | ||
|
|
1ef0a7ed82 | ||
|
|
43f5b4a078 | ||
|
|
83355a25c8 | ||
|
|
ec56bc32f8 | ||
|
|
050c3d2515 | ||
|
|
b649c6b301 | ||
|
|
99c9acccb0 | ||
|
|
524e6da591 | ||
|
|
6292bfd9cb | ||
|
|
83063d3a18 | ||
|
|
7b0dacabdd | ||
|
|
c8b861f0b6 | ||
|
|
09912509cb | ||
|
|
3e0940da8c | ||
|
|
d20e623f0c | ||
|
|
ef7a3ff1ed | ||
|
|
2115cb9085 | ||
|
|
5f981509ac |
@@ -15,6 +15,7 @@ docker-compose.yml
|
||||
.env.*
|
||||
*.log
|
||||
.obsigate-backup
|
||||
backend/VERSION
|
||||
.pytest_cache
|
||||
htmlcov
|
||||
.coverage
|
||||
|
||||
@@ -33,6 +33,15 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# Backup
|
||||
# OBSIGATE_BACKUP_DIR=.obsigate-backup
|
||||
|
||||
# PDF (ROADMAP #74)
|
||||
# OBSIGATE_PDF_MAX_SIZE_MB=50 # PDFs plus volumineux = texte non indexé
|
||||
# OBSIGATE_PDF_EXTRACT_TIMEOUT=30 # secondes avant abandon de l'extraction
|
||||
|
||||
# WebAuthn / MFA (ROADMAP #64) — nécessaire hors localhost
|
||||
# OBSIGATE_WEBAUTHN_RP_ID=obsigate.example.com
|
||||
# OBSIGATE_WEBAUTHN_RP_NAME=ObsiGate
|
||||
# OBSIGATE_WEBAUTHN_ORIGINS=https://obsigate.example.com
|
||||
|
||||
# ── AI Provider Configuration ──
|
||||
# Définir au moins un provider pour activer les fonctionnalités AI dans l'éditeur
|
||||
|
||||
|
||||
+50
-3
@@ -38,6 +38,23 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: node tests/frontend/unit.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI)
|
||||
run: |
|
||||
cd tests/frontend
|
||||
if [ -d node_modules ]; then
|
||||
node pane-manager.test.mjs
|
||||
node excalidraw-viewer.test.mjs
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing — installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
node pane-manager.test.mjs
|
||||
node excalidraw-viewer.test.mjs
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
test:
|
||||
needs: lint
|
||||
@@ -54,6 +71,7 @@ jobs:
|
||||
run: |
|
||||
pip install pytest pytest-cov pytest-asyncio httpx
|
||||
pip install -r backend/requirements.txt
|
||||
pip install -r backend/requirements-test.txt || echo "test deps install failed (non-blocking — PDF tests will skip)"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ --cov=backend --cov-report=xml --cov-report=term -q
|
||||
@@ -99,8 +117,33 @@ jobs:
|
||||
VERSION=$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo "0.0.0-dev")
|
||||
echo "$VERSION" > backend/VERSION
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t obsigate:ci .
|
||||
- name: Configure DNS (workaround flaky 127.0.0.11 resolver)
|
||||
# GitHub Actions runners occasionally fail to resolve auth.docker.io via
|
||||
# the embedded Docker DNS (127.0.0.11:53 → "server misbehaving").
|
||||
# Force the daemon to use public DNS as a fallback.
|
||||
run: |
|
||||
sudo mkdir -p /etc/docker
|
||||
if ! grep -q "dns" /etc/docker/daemon.json 2>/dev/null; then
|
||||
echo '{"dns": ["8.8.8.8", "1.1.1.1", "9.9.9.9"]}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker || sudo service docker restart || true
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
- name: Build Docker image (retry on transient DNS/network errors)
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
echo "=== docker build attempt $attempt/3 ==="
|
||||
if docker build -t obsigate:ci . ; then
|
||||
echo "✓ Docker build succeeded"
|
||||
exit 0
|
||||
fi
|
||||
echo "✗ Build failed (attempt $attempt)"
|
||||
if [ $attempt -lt 3 ]; then
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
echo "✗ Docker build failed after 3 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Verify image
|
||||
run: docker images obsigate:ci
|
||||
@@ -151,6 +194,10 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
curl -sf "http://$GW:2029/api/health" >/dev/null || { echo "App not reachable at $GW:2029"; exit 1; }
|
||||
# Les fixtures sont copiées via `docker cp` en root → rendre le vault
|
||||
# inscriptible par l'utilisateur non-root de l'app (UID 1000), sinon
|
||||
# toute création/édition de fichier renvoie 403 « Vault is read-only ».
|
||||
docker exec -u 0 obsigate-e2e chmod -R a+rwX /vaults/TestVault /vaults/TestDir || true
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
@@ -168,4 +215,4 @@ jobs:
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: docker rm -f obsigate-e2e
|
||||
run: docker rm -f obsigate-e2e
|
||||
@@ -26,5 +26,8 @@ playwright-report/
|
||||
desktop/target/
|
||||
desktop/python-embed/
|
||||
desktop/frontend-static/
|
||||
desktop/checksums.txt
|
||||
desktop/backend/
|
||||
desktop/frontend/
|
||||
backend/VERSION
|
||||
|
||||
|
||||
+167
-1
@@ -6,7 +6,164 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements non publiés sont dans la section
|
||||
> [2.0.0 — Unreleased](#200--unreleased). La dernière version publiée est **1.8.0**.
|
||||
> [Unreleased](#unreleased). La dernière version publiée est **2.1.0**.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Assistant AI contextuel (sidebar)** — l'assistant s'adapte désormais au contexte d'ouverture :
|
||||
- **Répertoire** (`directory`) : ouvert depuis le menu contextuel d'un dossier — le contexte est
|
||||
le dossier et son contenu (mode historique BooksLM).
|
||||
- **Documents** (`documents`) : ouvert via le bouton flottant quand un ou plusieurs documents
|
||||
sont ouverts — le contexte est la liste des fichiers ouverts (tous onglets/panneaux).
|
||||
- **Général** (`general`) : ouvert via le bouton flottant quand aucun document n'est ouvert —
|
||||
l'assistant répond sur l'application et peut proposer des actions (créer un fichier/dossier)
|
||||
via un bloc `obsigate-action` rendu en carte avec bouton « Appliquer » (aucune exécution
|
||||
automatique).
|
||||
- Le contexte est affiché dans le header de la sidebar (icône, titre, sous-titre) et les
|
||||
suggestions de questions s'adaptent au mode.
|
||||
- Backend : `/api/ai/bookslm/context` et `/chat` acceptent `mode` (`directory`/`documents`/
|
||||
`general`) et `context_files` ; `collect_files_context()`, `empty_context()` et
|
||||
`build_general_system_prompt()` dans `backend/bookslm.py` ; le mode documents dégrade
|
||||
proprement vers le mode général si aucun fichier n'est lisible.
|
||||
- Tests : `tests/test_bookslm.py` (+13 tests modes/documents/général) et
|
||||
`tests/frontend/ai.test.mjs` (+8 tests détection de contexte, clavier, actions, header).
|
||||
|
||||
- **#72 API publique documentée — OpenAPI 3.1 enrichie** — documentation interactive complète
|
||||
de l'API REST, générée automatiquement et enrichie.
|
||||
- Backend `backend/openapi_docs.py` : métadonnées de tags (18 catégories : System, Auth, Files,
|
||||
PDF, Vaults, Search, Bookmarks, Backups, Export, AI, BooksLM, Sharing, Webhooks, Conflicts,
|
||||
Admin, Plugins, Push, Frontend), assignation automatique des tags par préfixe de route
|
||||
(normalisation des tags des routeurs), schémas de sécurité (`bearerAuth`, `cookieAuth`),
|
||||
exemples de requête/réponse sur les endpoints clés, réponses d'erreur documentées
|
||||
(401/403/404/422/500), serveur et `externalDocs`.
|
||||
- `backend/schemas.py` : nouveaux `response_model` Pydantic pour les endpoints qui n'en avaient
|
||||
pas (recent, bookmarks, saved searches, backups, diff/restore/backlinks, pdf/info, replace,
|
||||
vaults, attachments, settings, config, AI keys/models, diagnostics, dashboard, webhooks,
|
||||
shares, conflicts, BooksLM context, AI status).
|
||||
- `backend/main.py` : `FastAPI(...)` enrichi (description Markdown, contact, licence, tags) et
|
||||
override `app.openapi` pour post-traiter le schéma ; page de documentation `/api` (HTML
|
||||
autonome listant les endpoints groupés par catégorie, liens vers `/docs`, `/redoc`,
|
||||
`/openapi.json`).
|
||||
- Frontend : entrée « API » dans le menu d'options (i18n FR/EN) ouvrant Swagger UI.
|
||||
- Tests : `tests/test_openapi.py` (58 tests — version 3.1, tags, sécurité, erreurs, exemples,
|
||||
schémas de réponse, pages `/api`, `/docs`, `/redoc`, `/openapi.json`).
|
||||
|
||||
- **#61 Plugins système au complet** — système de plugins permettant d'étendre ObsiGate
|
||||
(renderers personnalisés, filtres de recherche, actions d'éditeur), sandboxé pour la sécurité.
|
||||
- Backend `backend/plugins.py` : validation du manifest `plugin.json` (name regex lowercase,
|
||||
semver, hooks/permissions autorisés), stockage par vault `<vault>/.obsigate-plugins/`, lifecycle
|
||||
complet (install/uninstall/enable/disable via marker `.disabled`), validation ZIP à l'upload
|
||||
(anti path-traversal, max 100 fichiers, 500KB/fichier, 5MB upload), 9 endpoints `/api/plugins/*`
|
||||
(list/get/hooks/code/template + install/uninstall/enable/disable admin-gated), template API,
|
||||
scan au démarrage par vault (`get_plugin_registry().scan_vault`).
|
||||
- Frontend `frontend/js/plugins.js` : PluginManager (install/uninstall/enable/disable/view-code),
|
||||
sandbox d'exécution via Web Worker (code chargé par blob URL, protocole `postMessage` structuré,
|
||||
isolation DOM/localStorage/network selon permissions), hooks dispatch
|
||||
(`executeHook`, `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`,
|
||||
`onFileCreate`, `onFileDelete`, `onVaultMount`), UI Settings > Plugins.
|
||||
- Sécurité : manifest de permissions (`read_files`, `write_files`, `network_request`, …
|
||||
restreint), CSP stricte sans `importScripts`, limites de taille.
|
||||
- Tests : `tests/test_plugins.py` (44 tests — validation manifest, lifecycle manager,
|
||||
validation ZIP/directory, démarrage scan) + `tests/frontend/plugins.test.mjs`
|
||||
(21 tests JSDOM — protocole sandbox Worker, isolation DOM, lifecycle mirror).
|
||||
- CI : job lint ajoute `node plugins.test.mjs` aux tests JSDOM.
|
||||
- Docs : `docs/PLUGINS.md` (manifest, hooks, permissions, modèle de sécurité, API, guide).
|
||||
|
||||
### Documentation
|
||||
|
||||
- **#78 Excalidraw — doc utilisateur finalisée** : `.excalidraw` / `.excalidraw.md` ajoutés aux
|
||||
formats supportés (README FR/EN), nouvelle section « Diagrammes Excalidraw » dans le guide
|
||||
intégré (i18n FR/EN) et note de compatibilité avec le plugin Obsidian Excalidraw.
|
||||
- **Roadmap** : #78 marqué terminé (B5 extraction texte, C8 menu contextuel, F3 E2E
|
||||
`tests/e2e/excalidraw.spec.js`, H1-H3 docs) ; F2 non retenu. Points optionnels #61
|
||||
(dépôt communautaire de plugins) et #74 (D2/E4/H2/I2) explicitement marqués non retenus.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Assistant AI — sidebar qui ne s'ouvrait plus & erreur de contexte** :
|
||||
- Le bouton flottant passait le **chemin d'un fichier** comme répertoire de contexte, d'où
|
||||
l'erreur « Aucun fichier markdown trouvé dans ce dossier » à l'ouverture sur un document.
|
||||
Le contexte est maintenant détecté automatiquement (documents ouverts → mode documents,
|
||||
sinon mode général).
|
||||
- Un panneau précédemment masqué (`obsigate-bookslm-hidden=true` persisté en localStorage)
|
||||
restait caché lors d'une ouverture explicite → l'ouverture force désormais l'affichage.
|
||||
- **Ergonomie du header** : le sélecteur fournisseur/modèle était sur la même ligne que les
|
||||
actions et masquait ces dernières ; il est déplacé sur une seconde ligne dédiée (toolbar)
|
||||
pour que « nouvelle conversation / export / plein écran / fermer » restent toujours visibles.
|
||||
- **Clavier de la boîte de chat** : `Entrée` envoie la requête, `Ctrl+Entrée` insère un saut
|
||||
de ligne (au lieu de l'inverse), avec un texte d'aide sous le champ.
|
||||
|
||||
- **Outil AI de l'UI — fiabilisation** :
|
||||
- **BooksLM (chat IA par répertoire)** : les requêtes `context`/`chat` n'envoyaient pas le
|
||||
jeton d'authentification (échec 401 quand l'auth est activée) et la réponse SSE était
|
||||
mal parsée (le backend émet `{token: ...}`, le front lisait `data.content`) → les réponses
|
||||
n'apparaissaient jamais. Le message utilisateur envoyé était le placeholder assistant vide
|
||||
au lieu du texte saisi. Corrigé : requêtes authentifiées via `AuthManager` (retry 401),
|
||||
parsing SSE `event:`/`data:` avec support `token`/`error`, historique construit avant
|
||||
l'ajout du message courant, barre de progression basée sur `max_total_chars`.
|
||||
- **AI Editor** : le raccourci `Ctrl/Cmd+J` (complétion inline) était re-lié à chaque
|
||||
ouverture de l'éditeur (N listeners → N requêtes) ; un seul listener global est désormais
|
||||
posé. Les libellés de la barre d'outils AI étaient codés en dur (FR/EN mélangés) → i18n
|
||||
complet. La réécriture personnalisée utilisait `window.prompt()` → remplacée par une modale
|
||||
accessible (Échap annule, Ctrl/Cmd+Entrée valide).
|
||||
|
||||
- **Recherche cassée (régression plugins)** : `onSearchFilter()` était appelée avec 2 arguments
|
||||
au lieu de 3 (`frontend/js/search.js`), renvoyait `undefined`, faisait échouer le rendu des
|
||||
résultats et basculait silencieusement sur la recherche hors-ligne (0 résultat). Corrigé →
|
||||
la recherche full-text et l'autocomplétion refonctionnent (20 tests E2E).
|
||||
- **`.excalidraw.md` corrompu à l'ouverture** : Excalidraw émet `onChange` au montage, ce qui
|
||||
déclenchait un auto-save 2 s plus tard réécrivant le fichier au format JSON brut et détruisant
|
||||
le format plugin Obsidian (`frontend/excalidraw-editor.html` + `frontend/js/excalidraw-viewer.js`).
|
||||
Les changements initiaux sont désormais ignorés et le format `frontmatter + compressed-json`
|
||||
est préservé à la sauvegarde.
|
||||
- **Splash / tests E2E** : `window.__OBSIGATE_BOOTED` était posé avant la fin de `init()` — le
|
||||
splash disparaissait trop tôt et les tests E2E interagissaient avant que les handlers soient
|
||||
liés. Le flag est maintenant posé après résolution de `init()` (`frontend/js/app.js`), et
|
||||
`goHome()`/`login()` attendent ce signal.
|
||||
- **Tests E2E Excalidraw** : réécriture de `tests/e2e/excalidraw.spec.js` avec les sélecteurs
|
||||
réels (modale `.obsigate-modal` + `#file-ext-select`/`#file-name-input`, menu contextuel,
|
||||
`canvas.first()` pour les deux couches canvas).
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-09-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#74 Support PDF au complet** — `GET /api/file/{vault}/pdf/info` (métadonnées sans
|
||||
contenu), streaming avec HTTP Range / 206 Partial Content (rendu progressif des gros PDF),
|
||||
config `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s).
|
||||
16 tests supplémentaires (26 au total dans `test_pdf.py`).
|
||||
- **#64 MFA — WebAuthn** (second facteur en plus du TOTP) : enregistrement de clés de
|
||||
sécurité / biométrie (Windows Hello, Touch ID), assertion au login, gestion des clés dans
|
||||
« Sécurité du compte », codes de récupération émis à l'activation. Nouveau module
|
||||
`backend/auth/webauthn_mfa.py` (lib `webauthn==2.6.0`, challenges in-memory TTL 180s à
|
||||
usage unique). 10 tests avec authentificateur virtuel (CBOR réel, ECDSA P-256).
|
||||
i18n FR/EN (13 clés). Config : `OBSIGATE_WEBAUTHN_RP_ID` / `_RP_NAME` / `_ORIGINS`.
|
||||
- **#77 Desktop — port auto-increment** : `pick_free_port()` scanne 17890..17899 si le port
|
||||
est occupé (2 instances côte à côte possibles) + 3 tests Rust (19 au total côté desktop).
|
||||
- Docs : sections PDF README FR/EN (Range, /pdf/info), variables env, guide WebAuthn.
|
||||
- **#75 Split View — matrice E2E complète (37 tests)** : 16 tests `split-view.spec.js` (raccourcis,
|
||||
drag & drop, persistance, navigation clavier) + 21 tests `split-view-matrix.spec.js` couvrant
|
||||
100% des cas d'ouverture/fermeture : boutons ⊞→/⊞↓, menu contextuel (diviser bas, fermer, fermer
|
||||
les autres/à droite/tout, fermer le panneau, flyout « Déplacer vers… »), palette (diviser, focus
|
||||
suivant/précédent, reset), fermeture par X/double-clic/molette/Ctrl+W, auto-collapse d'un panneau
|
||||
vidé, plafond 4 panneaux, drag-to-split bas, pas de doublon à la réouverture.
|
||||
- Fix `scripts/run-e2e-local.sh` : chemins Windows via `cygpath` (les env vars MSYS n'étaient pas
|
||||
converties → vaults vides, les 16 tests split-view échouaient en local alors qu'ils passaient
|
||||
en CI).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **PDF stream 500** : `api_pdf_stream` plantait systématiquement (`NameError: current_user`
|
||||
non injecté — endpoint jamais couvert par un test). Désormais authentifié + testé.
|
||||
- **Indexation incrémentale des PDF** : le chemin watcher (`_index_single_file_sync`) lisait
|
||||
les PDFs en `read_text()` → contenu garbage indexé. Utilise maintenant `extract_pdf_text()`
|
||||
comme le scan complet.
|
||||
|
||||
---
|
||||
|
||||
@@ -18,6 +175,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
### Ajouté
|
||||
|
||||
- **Application bureau native (Tauri 2)** — Porte d'entrée desktop pour vaults Obsidian (`desktop/`)
|
||||
- **Dashboard administrateur (#71, backend)** — 4 endpoints admin-gated pour monitoring serveur
|
||||
- `GET /api/admin/stats` — CPU/RAM/Disk/Uptime via psutil
|
||||
- `GET /api/admin/audit` — 500 dernières entrées d'audit avec filtres `user`/`action`/`limit`/`offset`
|
||||
- `GET /api/admin/backup-stats` — compte + taille + age par vault
|
||||
- `GET /api/admin/stream` — Server-Sent Events qui push les stats toutes les 5s
|
||||
- Nouveau module `backend/admin.py` + 13 tests pytest
|
||||
- **Tests JSDOM PaneManager (#75, sous-tâche I2)** — 9 tests d'intégration frontend via JSDOM 30
|
||||
- Couvre : factory, init, open/activate/close, isolation entre panes, drag/drop smoke
|
||||
- Wired dans le CI Gitea (`.gitea/workflows/ci.yml`)
|
||||
- Shell Rust + Tauri 2 (tray icon, notifications OS, auto-update depuis Gitea)
|
||||
- Backend Python embarqué (python-embed) avec health check, démarrage ~2s
|
||||
- Sélecteur de dossiers natif, config persistante des vaults (type VAULT vs DIR)
|
||||
|
||||
+6
-2
@@ -31,8 +31,12 @@ RUN apt-get update \
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
# Copy pre-generated VERSION file (generated by build.sh before docker build)
|
||||
COPY backend/VERSION ./backend/VERSION
|
||||
# Bake version: build.sh/CI pre-generate backend/VERSION; backend/VERSION est
|
||||
# exclu du .dockerignore pour que le build utilise TOUJOURS l'ARG ci-dessous
|
||||
# (jamais un fichier stale "1.8.0-...-dirty" du contexte build qui afficherait
|
||||
# une vieille version au lieu du tag courant).
|
||||
ARG VERSION=2.1.0
|
||||
RUN test -f backend/VERSION || echo "$VERSION" > backend/VERSION
|
||||
|
||||
# Create non-root user for security + data directory for auth persistence
|
||||
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
- **🏷️ Tag cloud** : Filtrage par tags extraits des frontmatters YAML
|
||||
- **🔗 Wikilinks** : Les `[[liens internes]]` Obsidian sont cliquables
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
@@ -272,6 +273,8 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr
|
||||
| `OBSIGATE_REFRESH_TOKEN_TTL` | Durée de vie refresh token (secondes) | `2592000` |
|
||||
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Tentatives de login max par IP | `10` |
|
||||
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Fenêtre de rate limiting (secondes) | `900` |
|
||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
|
||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
|
||||
|
||||
### Volume pour la persistance
|
||||
|
||||
@@ -611,6 +614,23 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
|
||||
|
||||
Les opérateurs sont combinables : `tag:linux vault:IT ext:md serveur web`.
|
||||
|
||||
### Support PDF
|
||||
|
||||
Les fichiers PDF de vos vaults s'affichent en ligne dans le navigateur via le visualiseur PDF natif (iframe + `<embed>`).
|
||||
Le visualiseur streame le fichier via HTTP Range (206 Partial Content) — les gros PDF se chargent progressivement.
|
||||
Le texte est extrait à l'indexation (pypdf / pymupdf) — le contenu PDF est donc recherchable via la recherche full-text.
|
||||
Filtrez avec `ext:pdf` pour restreindre les résultats aux PDF.
|
||||
Les métadonnées (pages, titre, auteur) sont disponibles via `GET /api/file/{vault}/pdf/info` sans transférer le document.
|
||||
|
||||
**Limitations :** pas d'OCR (les PDF scannés ne sont pas recherchables), pas d'annotation, pas d'édition du PDF lui-même.
|
||||
|
||||
### Diagrammes Excalidraw
|
||||
|
||||
Les fichiers `.excalidraw` et `.excalidraw.md` s'ouvrent dans un éditeur visuel Excalidraw complet, intégré dans une iframe sandboxée — dessinez, modifiez et sauvegardez sans quitter ObsiGate.
|
||||
Les modifications sont sauvegardées automatiquement (2 s) ou avec `Ctrl+S` ; l'éditeur suit le thème clair/sombre.
|
||||
Le texte des éléments du diagramme est extrait à l'indexation : il est donc recherchable via la recherche full-text (`ext:excalidraw`).
|
||||
Les fichiers créés avec le **plugin Obsidian Excalidraw** (y compris le format `.excalidraw.md` compressé) sont compatibles.
|
||||
|
||||
### Raccourcis clavier
|
||||
|
||||
| Raccourci | Action |
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
- **🏷️ Tag Cloud** : Filtering by tags extracted from YAML frontmatters
|
||||
- **🔗 Wikilinks** : `[[internal links]]` from Obsidian are clickable
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -310,6 +311,8 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl
|
||||
| `OBSIGATE_REFRESH_TOKEN_TTL` | Refresh token lifetime (seconds) | `2592000` |
|
||||
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Max login attempts per IP | `10` |
|
||||
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Rate limiting window (seconds) | `900` |
|
||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
|
||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
|
||||
|
||||
>All these variables are documented in `.env.example`.
|
||||
|
||||
@@ -738,7 +741,24 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
|
||||
| `ext:<type>` | Filter by file type | `ext:md kubernetes` |
|
||||
| `"exact phrase"` | Phrase search | `tag:"multiple words"` |
|
||||
|
||||
Extension filter examples: `ext:sh` for bash scripts, `ext:py` for Python scripts, `ext:md` for Markdown files.
|
||||
Extension filter examples: `ext:sh` for bash scripts, `ext:py` for Python scripts, `ext:md` for Markdown files, `ext:pdf` for PDF documents, `ext:excalidraw` for Excalidraw diagrams (text-extracted content is indexed).
|
||||
|
||||
### PDF support
|
||||
|
||||
PDF files in your vaults are rendered inline in the browser via the native PDF viewer (iframe + `<embed>`).
|
||||
The viewer streams the file over HTTP Range requests (206 Partial Content), so large PDFs load progressively.
|
||||
Text is extracted on indexing (pypdf / pymupdf) so PDF content is searchable via the full-text search.
|
||||
Filter with `ext:pdf` to restrict results to PDFs.
|
||||
PDF metadata (pages, title, author) is available via `GET /api/file/{vault}/pdf/info` without transferring the document.
|
||||
|
||||
**Limitations:** no OCR (scanned PDFs aren't searchable), no annotation, no editing of the PDF itself.
|
||||
|
||||
### Excalidraw diagrams
|
||||
|
||||
`.excalidraw` and `.excalidraw.md` files open in a full visual Excalidraw editor embedded in a sandboxed iframe — draw, edit and save without leaving ObsiGate.
|
||||
Changes are saved automatically (2s debounce) or with `Ctrl+S`; the editor follows the light/dark theme.
|
||||
Text labels inside the diagram elements are extracted on indexing, so diagram content is searchable via the full-text search (`ext:excalidraw`).
|
||||
Files created with the **Obsidian Excalidraw plugin** (including the compressed `.excalidraw.md` format) are compatible.
|
||||
|
||||
Operators are combinable: `tag:linux vault:IT ext:md server web` searches for "server web" in Markdown files of the IT vault with the linux tag.
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# backend/admin.py
|
||||
# Admin Dashboard endpoints — system stats, audit logs, backup stats,
|
||||
# and Server-Sent Events stream for real-time widgets.
|
||||
#
|
||||
# All endpoints protected with require_admin.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from backend.audit import AUDIT_LOG_FILE, get_recent_entries
|
||||
from backend.auth.middleware import require_admin
|
||||
from backend.indexer import get_vault_data, index
|
||||
|
||||
logger = logging.getLogger("obsigate.admin")
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
# Server start time — set at module import. Approximates uptime even
|
||||
# if used before lifespan startup fully runs.
|
||||
_SERVER_START_TIME = time.time()
|
||||
|
||||
|
||||
def _server_uptime_seconds() -> float:
|
||||
return time.time() - _SERVER_START_TIME
|
||||
|
||||
|
||||
def _format_timestamp_for_stream(payload: dict) -> str:
|
||||
"""Serialize a payload as an SSE-compatible data line."""
|
||||
return json.dumps(payload, default=str, ensure_ascii=False)
|
||||
|
||||
|
||||
def _get_disk_stats() -> tuple[float, float]:
|
||||
"""Return (used_gb, total_gb) for the data volume or cwd fallback."""
|
||||
# Prefer /data mount when present (production), else use cwd
|
||||
target = "/data" if os.path.isdir("/data") else "."
|
||||
try:
|
||||
usage = shutil.disk_usage(target)
|
||||
used_gb = round(usage.used / (1024 ** 3), 2)
|
||||
total_gb = round(usage.total / (1024 ** 3), 2)
|
||||
return used_gb, total_gb
|
||||
except OSError as e:
|
||||
logger.warning(f"disk_usage failed for {target}: {e}")
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
def _count_active_sessions() -> int:
|
||||
"""Count currently-revoked-free active sessions.
|
||||
|
||||
Best-effort estimate: we don't keep an in-memory session store, so we
|
||||
fall back to 1 if the server is up. Useful for the dashboard tile.
|
||||
"""
|
||||
return 1
|
||||
|
||||
|
||||
# ── /api/admin/stats ────────────────────────────────────────────────────
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(_admin=Depends(require_admin)):
|
||||
"""Return real-time system stats for the admin dashboard."""
|
||||
import psutil
|
||||
|
||||
try:
|
||||
cpu_pct = psutil.cpu_percent(interval=None)
|
||||
except Exception:
|
||||
cpu_pct = 0.0
|
||||
|
||||
try:
|
||||
vm = psutil.virtual_memory()
|
||||
mem_used_mb = round(vm.used / (1024 ** 2), 1)
|
||||
mem_total_mb = round(vm.total / (1024 ** 2), 1)
|
||||
except Exception:
|
||||
mem_used_mb, mem_total_mb = 0.0, 0.0
|
||||
|
||||
disk_used_gb, disk_total_gb = _get_disk_stats()
|
||||
uptime_seconds = int(_server_uptime_seconds())
|
||||
active_sessions = _count_active_sessions()
|
||||
|
||||
return {
|
||||
"cpu_pct": cpu_pct,
|
||||
"mem_used_mb": mem_used_mb,
|
||||
"mem_total_mb": mem_total_mb,
|
||||
"disk_used_gb": disk_used_gb,
|
||||
"disk_total_gb": disk_total_gb,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"active_sessions": active_sessions,
|
||||
}
|
||||
|
||||
|
||||
# ── /api/admin/audit ───────────────────────────────────────────────────
|
||||
@router.get("/audit")
|
||||
async def get_admin_audit(
|
||||
user: str | None = Query(None, description="Filter by username (substring)"),
|
||||
action: str | None = Query(None, description="Filter by exact action"),
|
||||
limit: int = Query(500, ge=1, le=2000, description="Max entries"),
|
||||
offset: int = Query(0, ge=0, description="Skip first N entries"),
|
||||
_admin=Depends(require_admin),
|
||||
):
|
||||
"""Return recent audit log entries with optional filters."""
|
||||
entries = get_recent_entries(limit=offset + limit, action=action)
|
||||
|
||||
if user:
|
||||
needle = user.lower()
|
||||
entries = [e for e in entries if needle in str(e.get("username", "")).lower()]
|
||||
|
||||
if offset:
|
||||
entries = entries[offset:]
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"total": len(entries),
|
||||
"log_file": str(AUDIT_LOG_FILE),
|
||||
}
|
||||
|
||||
|
||||
# ── /api/admin/backup-stats ───────────────────────────────────────────
|
||||
def _scan_backups() -> list[dict]:
|
||||
"""Walk every vault's backup directory and return one row per .bak file."""
|
||||
rows: list[dict] = []
|
||||
for vault_name in list(index.keys()):
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
vault_backup_dir = backup_root / vault_name
|
||||
if not vault_backup_dir.exists():
|
||||
continue
|
||||
try:
|
||||
for fpath in vault_backup_dir.rglob("*.bak"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
try:
|
||||
st = fpath.stat()
|
||||
except OSError:
|
||||
continue
|
||||
# Backup filename: {orig}.{timestamp}.bak — split from the right
|
||||
name = fpath.name
|
||||
parts = name.rsplit(".", 2)
|
||||
if len(parts) < 3 or not parts[-2].isdigit():
|
||||
continue
|
||||
try:
|
||||
ts = int(parts[-2])
|
||||
except ValueError:
|
||||
continue
|
||||
rows.append({
|
||||
"vault": vault_name,
|
||||
"filename": name,
|
||||
"timestamp": ts,
|
||||
"size": st.st_size,
|
||||
})
|
||||
except OSError as e:
|
||||
logger.warning(f"Backup scan error in {vault_backup_dir}: {e}")
|
||||
rows.sort(key=lambda r: r["timestamp"], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/backup-stats")
|
||||
async def get_admin_backup_stats(_admin=Depends(require_admin)):
|
||||
"""Return backup statistics across all vaults."""
|
||||
rows = _scan_backups()
|
||||
now_ts = int(time.time())
|
||||
total_size_mb = round(sum(r["size"] for r in rows) / (1024 ** 2), 2)
|
||||
|
||||
by_vault: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
bucket = by_vault.setdefault(r["vault"], {"count": 0, "size_mb": 0.0})
|
||||
bucket["count"] += 1
|
||||
bucket["size_mb"] = round(bucket["size_mb"] + r["size"] / (1024 ** 2), 2)
|
||||
|
||||
if rows:
|
||||
newest_ts = rows[0]["timestamp"]
|
||||
oldest_ts = rows[-1]["timestamp"]
|
||||
newest_age_days = round((now_ts - newest_ts) / 86400, 2)
|
||||
oldest_age_days = round((now_ts - oldest_ts) / 86400, 2)
|
||||
else:
|
||||
newest_age_days = 0.0
|
||||
oldest_age_days = 0.0
|
||||
|
||||
return {
|
||||
"total_backups": len(rows),
|
||||
"total_size_mb": total_size_mb,
|
||||
"oldest_age_days": oldest_age_days,
|
||||
"newest_age_days": newest_age_days,
|
||||
"by_vault": by_vault,
|
||||
}
|
||||
|
||||
|
||||
# ── /api/admin/stream — Server-Sent Events ─────────────────────────────
|
||||
async def _stats_event_generator():
|
||||
"""Yield an SSE `stats` event with the current system stats."""
|
||||
try:
|
||||
import psutil
|
||||
# Prime cpu_percent so the first real measurement isn't 0.0
|
||||
psutil.cpu_percent(interval=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_keepalive = time.time()
|
||||
KEEPALIVE_INTERVAL = 15.0
|
||||
STATS_INTERVAL = 5.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
now = time.time()
|
||||
try:
|
||||
data = await asyncio.to_thread(_build_stats_payload)
|
||||
except Exception as e:
|
||||
logger.warning(f"SSE stats build failed: {e}")
|
||||
data = {"error": str(e)}
|
||||
|
||||
yield f"event: stats\ndata: {_format_timestamp_for_stream(data)}\n\n"
|
||||
|
||||
if now - last_keepalive >= KEEPALIVE_INTERVAL:
|
||||
last_keepalive = now
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
await asyncio.sleep(STATS_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("SSE stats stream cancelled")
|
||||
raise
|
||||
|
||||
|
||||
def _build_stats_payload() -> dict:
|
||||
"""Synchronous stats builder for the SSE generator."""
|
||||
import psutil
|
||||
cpu_pct = psutil.cpu_percent(interval=None)
|
||||
vm = psutil.virtual_memory()
|
||||
disk_used_gb, disk_total_gb = _get_disk_stats()
|
||||
return {
|
||||
"cpu_pct": cpu_pct,
|
||||
"mem_used_mb": round(vm.used / (1024 ** 2), 1),
|
||||
"mem_total_mb": round(vm.total / (1024 ** 2), 1),
|
||||
"disk_used_gb": disk_used_gb,
|
||||
"disk_total_gb": disk_total_gb,
|
||||
"uptime_seconds": int(_server_uptime_seconds()),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream_admin_stats(_admin=Depends(require_admin)):
|
||||
"""Server-Sent Events stream of system metrics, every 5 seconds."""
|
||||
return StreamingResponse(
|
||||
_stats_event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
+43
-4
@@ -1,6 +1,6 @@
|
||||
"""ObsiGate AI — Multi-provider AI service for editor enhancement.
|
||||
|
||||
Supports: DeepSeek, OpenRouter, Google Gemini.
|
||||
Supports: DeepSeek, OpenRouter, Google Gemini, Ollama, NVIDIA, QwenCloud, Xiaomi, Mistral.
|
||||
Configured via environment variables.
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,7 @@ import httpx
|
||||
|
||||
logger = logging.getLogger("obsigate.ai")
|
||||
|
||||
ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama"]
|
||||
ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama", "nvidia", "qwencloud", "xiaomi", "mistral"]
|
||||
|
||||
# Provider configurations — keys loaded from file or .env
|
||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
||||
@@ -61,6 +61,34 @@ def _load_provider_keys():
|
||||
"model": os.getenv("OLLAMA_MODEL", "qwen2.5-coder:1.5b"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"nvidia": {
|
||||
"api_key": get_ai_key("NVIDIA_API_KEY"),
|
||||
"base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"model": os.getenv("NVIDIA_MODEL", "meta/llama-3.1-405b-instruct"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"qwencloud": {
|
||||
"api_key": get_ai_key("QWENCLOUD_API_KEY"),
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"model": os.getenv("QWENCLOUD_MODEL", "qwen-max"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"xiaomi": {
|
||||
"api_key": get_ai_key("XIAOMI_API_KEY"),
|
||||
"base_url": os.getenv("XIAOMI_BASE_URL", "https://api.xiaomimimo.com/v1"),
|
||||
"model": os.getenv("XIAOMI_MODEL", "mimo-v2.5-pro"),
|
||||
# Xiaomi MiMo uses a dedicated `api-key` header (NOT Authorization: Bearer).
|
||||
# The `_call_deepseek_openrouter` helper substitutes {api_key} verbatim,
|
||||
# so we just emit the raw key value here.
|
||||
"auth_header": "{api_key}",
|
||||
"auth_header_name": "api-key",
|
||||
},
|
||||
"mistral": {
|
||||
"api_key": get_ai_key("MISTRAL_API_KEY"),
|
||||
"base_url": "https://api.mistral.ai/v1",
|
||||
"model": os.getenv("MISTRAL_MODEL", "mistral-large-latest"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
}
|
||||
|
||||
PROVIDERS = _load_provider_keys()
|
||||
@@ -86,13 +114,23 @@ def _get_provider_config(provider: ProviderName | None = None) -> dict:
|
||||
|
||||
async def _call_deepseek_openrouter(prompt: str, system: str, provider: ProviderName | None = None,
|
||||
temperature: float = 0.7, max_tokens: int = 2048) -> str:
|
||||
"""Call OpenAI-compatible API (DeepSeek, OpenRouter)."""
|
||||
"""Call OpenAI-compatible API (DeepSeek, OpenRouter, Xiaomi MiMo, etc.)."""
|
||||
cfg = _get_provider_config(provider)
|
||||
# Debug: log masked key to diagnose 401
|
||||
key_preview = cfg["api_key"][:8] + "..." + cfg["api_key"][-4:] if len(cfg["api_key"]) > 12 else "***"
|
||||
logger.info(f"AI call: provider={cfg['name']} model={cfg['model']} key={key_preview}")
|
||||
# Most providers use "Authorization: Bearer KEY". Some (Xiaomi MiMo) use a
|
||||
# dedicated header like "api-key: KEY". We support both via the
|
||||
# `auth_header_name` key in PROVIDERS — defaults to "Authorization".
|
||||
header_name = cfg.get("auth_header_name") or "Authorization"
|
||||
header_value = cfg["auth_header"].format(api_key=cfg["api_key"])
|
||||
# If the auth_header template doesn't include "Bearer " but the default
|
||||
# header is Authorization, prepend it. This preserves backward compatibility
|
||||
# for providers that store just the raw key.
|
||||
if header_name == "Authorization" and not header_value.lower().startswith("bearer "):
|
||||
header_value = "Bearer " + header_value
|
||||
headers = {
|
||||
"Authorization": cfg["auth_header"].format(api_key=cfg["api_key"]),
|
||||
header_name: header_value,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
@@ -139,6 +177,7 @@ async def ai_complete(prompt: str, provider: ProviderName | None = None) -> str:
|
||||
cfg = _get_provider_config(provider)
|
||||
if cfg["name"] == "gemini":
|
||||
return await _call_gemini(prompt, "You are a helpful assistant.")
|
||||
# All other providers use OpenAI-compatible format
|
||||
return await _call_deepseek_openrouter(prompt, "You are a helpful assistant.", provider)
|
||||
|
||||
|
||||
|
||||
+17
-2
@@ -25,12 +25,13 @@ from backend.ai import (
|
||||
ai_summarize,
|
||||
ai_translate,
|
||||
)
|
||||
from backend.schemas import AIStatusResponse
|
||||
|
||||
logger = logging.getLogger("obsigate.ai_routes")
|
||||
router = APIRouter(prefix="/api/ai", tags=["AI"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@router.get("/status", response_model=AIStatusResponse)
|
||||
async def api_status():
|
||||
"""Check if AI is configured and which providers are available.
|
||||
|
||||
@@ -42,6 +43,10 @@ async def api_status():
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
"openrouter": "OPENROUTER_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"nvidia": "NVIDIA_API_KEY",
|
||||
"qwencloud": "QWENCLOUD_API_KEY",
|
||||
"xiaomi": "XIAOMI_API_KEY",
|
||||
"mistral": "MISTRAL_API_KEY",
|
||||
}
|
||||
providers = {}
|
||||
for name, env_var in provider_keys.items():
|
||||
@@ -97,7 +102,8 @@ class AIRequest(BaseModel):
|
||||
instruction: str | None = Field(None, description="Custom instruction for rewrite")
|
||||
target_lang: str | None = Field(None, description="Target language for translation")
|
||||
tone: str | None = Field(None, description="Target tone (professional, casual, etc.)")
|
||||
provider: str | None = Field(None, description="AI provider override")
|
||||
provider: str | None = Field(None, description="AI provider override (e.g. 'deepseek', 'nvidia')")
|
||||
model: str | None = Field(None, description="Model name override for this request")
|
||||
|
||||
|
||||
class AIResponse(BaseModel):
|
||||
@@ -107,6 +113,12 @@ class AIResponse(BaseModel):
|
||||
|
||||
async def _handle(action, request: AIRequest):
|
||||
"""Wrapper with error handling."""
|
||||
from backend.ai import PROVIDERS
|
||||
# Apply per-request model override (saved/restored around the call)
|
||||
original_model = None
|
||||
if request.model and request.provider and request.provider in PROVIDERS:
|
||||
original_model = PROVIDERS[request.provider].get("model")
|
||||
PROVIDERS[request.provider]["model"] = request.model
|
||||
try:
|
||||
result = await action(request.text, request.provider)
|
||||
return AIResponse(result=result, provider=request.provider or "default")
|
||||
@@ -115,6 +127,9 @@ async def _handle(action, request: AIRequest):
|
||||
except Exception as e:
|
||||
logger.error(f"AI error: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"AI service error: {e!s}")
|
||||
finally:
|
||||
if original_model is not None and request.provider in PROVIDERS:
|
||||
PROVIDERS[request.provider]["model"] = original_model
|
||||
|
||||
|
||||
@router.post("/improve", response_model=AIResponse)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# backend/auth/mfa.py
|
||||
# Multi-Factor Authentication: TOTP + recovery codes.
|
||||
# TOTP via pyotp, recovery codes hashed with argon2 for single-use storage.
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
import pyotp
|
||||
|
||||
logger = logging.getLogger("obsigate.auth.mfa")
|
||||
|
||||
TOTP_ISSUER = "ObsiGate"
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
"""Generate a new TOTP secret (base32-encoded, 160 bits)."""
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def generate_qr_uri(secret: str, username: str, issuer: str = TOTP_ISSUER) -> str:
|
||||
"""Generate an otpauth:// URI for QR code generation."""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.provisioning_uri(name=username, issuer_name=issuer)
|
||||
|
||||
|
||||
def verify_totp(secret: str, code: str) -> bool:
|
||||
"""Verify a TOTP code with a ±1 window tolerance."""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code, valid_window=1)
|
||||
|
||||
|
||||
def generate_recovery_codes(n: int = 8) -> list[str]:
|
||||
"""Generate n human-readable recovery codes (XXXX-XXXX format)."""
|
||||
codes = []
|
||||
for _ in range(n):
|
||||
# 8 chars alphanumeric, grouped with dash for readability
|
||||
raw = secrets.token_hex(4).upper()
|
||||
code = f"{raw[:4]}-{raw[4:]}"
|
||||
codes.append(code)
|
||||
return codes
|
||||
|
||||
|
||||
def hash_recovery_code(code: str) -> str:
|
||||
"""Hash a recovery code for storage (SHA-256 for fast comparison).
|
||||
|
||||
We use SHA-256 instead of argon2 here because recovery codes are
|
||||
high-entropy random strings, not user-chosen passwords.
|
||||
"""
|
||||
return hashlib.sha256(code.upper().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def verify_recovery_code(code: str, hashed_codes: list[str]) -> int | None:
|
||||
"""Verify a recovery code against stored hashes.
|
||||
|
||||
Returns the index of the matched code (for removal), or None if invalid.
|
||||
Comparison is case-insensitive.
|
||||
"""
|
||||
code_hash = hash_recovery_code(code)
|
||||
for i, stored_hash in enumerate(hashed_codes):
|
||||
if secrets.compare_digest(code_hash, stored_hash):
|
||||
return i
|
||||
return None
|
||||
+407
-11
@@ -5,7 +5,7 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from backend.ratelimit import is_rate_limited
|
||||
@@ -20,6 +20,14 @@ from .jwt_handler import (
|
||||
is_token_revoked,
|
||||
revoke_token,
|
||||
)
|
||||
from .mfa import (
|
||||
generate_qr_uri,
|
||||
generate_recovery_codes,
|
||||
generate_secret,
|
||||
hash_recovery_code,
|
||||
verify_recovery_code,
|
||||
verify_totp,
|
||||
)
|
||||
from .middleware import is_auth_enabled, require_admin, require_auth
|
||||
from .password import hash_password, verify_password
|
||||
from .user_store import (
|
||||
@@ -136,16 +144,32 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
detail += f" ({remaining} tentative(s) restante(s))"
|
||||
raise HTTPException(401, detail)
|
||||
|
||||
# Success — clear rate limits and generate tokens
|
||||
record_login_success(body.username)
|
||||
# Success — clear rate limits
|
||||
rl_record_success(client_ip)
|
||||
|
||||
# If MFA is enabled, don't issue token yet — require second factor
|
||||
if user.get("mfa_enabled"):
|
||||
method = "totp" if user.get("mfa_secret") else _preferred_mfa_method(user)
|
||||
logger.info(f"User '{body.username}' login deferred — MFA required ({method})")
|
||||
return {
|
||||
"mfa_required": True,
|
||||
"mfa_method": method,
|
||||
"username": body.username,
|
||||
"remember_me": body.remember_me,
|
||||
}
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
|
||||
|
||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
|
||||
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
|
||||
record_login_success(username)
|
||||
|
||||
access_token = create_access_token(user)
|
||||
refresh_token, refresh_jti = create_refresh_token(body.username)
|
||||
refresh_token, refresh_jti = create_refresh_token(username)
|
||||
|
||||
# Set refresh token as HttpOnly cookie (path-restricted to /api/auth/refresh)
|
||||
max_age = 2592000 if body.remember_me else 604800 # 30d or 7d
|
||||
import os
|
||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
@@ -156,10 +180,7 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
secure=secure,
|
||||
path="/api/auth/refresh",
|
||||
)
|
||||
|
||||
logger.info(f"User '{body.username}' logged in")
|
||||
|
||||
# Set access token as cookie for same-origin requests (e.g. popout window)
|
||||
logger.info(f"User '{username}' logged in")
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=access_token,
|
||||
@@ -169,7 +190,6 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
secure=secure,
|
||||
path="/",
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
@@ -304,6 +324,382 @@ async def change_password(
|
||||
return {"message": "Mot de passe mis à jour"}
|
||||
|
||||
|
||||
# ── MFA endpoints ────────────────────────────────────────────────────
|
||||
|
||||
class MfaVerifyRequest(BaseModel):
|
||||
username: str
|
||||
code: str
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class MfaRecoveryRequest(BaseModel):
|
||||
username: str
|
||||
recovery_code: str
|
||||
|
||||
|
||||
class MfaDisableRequest(BaseModel):
|
||||
password: str
|
||||
code: str
|
||||
|
||||
|
||||
class MfaEnableRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/mfa/totp/setup")
|
||||
async def mfa_totp_setup(current_user=Depends(require_auth)):
|
||||
"""Generate a TOTP secret and QR URI for MFA setup.
|
||||
|
||||
Returns the secret and otpauth URI — client displays QR code.
|
||||
Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify.
|
||||
"""
|
||||
from .user_store import update_user
|
||||
secret = generate_secret()
|
||||
qr_uri = generate_qr_uri(secret, current_user["username"])
|
||||
# Store secret temporarily (not yet enabled)
|
||||
update_user(current_user["username"], {
|
||||
"mfa_secret_pending": secret,
|
||||
})
|
||||
return {
|
||||
"secret": secret,
|
||||
"qr_uri": qr_uri,
|
||||
"otpauth_uri": qr_uri,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mfa/totp/enable")
|
||||
async def mfa_totp_enable(
|
||||
req: MfaEnableRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Enable MFA after verifying the first TOTP code.
|
||||
|
||||
On success: generates recovery codes, enables MFA, returns recovery codes.
|
||||
"""
|
||||
from .user_store import get_user, update_user
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
secret = user.get("mfa_secret_pending")
|
||||
if not secret:
|
||||
raise HTTPException(400, "Aucune configuration MFA en cours. Commencez par /mfa/totp/setup")
|
||||
|
||||
if not verify_totp(secret, req.code):
|
||||
raise HTTPException(400, "Code TOTP invalide")
|
||||
|
||||
# Generate recovery codes
|
||||
recovery_codes = generate_recovery_codes()
|
||||
hashed_codes = [hash_recovery_code(c) for c in recovery_codes]
|
||||
|
||||
# Enable MFA
|
||||
update_user(current_user["username"], {
|
||||
"mfa_enabled": True,
|
||||
"mfa_secret": secret,
|
||||
"mfa_method": "totp",
|
||||
"mfa_recovery_codes": hashed_codes,
|
||||
"mfa_secret_pending": None, # clear pending
|
||||
})
|
||||
|
||||
logger.info(f"MFA enabled for user '{current_user['username']}'")
|
||||
return {
|
||||
"mfa_enabled": True,
|
||||
"recovery_codes": recovery_codes, # shown once, client must display/save
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mfa/totp/disable")
|
||||
async def mfa_totp_disable(
|
||||
req: MfaDisableRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Disable MFA. Requires current password + valid TOTP code."""
|
||||
from .user_store import get_user, update_user
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
if not user.get("mfa_enabled"):
|
||||
raise HTTPException(400, "MFA non activé")
|
||||
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(400, "Mot de passe incorrect")
|
||||
|
||||
if not verify_totp(user["mfa_secret"], req.code):
|
||||
raise HTTPException(400, "Code TOTP invalide")
|
||||
|
||||
update_user(current_user["username"], {
|
||||
"mfa_enabled": False,
|
||||
"mfa_secret": None,
|
||||
"mfa_method": None,
|
||||
"mfa_recovery_codes": [],
|
||||
})
|
||||
|
||||
logger.info(f"MFA disabled for user '{current_user['username']}'")
|
||||
return {"mfa_enabled": False}
|
||||
|
||||
|
||||
# ── WebAuthn endpoints (ROADMAP #64) ─────────────────────────────────
|
||||
|
||||
def _preferred_mfa_method(user: dict) -> str:
|
||||
"""Which second factor to offer at login: webauthn when keys exist, else totp."""
|
||||
if user.get("webauthn_credentials"):
|
||||
return "webauthn"
|
||||
return "totp"
|
||||
|
||||
|
||||
class WebauthnRegisterRequest(BaseModel):
|
||||
credential: dict
|
||||
label: str = ""
|
||||
|
||||
|
||||
class WebauthnVerifyRequest(BaseModel):
|
||||
username: str
|
||||
credential: dict
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class WebauthnRemoveRequest(BaseModel):
|
||||
credential_id: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/register/options")
|
||||
async def mfa_webauthn_register_options(current_user=Depends(require_auth)):
|
||||
"""Start WebAuthn key enrolment — returns publicKey creation options for the browser."""
|
||||
from .webauthn_mfa import begin_registration
|
||||
|
||||
options = begin_registration(current_user["username"],
|
||||
current_user.get("display_name", ""))
|
||||
return {"options": options}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/register")
|
||||
async def mfa_webauthn_register(
|
||||
req: WebauthnRegisterRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Verify the created credential, store it, and enable MFA if not already on.
|
||||
|
||||
Returns recovery codes when MFA is newly enabled (they were never issued).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_registration
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
try:
|
||||
record = complete_registration(current_user["username"], req.credential,
|
||||
label=req.label)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
|
||||
raise HTTPException(400, "Validation du credential WebAuthn échouée")
|
||||
|
||||
record["registered_at"] = datetime.now(timezone.utc).isoformat()
|
||||
creds = list(user.get("webauthn_credentials", []))
|
||||
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
|
||||
creds.append(record)
|
||||
|
||||
updates: dict = {"webauthn_credentials": creds}
|
||||
issued_recovery: list[str] = []
|
||||
if not user.get("mfa_enabled"):
|
||||
issued_recovery = generate_recovery_codes()
|
||||
updates.update({
|
||||
"mfa_enabled": True,
|
||||
"mfa_method": "webauthn",
|
||||
"mfa_recovery_codes": [hash_recovery_code(c) for c in issued_recovery],
|
||||
})
|
||||
update_user(current_user["username"], updates)
|
||||
|
||||
logger.info(f"WebAuthn credential registered for user '{current_user['username']}' "
|
||||
f"({record['label']})")
|
||||
return {
|
||||
"ok": True,
|
||||
"credentials": user_credentials_response(creds),
|
||||
"mfa_enabled": True,
|
||||
"recovery_codes": issued_recovery,
|
||||
}
|
||||
|
||||
|
||||
def user_credentials_response(creds: list[dict]) -> list[dict]:
|
||||
from .webauthn_mfa import credentials_for_api
|
||||
return credentials_for_api(creds)
|
||||
|
||||
|
||||
@router.get("/mfa/webauthn/credentials")
|
||||
async def mfa_webauthn_list(current_user=Depends(require_auth)):
|
||||
from .user_store import get_user
|
||||
user = get_user(current_user["username"])
|
||||
return {"credentials": user_credentials_response(user.get("webauthn_credentials", []))}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/credentials/remove")
|
||||
async def mfa_webauthn_remove(
|
||||
req: WebauthnRemoveRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Remove a WebAuthn key. Requires password. Disables MFA if no second factor remains."""
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import clear_pending
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(400, "Mot de passe incorrect")
|
||||
|
||||
creds = [c for c in user.get("webauthn_credentials", [])
|
||||
if c.get("credential_id") != req.credential_id]
|
||||
if len(creds) == len(user.get("webauthn_credentials", [])):
|
||||
raise HTTPException(404, "Credential inconnu")
|
||||
|
||||
updates: dict = {"webauthn_credentials": creds}
|
||||
if not creds and not user.get("mfa_secret"):
|
||||
updates.update({"mfa_enabled": False, "mfa_method": None, "mfa_recovery_codes": []})
|
||||
elif not creds and user.get("mfa_secret"):
|
||||
updates["mfa_method"] = "totp"
|
||||
update_user(current_user["username"], updates)
|
||||
clear_pending(current_user["username"])
|
||||
return {"ok": True, "credentials": user_credentials_response(creds),
|
||||
"mfa_enabled": bool(updates.get("mfa_enabled", user.get("mfa_enabled"))) and bool(creds or user.get("mfa_secret"))}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/options")
|
||||
async def mfa_webauthn_login_options(body: dict = Body(...)):
|
||||
"""Unauthenticated: begin the login assertion for a user with registered keys.
|
||||
|
||||
Enumeration-safe: always 200 — returns null options (caller falls back to
|
||||
TOTP/recovery UI) when the user has no WebAuthn key or MFA is off.
|
||||
"""
|
||||
username = str(body.get("username", ""))
|
||||
user = get_user(username)
|
||||
creds = (user or {}).get("webauthn_credentials", [])
|
||||
if not user or not user.get("mfa_enabled") or not creds:
|
||||
return {"mfa_method": "totp", "options": None}
|
||||
|
||||
from .webauthn_mfa import begin_authentication
|
||||
options = begin_authentication(username, creds)
|
||||
if options is None:
|
||||
return {"mfa_method": "totp", "options": None}
|
||||
return {"mfa_method": "webauthn", "options": options}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/verify")
|
||||
async def mfa_webauthn_verify(
|
||||
body: WebauthnVerifyRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
):
|
||||
"""Unauthenticated: verify the WebAuthn assertion and issue JWT tokens."""
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_authentication
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
hash_password("dummy_timing_protection")
|
||||
raise HTTPException(401, "Identifiants invalides")
|
||||
if not user.get("mfa_enabled"):
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
creds = user.get("webauthn_credentials", [])
|
||||
try:
|
||||
credential_id = body.credential.get("id", "")
|
||||
stored = next((c for c in creds if c.get("credential_id") == credential_id), None)
|
||||
if stored is None:
|
||||
raise ValueError("Credential non enregistré")
|
||||
new_count = complete_authentication(body.username, body.credential, stored)
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"WebAuthn verification failed for {body.username}: {e}")
|
||||
raise HTTPException(401, "Vérification WebAuthn échouée")
|
||||
|
||||
updated = [dict(c) for c in creds]
|
||||
for c in updated:
|
||||
if c.get("credential_id") == body.credential.get("id"):
|
||||
c["sign_count"] = new_count
|
||||
update_user(body.username, {"webauthn_credentials": updated})
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
|
||||
|
||||
@router.get("/mfa/status")
|
||||
async def mfa_status(current_user=Depends(require_auth)):
|
||||
"""Return current user's MFA status."""
|
||||
from .user_store import get_user
|
||||
user = get_user(current_user["username"])
|
||||
return {
|
||||
"mfa_enabled": user.get("mfa_enabled", False),
|
||||
"mfa_method": user.get("mfa_method"),
|
||||
"totp_enabled": bool(user.get("mfa_secret")),
|
||||
"webauthn_credentials": len(user.get("webauthn_credentials", [])),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mfa/totp/verify")
|
||||
async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: Request):
|
||||
"""Verify TOTP code during login (second factor).
|
||||
|
||||
Called after login returns mfa_required=true.
|
||||
On success: issues JWT tokens.
|
||||
"""
|
||||
from .user_store import get_user
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
# Timing-safe: simulate work
|
||||
hash_password("dummy_timing_protection")
|
||||
raise HTTPException(401, "Identifiants invalides")
|
||||
|
||||
if not user.get("mfa_enabled") or not user.get("mfa_secret"):
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
if not verify_totp(user["mfa_secret"], body.code):
|
||||
raise HTTPException(401, "Code TOTP invalide")
|
||||
|
||||
# Clear IP rate limit on success
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
|
||||
|
||||
@router.post("/mfa/recovery")
|
||||
async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, request: Request):
|
||||
"""Login with a recovery code (when TOTP device is unavailable).
|
||||
|
||||
Each recovery code is single-use.
|
||||
"""
|
||||
from .user_store import get_user, update_user
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
hash_password("dummy_timing_protection")
|
||||
raise HTTPException(401, "Identifiants invalides")
|
||||
|
||||
if not user.get("mfa_enabled"):
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
hashed_codes = user.get("mfa_recovery_codes", [])
|
||||
if not hashed_codes:
|
||||
raise HTTPException(400, "Aucun code de récupération disponible")
|
||||
|
||||
idx = verify_recovery_code(body.recovery_code, hashed_codes)
|
||||
if idx is None:
|
||||
raise HTTPException(401, "Code de récupération invalide")
|
||||
|
||||
# Remove used recovery code (single-use)
|
||||
hashed_codes.pop(idx)
|
||||
update_user(body.username, {"mfa_recovery_codes": hashed_codes})
|
||||
|
||||
# Clear IP rate limit
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
|
||||
logger.info(f"User '{body.username}' logged in via recovery code")
|
||||
return _issue_tokens(user, body.username, False, response)
|
||||
|
||||
|
||||
# ── Admin endpoints ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/users")
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# backend/auth/webauthn_mfa.py
|
||||
# WebAuthn support for MFA (ROADMAP #64): security keys / platform biometrics.
|
||||
# Thin wrapper over the `webauthn` library with an in-memory challenge store.
|
||||
#
|
||||
# Credentials are persisted in users.json under "webauthn_credentials":
|
||||
# [{ "credential_id": <b64url>, "public_key": <b64url>, "sign_count": int,
|
||||
# "transports": [...], "label": str, "registered_at": iso }]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
options_to_json,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers import (
|
||||
base64url_to_bytes,
|
||||
bytes_to_base64url,
|
||||
parse_authentication_credential_json,
|
||||
parse_registration_credential_json,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.auth.webauthn")
|
||||
|
||||
# Challenge lifetime: clients have 3 minutes to complete the ceremony.
|
||||
CHALLENGE_TTL_SECONDS = 180
|
||||
|
||||
# In-memory pending challenges: key -> (challenge_bytes, expires_at)
|
||||
_pending: dict[str, tuple[bytes, float]] = {}
|
||||
|
||||
|
||||
def rp_id() -> str:
|
||||
return os.environ.get("OBSIGATE_WEBAUTHN_RP_ID", "localhost")
|
||||
|
||||
|
||||
def rp_name() -> str:
|
||||
return os.environ.get("OBSIGATE_WEBAUTHN_RP_NAME", "ObsiGate")
|
||||
|
||||
|
||||
def expected_origins() -> list[str]:
|
||||
raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS", "http://localhost")
|
||||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||
|
||||
|
||||
def _prune_expired() -> None:
|
||||
now = time.time()
|
||||
for key in [k for k, (_, exp) in _pending.items() if exp < now]:
|
||||
_pending.pop(key, None)
|
||||
|
||||
|
||||
def _store_challenge(key: str) -> bytes:
|
||||
_prune_expired()
|
||||
challenge = secrets.token_bytes(32)
|
||||
_pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS)
|
||||
return challenge
|
||||
|
||||
|
||||
def _take_challenge(key: str) -> bytes | None:
|
||||
"""Pop a challenge (single-use). Returns None if missing/expired."""
|
||||
_prune_expired()
|
||||
entry = _pending.pop(key, None)
|
||||
return entry[0] if entry else None
|
||||
|
||||
|
||||
def clear_pending(username: str) -> None:
|
||||
"""Drop all pending challenges for a user (e.g. after enable/disable)."""
|
||||
for key in [k for k in _pending if k.startswith(f"{username}:")]:
|
||||
_pending.pop(key, None)
|
||||
|
||||
|
||||
# ── Registration (enrol a key in settings) ─────────────────────────────
|
||||
|
||||
def begin_registration(username: str, display_name: str) -> dict:
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id(),
|
||||
rp_name=rp_name(),
|
||||
user_name=username,
|
||||
user_display_name=display_name or username,
|
||||
challenge=_store_challenge(f"{username}:register"),
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
)
|
||||
return _finalize_options(options)
|
||||
|
||||
|
||||
def complete_registration(username: str, credential_json: dict[str, Any],
|
||||
label: str = "") -> dict:
|
||||
challenge = _take_challenge(f"{username}:register")
|
||||
if challenge is None:
|
||||
raise ValueError("Session d'enregistrement expirée — recommencez")
|
||||
|
||||
credential = parse_registration_credential_json(credential_json)
|
||||
verification = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id(),
|
||||
expected_origin=expected_origins(),
|
||||
)
|
||||
|
||||
transports = credential.response.transports or []
|
||||
label = (label or str(credential_json.get("label") or "")).strip() or "Security key"
|
||||
record = {
|
||||
"credential_id": bytes_to_base64url(verification.credential_id),
|
||||
"public_key": bytes_to_base64url(verification.credential_public_key),
|
||||
"sign_count": int(verification.sign_count),
|
||||
"transports": [str(t) for t in transports],
|
||||
"label": label[:60],
|
||||
}
|
||||
return record
|
||||
|
||||
|
||||
# ── Authentication (assertion at login) ────────────────────────────────
|
||||
|
||||
def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
|
||||
if not credentials:
|
||||
return None
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
allow = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
|
||||
for c in credentials
|
||||
]
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id(),
|
||||
challenge=_store_challenge(f"{username}:login"),
|
||||
allow_credentials=allow,
|
||||
)
|
||||
return _finalize_options(options)
|
||||
|
||||
|
||||
def complete_authentication(
|
||||
username: str,
|
||||
credential_json: dict[str, Any],
|
||||
stored: dict,
|
||||
) -> int:
|
||||
"""Verify an assertion. Returns the new sign_count. Raises ValueError on failure."""
|
||||
challenge = _take_challenge(f"{username}:login")
|
||||
if challenge is None:
|
||||
raise ValueError("Session expirée — rechargez la page")
|
||||
|
||||
credential = parse_authentication_credential_json(credential_json)
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id(),
|
||||
expected_origin=expected_origins(),
|
||||
credential_public_key=base64url_to_bytes(stored["public_key"]),
|
||||
credential_current_sign_count=int(stored.get("sign_count", 0)),
|
||||
)
|
||||
return int(verification.new_sign_count)
|
||||
|
||||
|
||||
def credentials_for_api(credentials: list[dict]) -> list[dict]:
|
||||
"""Sanitized view for the settings UI (no public keys)."""
|
||||
return [
|
||||
{
|
||||
"credential_id": c.get("credential_id"),
|
||||
"label": c.get("label", "Security key"),
|
||||
"transports": c.get("transports", []),
|
||||
"registered_at": c.get("registered_at"),
|
||||
}
|
||||
for c in credentials
|
||||
]
|
||||
|
||||
|
||||
def _finalize_options(options) -> dict:
|
||||
import json
|
||||
|
||||
return json.loads(options_to_json(options))
|
||||
@@ -0,0 +1,408 @@
|
||||
"""BooksLM — Context collection and caching for directory-scoped AI chat.
|
||||
|
||||
Collects markdown files from an Obsidian vault directory, applies secret
|
||||
redaction, builds a system prompt with file contents, and caches results
|
||||
for repeated queries.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.secret_redactor import redact_file_content
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm")
|
||||
|
||||
# ── Configuration limits ──
|
||||
BOOKSLM_MAX_FILES = int(os.getenv("BOOKSLM_MAX_FILES", "200"))
|
||||
BOOKSLM_MAX_TOTAL_CHARS = int(os.getenv("BOOKSLM_MAX_TOTAL_CHARS", "200000"))
|
||||
BOOKSLM_MAX_FILE_CHARS = int(os.getenv("BOOKSLM_MAX_FILE_CHARS", "30000"))
|
||||
|
||||
# ── Cache ──
|
||||
_cache: dict[str, dict[str, Any]] = {}
|
||||
_CACHE_TTL = 300 # seconds
|
||||
|
||||
|
||||
def _cache_key(vault_path: Path, directory: str, file_mtimes: list[tuple[str, float]]) -> str:
|
||||
"""Build a SHA-256 cache key from vault+directory+file modification times."""
|
||||
raw = json.dumps({
|
||||
"vault": str(vault_path),
|
||||
"dir": directory,
|
||||
"mtimes": sorted(file_mtimes),
|
||||
}, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def _should_skip(name: str) -> bool:
|
||||
"""Return True if this file/directory name should be skipped."""
|
||||
skip_prefixes = (".",)
|
||||
skip_names = {"_attachments", "node_modules", ".git", ".obsidian", "__pycache__"}
|
||||
if name in skip_names:
|
||||
return True
|
||||
return bool(any(name.startswith(p) for p in skip_prefixes))
|
||||
|
||||
|
||||
def _file_priority(path: Path) -> tuple[int, float]:
|
||||
"""Sort key: README/index first, then by modification time descending.
|
||||
|
||||
Returns (priority_group, -mtime) so that:
|
||||
- Group 0: README* and index* files (come first)
|
||||
- Group 1: all other files (come after)
|
||||
Within each group, newer files come first.
|
||||
"""
|
||||
name_lower = path.stem.lower()
|
||||
if name_lower.startswith("readme") or name_lower.startswith("index"):
|
||||
group = 0
|
||||
else:
|
||||
group = 1
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = 0.0
|
||||
return (group, -mtime)
|
||||
|
||||
|
||||
def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any]:
|
||||
"""Walk a directory recursively, collect .md files with content.
|
||||
|
||||
Args:
|
||||
vault_path: Absolute path to the vault root.
|
||||
directory: Relative directory path within the vault (empty = root).
|
||||
|
||||
Returns:
|
||||
Dict with keys: files, total_chars, file_count, directory_tree.
|
||||
"""
|
||||
target_dir = (vault_path / directory).resolve() if directory else vault_path.resolve()
|
||||
vault_resolved = vault_path.resolve()
|
||||
|
||||
# Safety: ensure target is within vault
|
||||
try:
|
||||
target_dir.relative_to(vault_resolved)
|
||||
except ValueError:
|
||||
logger.warning(f"Directory outside vault: {target_dir}")
|
||||
return {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
|
||||
|
||||
if not target_dir.exists() or not target_dir.is_dir():
|
||||
return {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
|
||||
|
||||
# Check cache
|
||||
file_mtimes: list[tuple[str, float]] = []
|
||||
md_files: list[Path] = []
|
||||
try:
|
||||
for p in target_dir.rglob("*"):
|
||||
# Skip hidden dirs/files and special dirs
|
||||
parts = p.relative_to(target_dir).parts
|
||||
if any(_should_skip(part) for part in parts):
|
||||
continue
|
||||
if p.is_file() and p.suffix.lower() == ".md":
|
||||
md_files.append(p)
|
||||
try:
|
||||
file_mtimes.append((str(p.relative_to(target_dir)), p.stat().st_mtime))
|
||||
except OSError:
|
||||
file_mtimes.append((str(p.relative_to(target_dir)), 0.0))
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied scanning {target_dir}")
|
||||
return {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
|
||||
|
||||
key = _cache_key(vault_resolved, directory, file_mtimes)
|
||||
if key in _cache:
|
||||
cached = _cache[key]
|
||||
if time.time() - cached.get("_ts", 0) < _CACHE_TTL:
|
||||
logger.debug(f"Cache hit for {directory}")
|
||||
return {k: v for k, v in cached.items() if k != "_ts"}
|
||||
|
||||
# Sort by priority: README/index first, then by mtime descending
|
||||
md_files.sort(key=_file_priority)
|
||||
|
||||
# Apply limits
|
||||
collected: list[dict[str, Any]] = []
|
||||
total_chars = 0
|
||||
|
||||
for p in md_files:
|
||||
if len(collected) >= BOOKSLM_MAX_FILES:
|
||||
break
|
||||
if total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
|
||||
break
|
||||
|
||||
rel_path = str(p.relative_to(vault_resolved)).replace("\\", "/")
|
||||
try:
|
||||
content = p.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.warning(f"Cannot read {rel_path}: {e}")
|
||||
continue
|
||||
|
||||
# Redact secrets
|
||||
content = redact_file_content(content, rel_path)
|
||||
|
||||
# Truncate if too long
|
||||
if len(content) > BOOKSLM_MAX_FILE_CHARS:
|
||||
content = content[:BOOKSLM_MAX_FILE_CHARS] + "\n\n[... tronqué]"
|
||||
|
||||
remaining = BOOKSLM_MAX_TOTAL_CHARS - total_chars
|
||||
if len(content) > remaining:
|
||||
content = content[:remaining] + "\n\n[... tronqué]"
|
||||
|
||||
title = p.stem.replace("-", " ").replace("_", " ").title()
|
||||
file_type = "markdown"
|
||||
|
||||
collected.append({
|
||||
"path": rel_path,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"type": file_type,
|
||||
})
|
||||
total_chars += len(content)
|
||||
|
||||
# Build directory tree
|
||||
dir_tree = _build_directory_tree(target_dir, vault_resolved)
|
||||
|
||||
result = {
|
||||
"files": collected,
|
||||
"total_chars": total_chars,
|
||||
"file_count": len(collected),
|
||||
"directory_tree": dir_tree,
|
||||
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
|
||||
"max_files": BOOKSLM_MAX_FILES,
|
||||
"scope": "directory",
|
||||
}
|
||||
|
||||
# Store in cache
|
||||
_cache[key] = {**result, "_ts": time.time()}
|
||||
logger.info(f"Collected {len(collected)} files ({total_chars} chars) from {directory or '/'}")
|
||||
return result
|
||||
|
||||
|
||||
def _file_entry(target: Path, rel_path: str, remaining: int) -> dict[str, Any] | None:
|
||||
"""Read, redact and truncate a single file into a context entry."""
|
||||
suffix = target.suffix.lower()
|
||||
try:
|
||||
if suffix == ".pdf":
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
content = extract_pdf_text(target)
|
||||
file_type = "pdf"
|
||||
else:
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
file_type = "markdown"
|
||||
except Exception as e:
|
||||
logger.warning(f"Cannot read {rel_path}: {e}")
|
||||
return None
|
||||
|
||||
content = redact_file_content(content, rel_path)
|
||||
if len(content) > BOOKSLM_MAX_FILE_CHARS:
|
||||
content = content[:BOOKSLM_MAX_FILE_CHARS] + "\n\n[... tronqué]"
|
||||
if len(content) > remaining:
|
||||
content = content[:remaining] + "\n\n[... tronqué]"
|
||||
|
||||
return {
|
||||
"path": rel_path,
|
||||
"title": target.stem.replace("-", " ").replace("_", " ").title(),
|
||||
"content": content,
|
||||
"type": file_type,
|
||||
}
|
||||
|
||||
|
||||
def collect_files_context(
|
||||
vault_path: Path,
|
||||
rel_paths: list[str],
|
||||
scope: str = "documents",
|
||||
) -> dict[str, Any]:
|
||||
"""Collect an explicit list of files (open documents) as AI context.
|
||||
|
||||
Unlike :func:`collect_directory_context`, this reads only the requested
|
||||
relative paths (markdown or PDF), never the whole directory. Paths outside
|
||||
the vault are silently ignored.
|
||||
|
||||
Args:
|
||||
vault_path: Absolute path to the vault root.
|
||||
rel_paths: Relative file paths within the vault (in display order).
|
||||
scope: Context label exposed to the UI ("documents").
|
||||
|
||||
Returns:
|
||||
Same shape as :func:`collect_directory_context`.
|
||||
"""
|
||||
vault_resolved = vault_path.resolve()
|
||||
collected: list[dict[str, Any]] = []
|
||||
total_chars = 0
|
||||
seen: set[str] = set()
|
||||
|
||||
for rel in rel_paths:
|
||||
if len(collected) >= BOOKSLM_MAX_FILES or total_chars >= BOOKSLM_MAX_TOTAL_CHARS:
|
||||
break
|
||||
if not rel or rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
try:
|
||||
target = (vault_resolved / rel).resolve()
|
||||
target.relative_to(vault_resolved)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if not target.is_file():
|
||||
continue
|
||||
entry = _file_entry(target, rel, BOOKSLM_MAX_TOTAL_CHARS - total_chars)
|
||||
if entry is None:
|
||||
continue
|
||||
collected.append(entry)
|
||||
total_chars += len(entry["content"])
|
||||
|
||||
return {
|
||||
"files": collected,
|
||||
"total_chars": total_chars,
|
||||
"file_count": len(collected),
|
||||
"directory_tree": "",
|
||||
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
|
||||
"max_files": BOOKSLM_MAX_FILES,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
def empty_context(scope: str = "general") -> dict[str, Any]:
|
||||
"""Return an empty context payload (used by the General assistant)."""
|
||||
return {
|
||||
"files": [],
|
||||
"total_chars": 0,
|
||||
"file_count": 0,
|
||||
"directory_tree": "",
|
||||
"max_total_chars": BOOKSLM_MAX_TOTAL_CHARS,
|
||||
"max_files": BOOKSLM_MAX_FILES,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
def _build_directory_tree(target_dir: Path, vault_root: Path) -> str:
|
||||
"""Build a text representation of the directory tree (dirs + .md files)."""
|
||||
lines: list[str] = []
|
||||
try:
|
||||
for p in sorted(target_dir.rglob("*")):
|
||||
parts = p.relative_to(target_dir).parts
|
||||
if any(_should_skip(part) for part in parts):
|
||||
continue
|
||||
if p.is_dir():
|
||||
depth = len(p.relative_to(target_dir).parts)
|
||||
lines.append(f"{' ' * depth}{p.name}/")
|
||||
elif p.is_file() and p.suffix.lower() == ".md":
|
||||
depth = len(p.relative_to(target_dir).parts)
|
||||
lines.append(f"{' ' * depth}{p.name}")
|
||||
except PermissionError:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_system_prompt(context: dict[str, Any], scope: str = "directory") -> str:
|
||||
"""Build a system prompt for document-scoped AI chat.
|
||||
|
||||
Args:
|
||||
context: Output of collect_directory_context()/collect_files_context().
|
||||
scope: "directory" (whole folder) or "documents" (open files).
|
||||
|
||||
Returns:
|
||||
System prompt string with file contents.
|
||||
"""
|
||||
files = context.get("files", [])
|
||||
file_count = context.get("file_count", 0)
|
||||
total_chars = context.get("total_chars", 0)
|
||||
|
||||
# Rough token estimate (1 token ≈ 4 chars)
|
||||
est_tokens = total_chars // 4
|
||||
token_warning = ""
|
||||
if est_tokens > 100_000:
|
||||
token_warning = f"\n⚠️ Attention : le contexte est très volumineux (~{est_tokens:,} tokens estimés). Les réponses peuvent être moins précises.\n"
|
||||
|
||||
if scope == "documents":
|
||||
role = (
|
||||
"Tu es un assistant de recherche documentaire intégré à ObsiGate. "
|
||||
"Tu réponds UNIQUEMENT en te basant sur les documents ouverts par l'utilisateur ci-dessous. "
|
||||
"Cite tes sources avec le nom du fichier quand tu utilises une information. "
|
||||
"Si l'information ne se trouve pas dans les documents, dis-le clairement."
|
||||
)
|
||||
context_label = "📄 Documents ouverts"
|
||||
else:
|
||||
role = (
|
||||
"Tu es un assistant de recherche documentaire. "
|
||||
"Tu réponds UNIQUEMENT en te basant sur les documents fournis ci-dessous. "
|
||||
"Cite tes sources avec le nom du fichier quand tu utilises une information. "
|
||||
"Si l'information ne se trouve pas dans les documents, dis-le clairement."
|
||||
)
|
||||
context_label = "📚 Contexte"
|
||||
|
||||
prompt = (
|
||||
f"{role}"
|
||||
f"\n\n{context_label} : {file_count} fichier(s) ({total_chars:,} caractères)"
|
||||
f"{token_warning}\n"
|
||||
)
|
||||
|
||||
# Directory tree
|
||||
tree = context.get("directory_tree", "")
|
||||
if tree:
|
||||
prompt += f"\n📂 Arborescence du dossier :\n```\n{tree}\n```\n"
|
||||
|
||||
# File contents
|
||||
prompt += "\n---\n"
|
||||
for f in files:
|
||||
prompt += f"\n## 📄 {f['title']} (`{f['path']}`)\n\n{f['content']}\n\n---\n"
|
||||
|
||||
prompt += "\nFin du contexte. Réponds à la question de l'utilisateur en te basant uniquement sur ces documents."
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
GENERAL_SYSTEM_PROMPT = """Tu es l'assistant intégré d'ObsiGate, une application web auto-hébergée pour consulter, rechercher et éditer des vaults Obsidian (Markdown).
|
||||
|
||||
Tes deux rôles :
|
||||
1. **Aider sur l'application** : expliquer la navigation, la recherche (full-text, filtres `tag:`, `created:`, `path:`), l'éditeur (CodeMirror, autosave, raccourcis), les onglets et le split view, les sauvegardes et la restauration, le partage public, l'export (HTML/Markdown/ePub/PDF), Mermaid, Excalidraw, les plugins, les thèmes, le mode hors-ligne, le MFA, etc.
|
||||
2. **Proposer des actions concrètes** : créer un fichier ou un dossier dans un vault.
|
||||
|
||||
Quand l'utilisateur demande explicitement de créer un fichier, inclus EXACTEMENT un bloc de ce type dans ta réponse (et rien d'autre à l'intérieur du bloc) :
|
||||
|
||||
```obsigate-action
|
||||
{"action": "create_file", "vault": "<nom du vault>", "path": "<chemin/relatif.md>", "content": "<contenu markdown>"}
|
||||
```
|
||||
|
||||
Pour créer un dossier :
|
||||
|
||||
```obsigate-action
|
||||
{"action": "create_directory", "vault": "<nom du vault>", "path": "<chemin/relatif>"}
|
||||
```
|
||||
|
||||
Règles :
|
||||
- Ne propose une action que si l'utilisateur la demande explicitement.
|
||||
- Explique en une phrase ce que fait l'action avant le bloc.
|
||||
- Utilise un chemin relatif se terminant par `.md` pour un fichier.
|
||||
- N'invente jamais un nom de vault : utilise l'un des vaults disponibles listés ci-dessous.
|
||||
- Réponds dans la langue de l'utilisateur, de façon concise et structurée (Markdown).
|
||||
"""
|
||||
|
||||
|
||||
def build_general_system_prompt(vaults: list[str] | None = None) -> str:
|
||||
"""System prompt for the General assistant (app help + actions)."""
|
||||
prompt = GENERAL_SYSTEM_PROMPT
|
||||
if vaults:
|
||||
prompt += "\nVaults disponibles : " + ", ".join(sorted(vaults)) + "\n"
|
||||
else:
|
||||
prompt += "\nAucun vault n'est actuellement configuré.\n"
|
||||
return prompt
|
||||
|
||||
|
||||
def invalidate_cache(vault_path: Path | None = None, directory: str | None = None) -> int:
|
||||
"""Invalidate cache entries.
|
||||
|
||||
Args:
|
||||
vault_path: If provided, only invalidate entries for this vault.
|
||||
directory: If provided, only invalidate entries for this directory.
|
||||
|
||||
Returns:
|
||||
Number of cache entries removed.
|
||||
"""
|
||||
if vault_path is None and directory is None:
|
||||
count = len(_cache)
|
||||
_cache.clear()
|
||||
return count
|
||||
|
||||
to_remove = list(_cache)
|
||||
for k in to_remove:
|
||||
del _cache[k]
|
||||
return len(to_remove)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""BooksLM API routes — directory-scoped AI chat for Obsidian vaults."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.bookslm import (
|
||||
build_general_system_prompt,
|
||||
build_system_prompt,
|
||||
collect_directory_context,
|
||||
collect_files_context,
|
||||
empty_context,
|
||||
)
|
||||
from backend.indexer import get_vault_data, index
|
||||
from backend.schemas import BooksLMContextResponse
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm_routes")
|
||||
router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"])
|
||||
|
||||
VALID_MODES = {"directory", "documents", "general"}
|
||||
|
||||
|
||||
# ── Request models ──
|
||||
|
||||
|
||||
class BooksLMContextRequest(BaseModel):
|
||||
vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
|
||||
directory: str = Field(default="", description="Relative directory path within the vault")
|
||||
mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
|
||||
context_files: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Relative file paths to use as context (documents mode)",
|
||||
)
|
||||
|
||||
|
||||
class BooksLMChatRequest(BaseModel):
|
||||
vault: str | None = Field(default=None, description="Vault name (required for directory/documents modes)")
|
||||
directory: str = Field(default="", description="Relative directory path within the vault")
|
||||
mode: str = Field(default="directory", description="Context mode: 'directory', 'documents' or 'general'")
|
||||
context_files: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Relative file paths to use as context (documents mode)",
|
||||
)
|
||||
message: str = Field(description="User message")
|
||||
conversation_history: list[dict[str, str]] = Field(
|
||||
default_factory=list,
|
||||
description="Previous conversation turns [{role, content}]",
|
||||
)
|
||||
provider: str | None = Field(
|
||||
default=None,
|
||||
description="AI provider override (e.g. 'deepseek', 'openrouter', 'gemini', 'nvidia', 'xiaomi', 'mistral', 'qwencloud'). "
|
||||
"If not set, uses DEFAULT_PROVIDER.",
|
||||
)
|
||||
model: str | None = Field(
|
||||
default=None,
|
||||
description="Model name to use for this request. If not set, uses the provider's default model.",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_mode(mode: str | None) -> str:
|
||||
mode = (mode or "directory").lower()
|
||||
return mode if mode in VALID_MODES else "directory"
|
||||
|
||||
|
||||
def _resolve_vault_path(vault: str | None, current_user):
|
||||
"""Validate vault access and return (vault_name, vault_path)."""
|
||||
if not vault:
|
||||
raise HTTPException(status_code=400, detail="Champ 'vault' requis pour ce contexte")
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault}'")
|
||||
vault_data = get_vault_data(vault)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault}' not found")
|
||||
return vault, Path(vault_data["path"])
|
||||
|
||||
|
||||
def _build_context(mode: str, vault_path: Path | None, directory: str, context_files: list[str]):
|
||||
"""Collect the context payload for the requested mode."""
|
||||
if mode == "general":
|
||||
return empty_context("general")
|
||||
if mode == "documents":
|
||||
ctx = collect_files_context(vault_path, context_files, scope="documents") # type: ignore[arg-type]
|
||||
# No open document could be read → degrade gracefully to General.
|
||||
return ctx if ctx["file_count"] else empty_context("general")
|
||||
return collect_directory_context(vault_path, directory) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ── Endpoints ──
|
||||
|
||||
|
||||
@router.post("/context", response_model=BooksLMContextResponse)
|
||||
async def api_bookslm_context(
|
||||
req: BooksLMContextRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Collect context for the AI assistant.
|
||||
|
||||
Three modes are supported:
|
||||
|
||||
* ``directory`` — every supported file under a vault directory;
|
||||
* ``documents`` — only the explicitly listed open documents;
|
||||
* ``general`` — no document context (assistant for the app itself).
|
||||
"""
|
||||
mode = _normalize_mode(req.mode)
|
||||
if mode == "general":
|
||||
return empty_context("general")
|
||||
|
||||
_resolve_vault_path(req.vault, current_user)
|
||||
vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
|
||||
return _build_context(mode, vault_path, req.directory, req.context_files)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chat",
|
||||
response_class=StreamingResponse,
|
||||
responses={200: {"content": {"text/event-stream": {}}, "description": "SSE token stream"}},
|
||||
)
|
||||
async def api_bookslm_chat(
|
||||
req: BooksLMChatRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Chat with the AI assistant.
|
||||
|
||||
Builds a system prompt from the selected context (directory, open
|
||||
documents or general app knowledge), then streams the provider's answer
|
||||
as Server-Sent Events.
|
||||
"""
|
||||
mode = _normalize_mode(req.mode)
|
||||
|
||||
vault_path: Path | None = None
|
||||
if mode != "general":
|
||||
_resolve_vault_path(req.vault, current_user)
|
||||
vault_path = Path(get_vault_data(req.vault)["path"]) # type: ignore[index]
|
||||
|
||||
# Collect context
|
||||
context = _build_context(mode, vault_path, req.directory, req.context_files)
|
||||
effective_mode = context.get("scope", mode)
|
||||
|
||||
# Build system prompt
|
||||
if effective_mode == "general":
|
||||
system_prompt = build_general_system_prompt(list(index.keys()))
|
||||
elif effective_mode == "documents":
|
||||
system_prompt = build_system_prompt(context, scope="documents")
|
||||
else:
|
||||
if context["file_count"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
|
||||
system_prompt = build_system_prompt(context, scope="directory")
|
||||
|
||||
# Call AI provider
|
||||
from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini
|
||||
|
||||
# Build messages with conversation history
|
||||
messages_text = ""
|
||||
if req.conversation_history:
|
||||
for turn in req.conversation_history:
|
||||
role = turn.get("role", "user")
|
||||
content = turn.get("content", "")
|
||||
if role == "user":
|
||||
messages_text += f"\n\nUtilisateur : {content}"
|
||||
elif role == "assistant":
|
||||
messages_text += f"\n\nAssistant : {content}"
|
||||
|
||||
# Current message
|
||||
user_prompt = req.message
|
||||
if messages_text:
|
||||
user_prompt = f"Historique de la conversation :{messages_text}\n\nQuestion actuelle : {req.message}"
|
||||
|
||||
async def generate_sse():
|
||||
try:
|
||||
# Resolve provider: explicit override wins, else default.
|
||||
# Fall back to first available if the requested one isn't configured.
|
||||
cfg_name = (req.provider or DEFAULT_PROVIDER).lower()
|
||||
if cfg_name not in PROVIDERS or not PROVIDERS[cfg_name].get("api_key"):
|
||||
# Try next available provider
|
||||
for pname, pcfg in PROVIDERS.items():
|
||||
if pcfg.get("api_key") and pname != "gemini":
|
||||
cfg_name = pname
|
||||
break
|
||||
else:
|
||||
# No provider available at all
|
||||
err = "Aucun fournisseur AI configuré (clés API manquantes)"
|
||||
error_data = json.dumps({"error": err}, ensure_ascii=False)
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
return
|
||||
|
||||
# Optional per-request model override
|
||||
original_model = None
|
||||
if req.model and cfg_name in PROVIDERS:
|
||||
original_model = PROVIDERS[cfg_name].get("model")
|
||||
PROVIDERS[cfg_name]["model"] = req.model
|
||||
try:
|
||||
if cfg_name == "gemini":
|
||||
response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096)
|
||||
else:
|
||||
response = await _call_deepseek_openrouter(
|
||||
user_prompt, system_prompt,
|
||||
provider=cfg_name,
|
||||
temperature=0.3,
|
||||
max_tokens=4096,
|
||||
)
|
||||
finally:
|
||||
# Restore the original model so other calls aren't affected
|
||||
if original_model is not None and cfg_name in PROVIDERS:
|
||||
PROVIDERS[cfg_name]["model"] = original_model
|
||||
|
||||
# Send the full response as a single SSE event
|
||||
data = json.dumps({"token": response, "provider": cfg_name, "model": req.model or PROVIDERS.get(cfg_name, {}).get("model", "")}, ensure_ascii=False)
|
||||
yield f"event: message\ndata: {data}\n\n"
|
||||
yield "event: done\ndata: {}\n\n"
|
||||
except Exception as e:
|
||||
logger.error(f"BooksLM chat error: {e}")
|
||||
error_data = json.dumps({"error": str(e)}, ensure_ascii=False)
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate_sse(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
Export utilities for ObsiGate — standalone HTML, Markdown bundle (.zip) and ePub.
|
||||
|
||||
Each function converts vault content (markdown notes) into a self-contained
|
||||
downloadable artifact. Rendering reuses ``mistune`` (already a core dependency)
|
||||
so no extra markdown engine is required. The ePub generator builds a minimal
|
||||
but valid EPUB 3 container by hand (``zipfile`` only) so there is no hard
|
||||
dependency on ``ebooklib``.
|
||||
|
||||
All functions raise :class:`ExportError` on any failure (missing file, binary
|
||||
content, unreadable source, ...) so callers can translate to HTTP errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import html as html_mod
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
|
||||
logger = logging.getLogger("obsigate.export")
|
||||
|
||||
|
||||
class ExportError(Exception):
|
||||
"""Raised when an export operation cannot be completed."""
|
||||
|
||||
|
||||
# Cached mistune renderer (singleton) — mirrors backend.main's configuration.
|
||||
_markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
|
||||
# File extensions considered "safe" text files for markdown-like exports.
|
||||
_MARKDOWN_EXTS = {".md", ".markdown", ".mdown", ".mkd", ".txt"}
|
||||
|
||||
_HTML_CSS = """
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
max-width: 760px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
line-height: 1.7;
|
||||
color: #1a1a2e;
|
||||
font-size: 16px;
|
||||
}
|
||||
h1 { font-size: 28px; border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }
|
||||
h2 { font-size: 22px; margin-top: 28px; border-bottom: 1px solid #ddd; padding-bottom: 4px; }
|
||||
h3 { font-size: 18px; margin-top: 24px; }
|
||||
h4, h5, h6 { font-size: 16px; margin-top: 20px; }
|
||||
p { margin: 10px 0; }
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
code { font-size: 14px; background: #f5f5f5; padding: 2px 5px; border-radius: 3px; }
|
||||
pre code { background: none; padding: 0; }
|
||||
a { color: #4f46e5; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
img { max-width: 100%; border-radius: 4px; }
|
||||
blockquote { border-left: 3px solid #ccc; padding-left: 14px; color: #555; margin: 12px 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 14px; }
|
||||
th { background: #f0f0f0; font-weight: 600; }
|
||||
ul, ol { margin: 8px 0; padding-left: 24px; }
|
||||
li { margin: 2px 0; }
|
||||
hr { border: none; border-top: 1px solid #ddd; margin: 20px 0; }
|
||||
nav.export-nav {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
margin: 16px 0 28px;
|
||||
background: #fafafa;
|
||||
}
|
||||
nav.export-nav h2 { border: none; margin: 0 0 8px; font-size: 15px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
nav.export-nav ul { margin: 0; padding-left: 20px; }
|
||||
.wikilink-missing { color: #c0392b; }
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""URL-safe slug from arbitrary text (Unicode-aware)."""
|
||||
text = html_mod.unescape(re.sub(r"<[^>]+>", "", text))
|
||||
text = text.lower()
|
||||
text = unicodedata.normalize("NFD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
cleaned = []
|
||||
for ch in text:
|
||||
if unicodedata.category(ch).startswith(("L", "N")) or ch in (" ", "-"):
|
||||
cleaned.append(ch)
|
||||
text = re.sub(r"\s+", "-", "".join(cleaned))
|
||||
text = re.sub(r"-+", "-", text)
|
||||
return text.strip("-") or "document"
|
||||
|
||||
|
||||
def _resolve(vault_path: Path, file_path: Path | str) -> Path:
|
||||
"""Resolve *file_path* against *vault_path*, accepting absolute or relative.
|
||||
|
||||
Returns an absolute :class:`Path`. Does not require the file to exist.
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
if file_path.is_absolute():
|
||||
return file_path
|
||||
return (Path(vault_path) / file_path).resolve()
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
"""Read a text file, raising :class:`ExportError` on missing/binary content."""
|
||||
if not path.exists() or not path.is_file():
|
||||
raise ExportError(f"File not found: {path}")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as e:
|
||||
raise ExportError(f"Cannot read file {path}: {e}") from e
|
||||
if b"\x00" in raw[:4096]:
|
||||
raise ExportError(f"File appears to be binary: {path}")
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _strip_frontmatter(text: str) -> tuple[str, dict]:
|
||||
"""Split frontmatter from a markdown document.
|
||||
|
||||
Returns ``(body, metadata)``. Falls back gracefully when no frontmatter.
|
||||
"""
|
||||
try:
|
||||
post = frontmatter.loads(text)
|
||||
return post.content, dict(post.metadata or {})
|
||||
except Exception:
|
||||
return text, {}
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""ASCII-safe, filename-safe download name."""
|
||||
cleaned = "".join(
|
||||
c for c in name if c.isascii() and (c.isalnum() or c in " _-.")
|
||||
).strip()
|
||||
return cleaned or "document"
|
||||
|
||||
|
||||
def _collect_markdown_files(vault_path: Path) -> list[Path]:
|
||||
"""List all markdown files in the vault, sorted by relative path."""
|
||||
vault_path = Path(vault_path)
|
||||
results = []
|
||||
if not vault_path.is_dir():
|
||||
return results
|
||||
for p in sorted(vault_path.rglob("*")):
|
||||
if p.is_file() and p.suffix.lower() in _MARKDOWN_EXTS:
|
||||
results.append(p)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown → HTML (shared by HTML and ePub exporters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _inline_images(md: str, file_dir: Path, vault_path: Path) -> str:
|
||||
"""Replace image references with base64 data URIs.
|
||||
|
||||
Supports standard markdown ```` and Obsidian embeds
|
||||
``![[image.png]]`` / ``![[image.png|300]]``. Sources are resolved relative
|
||||
to the note's directory, then the vault root.
|
||||
"""
|
||||
candidates = [file_dir, vault_path]
|
||||
|
||||
def _to_data_uri(src: str) -> str:
|
||||
src = src.strip().strip("<>")
|
||||
if src.startswith(("http://", "https://", "data:")):
|
||||
return src # leave remote/data URLs untouched
|
||||
src_path = None
|
||||
for base in candidates:
|
||||
candidate = (base / src).resolve()
|
||||
if candidate.is_file():
|
||||
src_path = candidate
|
||||
break
|
||||
if src_path is None:
|
||||
return src # unresolved — leave as-is
|
||||
try:
|
||||
data = src_path.read_bytes()
|
||||
mime = mimetypes.guess_type(str(src_path))[0] or "application/octet-stream"
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{mime};base64,{b64}"
|
||||
except OSError:
|
||||
return src
|
||||
|
||||
# Obsidian embeds: ![[path]] or ![[path|size]]
|
||||
def _embed(match):
|
||||
inner = match.group(1).strip()
|
||||
target = inner.split("|", 1)[0].strip()
|
||||
return f"})"
|
||||
|
||||
md = re.sub(r"!\[\[([^\]]+)\]\]", _embed, md)
|
||||
|
||||
# Standard markdown images
|
||||
def _img(match):
|
||||
alt = match.group(1)
|
||||
src = match.group(2)
|
||||
return f"})"
|
||||
|
||||
md = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", _img, md)
|
||||
return md
|
||||
|
||||
|
||||
def _convert_wikilinks(md: str, vault_path: Path, current: Path) -> str:
|
||||
"""Convert ``[[Note]]`` / ``[[Note|display]]`` to cross-file HTML links."""
|
||||
md_files = {p.stem.lower(): p for p in _collect_markdown_files(vault_path)}
|
||||
|
||||
def _replace(match):
|
||||
target = match.group(1).strip()
|
||||
display = (match.group(2) or target).strip()
|
||||
|
||||
# Same-document anchor
|
||||
if target.startswith("#"):
|
||||
return f'<a href="#{_slugify(target[1:])}">{html_mod.escape(display)}</a>'
|
||||
|
||||
# Resolve to another file — link to its exported .html sibling
|
||||
key = Path(target).stem.lower()
|
||||
if key in md_files:
|
||||
href = f"{_slugify(md_files[key].stem)}.html"
|
||||
return f'<a href="{href}">{html_mod.escape(display)}</a>'
|
||||
|
||||
return f'<span class="wikilink-missing">{html_mod.escape(display)}</span>'
|
||||
|
||||
return re.sub(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]", _replace, md)
|
||||
|
||||
|
||||
def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> str:
|
||||
"""Render raw markdown to an HTML fragment (images inlined, wikilinks resolved)."""
|
||||
md = _inline_images(md, file_dir, vault_path)
|
||||
md = _convert_wikilinks(md, vault_path, current)
|
||||
return _markdown(md)
|
||||
|
||||
|
||||
def _build_nav(vault_path: Path, current: Path) -> str:
|
||||
"""Build a navigation block linking to every other markdown note in the vault."""
|
||||
items = []
|
||||
for p in _collect_markdown_files(vault_path):
|
||||
if p.resolve() == Path(current).resolve():
|
||||
continue
|
||||
stem = p.stem
|
||||
items.append(f'<li><a href="{_slugify(stem)}.html">{html_mod.escape(stem)}</a></li>')
|
||||
if not items:
|
||||
return ""
|
||||
return (
|
||||
'<nav class="export-nav"><h2>Notes</h2><ul>'
|
||||
+ "".join(items)
|
||||
+ "</ul></nav>"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public exporters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def export_html(vault_path: Path, file_path: Path) -> bytes:
|
||||
"""Convert a markdown note into a standalone HTML document.
|
||||
|
||||
Images are inlined as base64 data URIs, wikilinks become cross-file links,
|
||||
and a navigation block lists every other note in the vault.
|
||||
|
||||
Returns:
|
||||
UTF-8 encoded HTML as bytes.
|
||||
"""
|
||||
vault_path = Path(vault_path)
|
||||
path = _resolve(vault_path, file_path)
|
||||
raw = _read_text(path)
|
||||
body, metadata = _strip_frontmatter(raw)
|
||||
title = str(metadata.get("title") or path.stem)
|
||||
|
||||
rendered = _render_body(body, path.parent, vault_path, path)
|
||||
nav = _build_nav(vault_path, path)
|
||||
|
||||
html_doc = f"""<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="generator" content="ObsiGate">
|
||||
<title>{html_mod.escape(title)}</title>
|
||||
<style>{_HTML_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>{html_mod.escape(title)}</h1></header>
|
||||
{nav}
|
||||
{rendered}
|
||||
</body>
|
||||
</html>"""
|
||||
return html_doc.encode("utf-8")
|
||||
|
||||
|
||||
def export_md_bundle(vault_path: Path, directory_path: Path) -> bytes:
|
||||
"""Zip a directory (or single file) of markdown, preserving structure.
|
||||
|
||||
Returns:
|
||||
ZIP archive as bytes.
|
||||
"""
|
||||
vault_path = Path(vault_path)
|
||||
path = _resolve(vault_path, directory_path)
|
||||
|
||||
if not path.exists():
|
||||
raise ExportError(f"Path not found: {path}")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
if path.is_dir():
|
||||
base = path
|
||||
for p in sorted(path.rglob("*")):
|
||||
if p.is_file():
|
||||
arcname = p.relative_to(base).as_posix()
|
||||
zf.write(p, arcname=arcname)
|
||||
# Avoid emitting an empty (invalid) zip for an empty directory
|
||||
if not any(p.is_file() for p in path.rglob("*")):
|
||||
zf.writestr(".empty", "")
|
||||
else:
|
||||
zf.write(path, arcname=path.name)
|
||||
except OSError as e:
|
||||
raise ExportError(f"Cannot create bundle: {e}") from e
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
||||
"""Convert a markdown note into a minimal, valid EPUB 3 container.
|
||||
|
||||
Uses only ``zipfile`` + ``mistune`` (no ``ebooklib`` dependency). Images
|
||||
are inlined as base64 so the ePub is fully self-contained.
|
||||
|
||||
Returns:
|
||||
EPUB archive as bytes.
|
||||
"""
|
||||
vault_path = Path(vault_path)
|
||||
path = _resolve(vault_path, file_path)
|
||||
raw = _read_text(path)
|
||||
body, metadata = _strip_frontmatter(raw)
|
||||
title = str(metadata.get("title") or path.stem)
|
||||
author = str(metadata.get("author") or "ObsiGate")
|
||||
|
||||
rendered = _render_body(body, path.parent, vault_path, path)
|
||||
|
||||
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())}"
|
||||
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
|
||||
<head>
|
||||
<title>{html_mod.escape(title)}</title>
|
||||
<style>{_HTML_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{html_mod.escape(title)}</h1>
|
||||
{rendered}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# EPUB requires a valid XHTML-ish body; strip our HTML5-only doctype is not
|
||||
# needed since we emit XML directly above. Escape any bare ampersands in
|
||||
# text nodes outside tags is delegated to mistune's own escaping.
|
||||
content_opf = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="bookid">{html_mod.escape(uid)}</dc:identifier>
|
||||
<dc:title>{html_mod.escape(title)}</dc:title>
|
||||
<dc:creator>{html_mod.escape(author)}</dc:creator>
|
||||
<dc:language>fr</dc:language>
|
||||
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="chapter1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="chapter1"/>
|
||||
</spine>
|
||||
</package>"""
|
||||
|
||||
toc_ncx = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head>
|
||||
<meta name="dtb:uid" content="{html_mod.escape(uid)}"/>
|
||||
</head>
|
||||
<docTitle><text>{html_mod.escape(title)}</text></docTitle>
|
||||
<navMap>
|
||||
<navPoint id="navpoint-1" playOrder="1">
|
||||
<navLabel><text>{html_mod.escape(title)}</text></navLabel>
|
||||
<content src="chapter1.xhtml"/>
|
||||
</navPoint>
|
||||
</navMap>
|
||||
</ncx>"""
|
||||
|
||||
container_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>"""
|
||||
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
# `mimetype` must be the first entry, stored (uncompressed).
|
||||
zf.writestr(
|
||||
zipfile.ZipInfo("mimetype"),
|
||||
"application/epub+zip",
|
||||
compress_type=zipfile.ZIP_STORED,
|
||||
)
|
||||
zf.writestr("META-INF/container.xml", container_xml)
|
||||
zf.writestr("OEBPS/content.opf", content_opf)
|
||||
zf.writestr("OEBPS/toc.ncx", toc_ncx)
|
||||
zf.writestr("OEBPS/chapter1.xhtml", xhtml)
|
||||
except OSError as e:
|
||||
raise ExportError(f"Cannot create ePub: {e}") from e
|
||||
|
||||
return buffer.getvalue()
|
||||
+208
-9
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -28,6 +29,9 @@ _async_index_lock: asyncio.Lock | None = None # initialized lazily
|
||||
# (e.g. the inverted index in search.py) can detect staleness.
|
||||
_index_generation: int = 0
|
||||
|
||||
# Timestamp of last full index rebuild (ISO format, empty if never built)
|
||||
_last_full_index_ts: str = ""
|
||||
|
||||
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
|
||||
_on_index_change: Callable[..., None] | None = None
|
||||
|
||||
@@ -197,6 +201,178 @@ def _extract_title(post: frontmatter.Post, filepath: Path) -> str:
|
||||
return str(title)
|
||||
|
||||
|
||||
_EXCALIDRAW_TEXT_FIELDS = ("text", "originalText", "label", "title")
|
||||
|
||||
|
||||
def extract_excalidraw_text_from_elements(elements: list[dict[str, Any]]) -> str:
|
||||
"""Concatenate all user-visible text from an Excalidraw elements list.
|
||||
|
||||
Iterates diagram elements and collects the text-bearing fields
|
||||
(``text`` for text elements, ``label``/``title`` for bound shapes,
|
||||
``originalText`` as the stable source of a text element). Non-text
|
||||
elements and geometry-only shapes contribute nothing. This gives the
|
||||
TF-IDF search engine human-readable content instead of raw JSON.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
for el in elements:
|
||||
if not isinstance(el, dict):
|
||||
continue
|
||||
collected = set()
|
||||
for field in _EXCALIDRAW_TEXT_FIELDS:
|
||||
val = el.get(field)
|
||||
if isinstance(val, str) and val.strip():
|
||||
collected.add(val.strip())
|
||||
if collected:
|
||||
chunks.append(" ".join(sorted(collected)))
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
_EXCALIDRAW_B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||
|
||||
|
||||
def _decompress_excalidraw(compressed: str) -> dict[str, Any] | None:
|
||||
"""Decompress the Obsidian ``compressed-json`` block of a .excalidraw.md file.
|
||||
|
||||
The Obsidian Excalidraw plugin stores scene data as an ``lz-string``
|
||||
``compressToBase64`` payload (LZ-based compression, alphabet = standard
|
||||
base64). We port the reference ``lz-string`` ``_decompress`` algorithm
|
||||
(bitsPerChar=6/base64 key, resetValue=32) in pure Python so indexation
|
||||
can recover the diagram text without the JS client. The port is validated
|
||||
against multiple JS-truth fixtures in ``test_excalidraw.py`` including
|
||||
accented text and edge cases. Returns the parsed scene dict, or None if
|
||||
the payload is not valid base64-lz or does not parse as JSON.
|
||||
|
||||
Only ``decompress`` is needed for indexation (read side); compression
|
||||
stays on the JS client.
|
||||
"""
|
||||
if not compressed:
|
||||
return None
|
||||
|
||||
length = len(compressed)
|
||||
|
||||
def _get_char_value(index: int) -> int:
|
||||
# Mirror JS: input.charAt(index), 0-indexed.
|
||||
ch = compressed[index] if 0 <= index < length else "="
|
||||
pos = _EXCALIDRAW_B64_ALPHABET.find(ch)
|
||||
return 0 if pos == -1 else pos
|
||||
|
||||
# ── Build a bit reader over the base64 characters ────────────────────
|
||||
# JS decompressFromBase64 calls _decompress(length, 32, getNextValue).
|
||||
# state = current 6-bit value + position mask + next char index.
|
||||
value = _get_char_value(0)
|
||||
position = 32 # resetValue
|
||||
index = 1
|
||||
|
||||
def _read_bit() -> int:
|
||||
nonlocal value, position, index
|
||||
resb = value & position
|
||||
position >>= 1
|
||||
if position == 0:
|
||||
position = 32
|
||||
value = _get_char_value(index)
|
||||
index += 1
|
||||
return 1 if resb > 0 else 0
|
||||
|
||||
def _read_bits(n: int) -> int:
|
||||
out = 0
|
||||
for i in range(n):
|
||||
out |= _read_bit() << i
|
||||
return out
|
||||
|
||||
# ── Decompressor state ────────────────────────────────────────────────
|
||||
dictionary: list[Any] = [0, 1, 2] # values 0,1,2 as in JS
|
||||
enlarge_in = 4
|
||||
dict_size = 4
|
||||
num_bits = 3
|
||||
|
||||
first = _read_bits(2)
|
||||
if first == 0:
|
||||
c = chr(_read_bits(8))
|
||||
elif first == 1:
|
||||
c = chr(_read_bits(16))
|
||||
elif first == 2:
|
||||
return None # empty stream
|
||||
else:
|
||||
return None
|
||||
|
||||
dictionary.append(c)
|
||||
w: str = c
|
||||
result = [c]
|
||||
|
||||
while True:
|
||||
if index > length:
|
||||
return None
|
||||
|
||||
c = _read_bits(num_bits)
|
||||
if c == 0:
|
||||
dictionary.append(chr(_read_bits(8)))
|
||||
dict_size += 1
|
||||
c = dict_size - 1
|
||||
enlarge_in -= 1
|
||||
elif c == 1:
|
||||
dictionary.append(chr(_read_bits(16)))
|
||||
dict_size += 1
|
||||
c = dict_size - 1
|
||||
enlarge_in -= 1
|
||||
elif c == 2:
|
||||
break # end of stream
|
||||
|
||||
if enlarge_in == 0:
|
||||
enlarge_in = 1 << num_bits
|
||||
num_bits += 1
|
||||
|
||||
if c < len(dictionary) and dictionary[c]:
|
||||
entry = dictionary[c]
|
||||
elif c == dict_size:
|
||||
entry = w + w[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
result.append(entry)
|
||||
dictionary.append(w + entry[0])
|
||||
dict_size += 1
|
||||
enlarge_in -= 1
|
||||
|
||||
w = entry
|
||||
if enlarge_in == 0:
|
||||
enlarge_in = 1 << num_bits
|
||||
num_bits += 1
|
||||
|
||||
text = "".join(result)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def extract_excalidraw_indexable(raw: str) -> str:
|
||||
"""Return indexable text content for a raw .excalidraw / .excalidraw.md file.
|
||||
|
||||
Supports both the pure JSON format (``type: "excalidraw"``) and the
|
||||
Obsidian ``compressed-json`` block. Falls back to ``""`` when neither
|
||||
format can be parsed so the file is still indexed (title only).
|
||||
"""
|
||||
# .excalidraw.md — Obsidian plugin embeds a compressed-json block
|
||||
if "excalidraw-plugin:" in raw:
|
||||
m = re.search(r"```compressed-json\n(.*?)\n```", raw, re.DOTALL)
|
||||
if m:
|
||||
parsed = _decompress_excalidraw(m.group(1).strip())
|
||||
if parsed:
|
||||
return extract_excalidraw_text_from_elements(parsed.get("elements") or [])
|
||||
return ""
|
||||
# Pure .excalidraw JSON
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
return ""
|
||||
if not isinstance(data, dict) or data.get("type") != "excalidraw":
|
||||
return ""
|
||||
return extract_excalidraw_text_from_elements(data.get("elements") or [])
|
||||
|
||||
|
||||
def parse_markdown_file(raw: str) -> frontmatter.Post:
|
||||
"""Parse markdown frontmatter, falling back to plain content if YAML is invalid.
|
||||
|
||||
@@ -292,6 +468,12 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
|
||||
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
tags: list[str] = []
|
||||
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
raw = extract_excalidraw_indexable(raw)
|
||||
tags: list[str] = []
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
tags: list[str] = []
|
||||
@@ -411,6 +593,10 @@ async def build_index(progress_callback=None) -> None:
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# Record timestamp of full index rebuild
|
||||
global _last_full_index_ts
|
||||
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Build attachment index
|
||||
from backend.attachment_indexer import build_attachment_index
|
||||
await build_attachment_index(vault_config)
|
||||
@@ -546,19 +732,32 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
|
||||
stat = fpath.stat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
|
||||
tags: list[str] = []
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
if ext == ".pdf":
|
||||
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
|
||||
raw = extract_pdf_text(fpath, max_chars=SEARCH_CONTENT_LIMIT)
|
||||
pdf_meta = extract_pdf_metadata(fpath)
|
||||
title = pdf_meta.get("title") or title
|
||||
content_preview = raw[:200].strip()
|
||||
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
raw = extract_excalidraw_indexable(raw)
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
content_preview = raw[:200].strip()
|
||||
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
tags = _extract_tags(post)
|
||||
inline_tags = _extract_inline_tags(post.content)
|
||||
tags = list(set(tags) | set(inline_tags))
|
||||
title = _extract_title(post, fpath)
|
||||
content_preview = post.content[:200].strip()
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
tags = _extract_tags(post)
|
||||
inline_tags = _extract_inline_tags(post.content)
|
||||
tags = list(set(tags) | set(inline_tags))
|
||||
title = _extract_title(post, fpath)
|
||||
content_preview = post.content[:200].strip()
|
||||
|
||||
return {
|
||||
"path": str(relative).replace("\\", "/"),
|
||||
|
||||
+702
-87
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
+49
-4
@@ -2,11 +2,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import TimeoutError as FuturesTimeout
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configurable limits (see ROADMAP #74 G3):
|
||||
# OBSIGATE_PDF_MAX_SIZE_MB — PDFs larger than this are not text-extracted (default 50)
|
||||
# OBSIGATE_PDF_EXTRACT_TIMEOUT — seconds before extraction is abandoned (default 30)
|
||||
PDF_MAX_SIZE_MB: int = int(os.environ.get("OBSIGATE_PDF_MAX_SIZE_MB", "50"))
|
||||
PDF_EXTRACT_TIMEOUT: float = float(os.environ.get("OBSIGATE_PDF_EXTRACT_TIMEOUT", "30"))
|
||||
|
||||
PDF_READER: str = "pypdf"
|
||||
PdfReader = None # type: ignore
|
||||
try:
|
||||
import fitz # pymupdf
|
||||
PDF_READER = "pymupdf"
|
||||
@@ -16,19 +26,54 @@ except ImportError:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
logger.info("PDF reader: pypdf (pure Python)")
|
||||
except ImportError:
|
||||
PdfReader = None # type: ignore
|
||||
logger.warning("No PDF reader available — install pypdf or pymupdf")
|
||||
|
||||
|
||||
def pdf_exceeds_size_limit(file_path: Path) -> bool:
|
||||
"""True if the PDF is larger than OBSIGATE_PDF_MAX_SIZE_MB (skip text extraction)."""
|
||||
try:
|
||||
return file_path.stat().st_size > PDF_MAX_SIZE_MB * 1024 * 1024
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _run_with_timeout(fn, *args, timeout: float):
|
||||
"""Run a sync function in a worker thread with a hard timeout.
|
||||
|
||||
A timed-out extraction leaves its worker thread running (daemon-style pool
|
||||
is abandoned), but the request itself is freed — acceptable trade-off for
|
||||
pathological PDFs on a self-hosted single-user server.
|
||||
"""
|
||||
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="pdf-extract")
|
||||
try:
|
||||
future = executor.submit(fn, *args)
|
||||
return future.result(timeout=timeout)
|
||||
finally:
|
||||
executor.shutdown(wait=False)
|
||||
|
||||
|
||||
def extract_pdf_text(file_path: Path, max_chars: int = 100000) -> str:
|
||||
"""Extract text from a PDF file. Returns empty string on failure."""
|
||||
"""Extract text from a PDF file. Returns empty string on failure.
|
||||
|
||||
Oversized PDFs (> OBSIGATE_PDF_MAX_SIZE_MB) and extractions exceeding
|
||||
OBSIGATE_PDF_EXTRACT_TIMEOUT seconds return "" instead of blocking.
|
||||
"""
|
||||
if PdfReader is None and PDF_READER == "pypdf":
|
||||
return ""
|
||||
if pdf_exceeds_size_limit(file_path):
|
||||
logger.info("PDF too large to index (> %d MB), skipping text extraction: %s",
|
||||
PDF_MAX_SIZE_MB, file_path)
|
||||
return ""
|
||||
try:
|
||||
if PDF_READER == "pymupdf":
|
||||
return _extract_pymupdf(file_path, max_chars)
|
||||
return _run_with_timeout(_extract_pymupdf, file_path, max_chars,
|
||||
timeout=PDF_EXTRACT_TIMEOUT)
|
||||
else:
|
||||
return _extract_pypdf(file_path, max_chars)
|
||||
return _run_with_timeout(_extract_pypdf, file_path, max_chars,
|
||||
timeout=PDF_EXTRACT_TIMEOUT)
|
||||
except FuturesTimeout:
|
||||
logger.warning("PDF text extraction timed out (%ss): %s", PDF_EXTRACT_TIMEOUT, file_path)
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract PDF text from %s: %s", file_path, e)
|
||||
return ""
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# Additional dev/test-only dependencies for the test suite.
|
||||
# These are NOT required for production runtime.
|
||||
# Install with: pip install -r requirements-test.txt
|
||||
|
||||
reportlab>=4.0 # PDF fixture generation for test_pdf.py
|
||||
@@ -13,3 +13,7 @@ snowballstemmer>=2.2.0
|
||||
weasyprint>=60.0
|
||||
httpx>=0.27.0
|
||||
pypdf>=4.0
|
||||
pyotp>=2.10.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Additional Pydantic response models for the ObsiGate REST API.
|
||||
|
||||
These models enrich the auto-generated OpenAPI 3.1 schema (#72). They are
|
||||
intentionally permissive (``extra="allow"``) so that adding a new field to an
|
||||
existing endpoint never breaks response validation — the model documents the
|
||||
stable, public shape while still accepting internal additions.
|
||||
|
||||
Models whose endpoints are already typed in :mod:`backend.main` (e.g.
|
||||
``FileContentResponse``) live there; this module only holds the ones used to
|
||||
fill the gaps.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
"""Generic ``{"status": "..."}`` acknowledgement."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
status: str = Field(default="ok", description="Operation status")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard FastAPI error envelope."""
|
||||
|
||||
detail: str | list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description="Human-readable error message, or a list of validation errors",
|
||||
examples=["Vault 'Notes' not found"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recent files & bookmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RecentFileItem(BaseModel):
|
||||
"""A recently opened or recently modified file."""
|
||||
|
||||
path: str = Field(description="Relative path within the vault")
|
||||
title: str = Field(description="File title")
|
||||
vault: str = Field(description="Vault name")
|
||||
mtime: float | int | str | None = Field(default=None, description="Modification timestamp")
|
||||
mtime_human: str | None = Field(default=None, description="Human-readable modification time")
|
||||
mtime_iso: str | None = Field(default=None, description="ISO-8601 modification time")
|
||||
size_bytes: int | None = Field(default=None, description="File size in bytes")
|
||||
tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags")
|
||||
preview: str | None = Field(default=None, description="Short content preview")
|
||||
bookmarked: bool | None = Field(default=None, description="Whether the file is bookmarked")
|
||||
|
||||
|
||||
class RecentResponse(BaseModel):
|
||||
"""Response for ``GET /api/recent``."""
|
||||
|
||||
files: list[RecentFileItem]
|
||||
total: int = Field(description="Total number of files in the selected mode")
|
||||
limit: int = Field(description="Applied limit")
|
||||
mode: str = Field(description="'opened' or 'modified'")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"files": [
|
||||
{
|
||||
"path": "notes/Accueil.md",
|
||||
"title": "Accueil",
|
||||
"vault": "TestVault",
|
||||
"mtime": 1750000000.0,
|
||||
"mtime_human": "il y a 2 h",
|
||||
"size_bytes": 1024,
|
||||
"tags": ["#accueil"],
|
||||
"preview": "# Bienvenue…",
|
||||
"bookmarked": False,
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": 20,
|
||||
"mode": "opened",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BookmarkFileItem(BaseModel):
|
||||
"""A bookmarked file."""
|
||||
|
||||
path: str = Field(description="Relative path within the vault")
|
||||
title: str = Field(description="File title")
|
||||
vault: str = Field(description="Vault name")
|
||||
mtime: float | int | str | None = Field(default=None, description="Bookmark timestamp")
|
||||
mtime_human: str | None = Field(default=None, description="Human-readable bookmark time")
|
||||
size_bytes: int | None = Field(default=None, description="File size in bytes")
|
||||
tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags")
|
||||
bookmarked: bool | None = Field(default=True, description="Always true for this endpoint")
|
||||
|
||||
|
||||
class BookmarksResponse(BaseModel):
|
||||
"""Response for ``GET /api/bookmarks``."""
|
||||
|
||||
files: list[BookmarkFileItem]
|
||||
total: int = Field(description="Number of bookmarked files")
|
||||
|
||||
|
||||
class BookmarkToggleResponse(BaseModel):
|
||||
"""Response for ``POST /api/bookmarks/toggle``."""
|
||||
|
||||
bookmarked: bool = Field(description="New bookmark state")
|
||||
|
||||
|
||||
class SavedSearch(BaseModel):
|
||||
"""A persisted search definition."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str = Field(description="Unique identifier (millisecond timestamp)")
|
||||
query: str = Field(description="Search query text")
|
||||
vault: str = Field(default="all", description="Vault filter")
|
||||
case_sensitive: bool = Field(default=False)
|
||||
whole_word: bool = Field(default=False)
|
||||
regex: bool = Field(default=False)
|
||||
include_paths: str = Field(default="")
|
||||
exclude_paths: str = Field(default="")
|
||||
created_at: float = Field(description="Creation timestamp (epoch seconds)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backups & diffs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BackupsResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/backups``."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
backups: list[dict[str, Any]] = Field(description="Backups, newest first")
|
||||
|
||||
|
||||
class BacklinksResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/backlinks``."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
backlinks: list[dict[str, Any]] = Field(description="Files linking to the target")
|
||||
total: int
|
||||
|
||||
|
||||
class BackupsListResponse(BaseModel):
|
||||
"""Response for ``GET /api/backups``."""
|
||||
|
||||
backups: list[dict[str, Any]] = Field(description="All backups across vaults")
|
||||
total: int
|
||||
total_size_bytes: int
|
||||
|
||||
|
||||
class BackupsDeletedResponse(BaseModel):
|
||||
"""Response for backup deletion / purge endpoints."""
|
||||
|
||||
deleted: int = Field(description="Number of backup files deleted")
|
||||
|
||||
|
||||
class BackupContentResponse(BaseModel):
|
||||
"""Response for ``GET /api/backups/content``."""
|
||||
|
||||
content: str = Field(description="Backup file content (truncated to 100 KB)")
|
||||
name: str = Field(description="Backup file name")
|
||||
size: int = Field(description="Returned content size in characters")
|
||||
|
||||
|
||||
class BackupsCompressResponse(BaseModel):
|
||||
"""Response for ``POST /api/backups/compress``."""
|
||||
|
||||
compressed: int = Field(description="Number of backups processed")
|
||||
saved_bytes: int = Field(description="Bytes saved by gzip compression")
|
||||
dry_run: bool
|
||||
|
||||
|
||||
class BackupsAutoResponse(BaseModel):
|
||||
"""Response for ``POST /api/backups/auto``."""
|
||||
|
||||
backed_up: int = Field(description="Number of files backed up")
|
||||
since_hours: int | float = Field(description="Look-back window in hours")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PdfInfoResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/pdf/info``."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
pages: int = Field(description="Page count")
|
||||
title: str = Field(description="PDF title (metadata or filename)")
|
||||
author: str = Field(default="", description="PDF author")
|
||||
size_bytes: int = Field(description="File size in bytes")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"vault": "TestVault",
|
||||
"path": "docs/rapport.pdf",
|
||||
"pages": 12,
|
||||
"title": "Rapport annuel",
|
||||
"author": "ObsiGate",
|
||||
"size_bytes": 524288,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search & replace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ReplaceMatch(BaseModel):
|
||||
"""A single file affected by a find/replace operation."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str
|
||||
path: str
|
||||
title: str | None = None
|
||||
match_count: int | None = Field(default=None, description="Occurrences found (dry run)")
|
||||
replacements: int | None = Field(default=None, description="Occurrences replaced")
|
||||
preview: list[str] = Field(default_factory=list, description="Context snippets (dry run)")
|
||||
|
||||
|
||||
class ReplaceResponse(BaseModel):
|
||||
"""Response for ``POST /api/search/replace``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
matches: list[ReplaceMatch] = Field(default_factory=list, description="Dry-run matches")
|
||||
replaced: list[ReplaceMatch] = Field(default_factory=list, description="Applied replacements")
|
||||
total_matches: int | None = Field(default=None, description="Total matches (dry run)")
|
||||
total_replacements: int = Field(description="Total replacements performed or previewed")
|
||||
dry_run: bool | None = Field(default=None, description="True when no file was written")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vaults, attachments & settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VaultStatsResponse(BaseModel):
|
||||
"""Response for ``POST /api/vaults/add`` and ``GET /api/index/reload/{vault}``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
status: str = Field(default="ok")
|
||||
vault: str
|
||||
stats: dict[str, Any] = Field(default_factory=dict, description="Index statistics for the vault")
|
||||
|
||||
|
||||
class VaultActionResponse(BaseModel):
|
||||
"""Response for ``DELETE /api/vaults/{vault}``."""
|
||||
|
||||
status: str = Field(default="ok")
|
||||
vault: str
|
||||
|
||||
|
||||
class VaultStatusEntry(BaseModel):
|
||||
"""Per-vault status entry."""
|
||||
|
||||
file_count: int
|
||||
tag_count: int
|
||||
path: str = ""
|
||||
watching: bool = False
|
||||
|
||||
|
||||
class VaultsStatusResponse(BaseModel):
|
||||
"""Response for ``GET /api/vaults/status``."""
|
||||
|
||||
vaults: dict[str, VaultStatusEntry]
|
||||
watcher_active: bool
|
||||
sse_clients: int
|
||||
|
||||
|
||||
class AttachmentRescanResponse(BaseModel):
|
||||
"""Response for ``POST /api/attachments/rescan/{vault}``."""
|
||||
|
||||
status: str = Field(default="ok")
|
||||
vault: str
|
||||
attachment_count: int
|
||||
|
||||
|
||||
class AttachmentStatsResponse(BaseModel):
|
||||
"""Response for ``GET /api/attachments/stats``."""
|
||||
|
||||
vaults: dict[str, Any] = Field(description="Vault name → attachment statistics")
|
||||
|
||||
|
||||
class VaultSettingsResponse(BaseModel):
|
||||
"""Response for the per-vault display settings endpoints."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
hideHiddenFiles: bool = Field(default=False, description="Hide dotfiles in the tree")
|
||||
|
||||
|
||||
class AllVaultSettingsResponse(BaseModel):
|
||||
"""Response for ``GET /api/vaults/settings/all``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class VaultFileEntry(BaseModel):
|
||||
"""A file entry returned by the vault home listing."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
name: str
|
||||
path: str
|
||||
vault: str
|
||||
size: int = 0
|
||||
modified: float = 0
|
||||
modified_iso: str | None = None
|
||||
extension: str = ""
|
||||
rel_dir: str | None = None
|
||||
|
||||
|
||||
class VaultFilesResponse(BaseModel):
|
||||
"""Response for ``GET /api/vault/{vault}/files``."""
|
||||
|
||||
vault: str
|
||||
directory: str = ""
|
||||
recursive: bool = True
|
||||
count: int
|
||||
files: list[VaultFileEntry]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration, AI keys & diagnostics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AppConfigResponse(BaseModel):
|
||||
"""Application configuration (``GET/POST /api/config``)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class AIKeysResponse(BaseModel):
|
||||
"""Masked AI provider keys (``GET /api/config/ai-keys``)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class AIKeyDeleteResponse(BaseModel):
|
||||
"""Response for ``DELETE /api/config/ai-keys/{provider}``."""
|
||||
|
||||
status: str = Field(default="deleted")
|
||||
key: str = Field(description="Deleted environment variable name")
|
||||
|
||||
|
||||
class AITestResponse(BaseModel):
|
||||
"""Response for ``POST /api/config/ai-keys/test`` (provider → status)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class AIModelsResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/ai-models``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
models: list[str] = Field(default_factory=list, description="Available model identifiers")
|
||||
source: str = Field(default="fallback", description="'live', 'fallback' or 'validation'")
|
||||
count: int | None = Field(default=None, description="Number of live models")
|
||||
error: str | None = Field(default=None, description="Provider/network error, if any")
|
||||
note: str | None = Field(default=None, description="Explanatory note when using fallback")
|
||||
|
||||
|
||||
class DiagnosticsResponse(BaseModel):
|
||||
"""Response for ``GET /api/diagnostics``."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
index: dict[str, Any] = Field(default_factory=dict)
|
||||
inverted_index: dict[str, Any] = Field(default_factory=dict)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DashboardVaultStat(BaseModel):
|
||||
"""Per-vault dashboard statistics."""
|
||||
|
||||
name: str
|
||||
file_count: int
|
||||
tag_count: int
|
||||
total_size_bytes: int
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
"""Response for ``GET /api/dashboard``."""
|
||||
|
||||
vaults: list[DashboardVaultStat]
|
||||
total_files: int
|
||||
total_tags: int
|
||||
total_size_bytes: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhooks, sharing & conflicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WebhookModel(BaseModel):
|
||||
"""A configured webhook."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str = Field(description="Webhook UUID")
|
||||
name: str
|
||||
url: str = Field(description="Target HTTP(S) URL")
|
||||
events: list[str] = Field(default_factory=list, description="Subscribed event types")
|
||||
secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret")
|
||||
enabled: bool = True
|
||||
created_at: str | None = None
|
||||
last_fired_at: str | None = None
|
||||
|
||||
|
||||
class ShareModel(BaseModel):
|
||||
"""A public document share."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str
|
||||
token: str = Field(description="Opaque share token")
|
||||
vault: str
|
||||
path: str
|
||||
url: str | None = Field(default=None, description="Relative public URL (/s/{token})")
|
||||
created_by: str | None = None
|
||||
created_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
access_count: int = 0
|
||||
last_accessed: str | None = None
|
||||
|
||||
|
||||
class ConflictEntry(BaseModel):
|
||||
"""A Syncthing sync-conflict file."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str
|
||||
path: str
|
||||
original_path: str | None = None
|
||||
|
||||
|
||||
class ConflictsResponse(BaseModel):
|
||||
"""Response for ``GET /api/conflicts``."""
|
||||
|
||||
conflicts: list[ConflictEntry]
|
||||
total: int
|
||||
|
||||
|
||||
class ConflictResolveResponse(BaseModel):
|
||||
"""Response for ``POST /api/conflicts/resolve``."""
|
||||
|
||||
status: str = Field(default="resolved")
|
||||
action: str | None = Field(default=None, description="'keep_local' or 'keep_conflict'")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI status & BooksLM context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AIProviderStatus(BaseModel):
|
||||
"""Availability of a single AI provider."""
|
||||
|
||||
available: bool
|
||||
model: str | None = Field(default=None, description="Default model when the provider is configured")
|
||||
|
||||
|
||||
class AIAutocompleteStatus(BaseModel):
|
||||
"""Status of the local Ollama autocomplete backend."""
|
||||
|
||||
available: bool = False
|
||||
server_ok: bool = False
|
||||
model_loaded: bool = False
|
||||
model: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class AIStatusResponse(BaseModel):
|
||||
"""Response for ``GET /api/ai/status``."""
|
||||
|
||||
configured: bool = Field(description="True when at least one provider has an API key")
|
||||
default_provider: str
|
||||
providers: dict[str, AIProviderStatus]
|
||||
autocomplete: AIAutocompleteStatus
|
||||
|
||||
|
||||
class BooksLMContextFile(BaseModel):
|
||||
"""A single document included in the BooksLM context."""
|
||||
|
||||
path: str
|
||||
title: str
|
||||
content: str
|
||||
type: str = Field(default="markdown", description="'markdown' (and future types)")
|
||||
|
||||
|
||||
class BooksLMContextResponse(BaseModel):
|
||||
"""Response for ``POST /api/ai/bookslm/context``."""
|
||||
|
||||
files: list[BooksLMContextFile]
|
||||
total_chars: int
|
||||
file_count: int
|
||||
directory_tree: str = ""
|
||||
max_total_chars: int = Field(default=200000, description="Configured context character limit")
|
||||
max_files: int = Field(default=200, description="Configured file-count limit")
|
||||
scope: str = Field(default="directory", description="Context scope: 'directory', 'documents' or 'general'")
|
||||
|
||||
+64
-28
@@ -1,13 +1,15 @@
|
||||
"""
|
||||
Version management for ObsiGate.
|
||||
|
||||
Uses git describe to generate a SemVer-compatible version string.
|
||||
Format: MAJOR.MINOR.PATCH[-commits-since-tag-gHASH]
|
||||
The canonical version is the numeric SemVer of the LATEST release tag
|
||||
(MAJOR.MINOR.PATCH). get_version() always returns a clean "x.y.z" string so
|
||||
the UI never shows "-dev", "0.0.0-dev" or a "-N-gHASH" suffix — the same
|
||||
number appears in the header badge, the About modal and the API health.
|
||||
|
||||
Examples:
|
||||
v1.7.0 (exact tag)
|
||||
v1.7.0-3-gabc1234 (3 commits after v1.7.0)
|
||||
v1.7.0-dev (fallback when Git unavailable)
|
||||
tag v2.0.0 -> "2.0.0"
|
||||
HEAD 31 commits after -> "2.0.0" (release version, no dev clutter)
|
||||
no git / no VERSION -> "0.0.0"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,13 +17,14 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent # ObsiGate repo root
|
||||
_VERSION_FILE = Path(__file__).resolve().parent / "VERSION"
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""Get the current version from Git tags."""
|
||||
def _run_git(args: list[str]) -> str:
|
||||
"""Run a git command in the repo root; return stdout (stripped) or ''."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "describe", "--tags", "--dirty=-dirty"],
|
||||
["git", *args],
|
||||
cwd=str(_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -29,35 +32,68 @@ def get_version() -> str:
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
tag = result.stdout.strip()
|
||||
# git describe returns v1.7.0 or v1.7.0-3-gabc1234
|
||||
# Remove leading 'v' if present
|
||||
tag = tag.removeprefix("v")
|
||||
return tag
|
||||
return result.stdout.strip()
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
# Fallback: try reading from a VERSION file next to this module
|
||||
version_file = Path(__file__).parent / "VERSION"
|
||||
if version_file.exists():
|
||||
return version_file.read_text().strip()
|
||||
|
||||
return "0.0.0-dev"
|
||||
def _clean_base(raw: str) -> str:
|
||||
"""Reduce a version string to its numeric MAJOR.MINOR.PATCH base.
|
||||
|
||||
Handles "v2.0.0", "2.0.0-31-gabc1234", "2.0.0-dev", "0.0.0-dev".
|
||||
Returns "" if no numeric triplet can be parsed.
|
||||
"""
|
||||
s = (raw or "").strip().lstrip("vV")
|
||||
base = s.split("-")[0] # strip -N-gHASH / -dev / -dirty suffixes
|
||||
parts = base.split(".")
|
||||
nums = []
|
||||
for p in parts[:3]:
|
||||
if not p.isdigit():
|
||||
break
|
||||
nums.append(str(int(p)))
|
||||
if len(nums) != 3:
|
||||
return ""
|
||||
return ".".join(nums)
|
||||
|
||||
|
||||
def get_git_describe() -> str:
|
||||
"""Full `git describe` string (e.g. "2.0.0-31-gabc1234") or '' if no git."""
|
||||
return _run_git(["describe", "--tags", "--dirty=-dirty"]).lstrip("v")
|
||||
|
||||
|
||||
def get_git_commit() -> str:
|
||||
"""Short HEAD commit hash (e.g. "abc1234") or '' if unavailable."""
|
||||
return _run_git(["rev-parse", "--short", "HEAD"])
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""Return the clean release version x.y.z (latest tag) — never a -suffix.
|
||||
|
||||
Priority: latest git tag -> backend/VERSION file -> "0.0.0".
|
||||
"""
|
||||
# 1) Latest tag from git (works even with commits beyond the tag)
|
||||
tag = _run_git(["describe", "--tags", "--abbrev=0"])
|
||||
base = _clean_base(tag)
|
||||
if base:
|
||||
return base
|
||||
|
||||
# 2) backend/VERSION file (baked at build time by build.sh / CI / Docker)
|
||||
if _VERSION_FILE.exists():
|
||||
base = _clean_base(_VERSION_FILE.read_text(encoding="utf-8"))
|
||||
if base:
|
||||
return base
|
||||
|
||||
# 3) Nothing available
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def get_version_tuple() -> tuple[int, int, int]:
|
||||
"""Return (major, minor, patch) tuple for programmatic use."""
|
||||
raw = get_version()
|
||||
# Strip -suffix for parsing
|
||||
base = raw.split("-")[0]
|
||||
try:
|
||||
parts = base.split(".")
|
||||
major = int(parts[0]) if len(parts) > 0 else 0
|
||||
minor = int(parts[1]) if len(parts) > 1 else 0
|
||||
patch = int(parts[2]) if len(parts) > 2 else 0
|
||||
return (major, minor, patch)
|
||||
except (ValueError, IndexError):
|
||||
return (0, 0, 0)
|
||||
base = _clean_base(raw) or "0.0.0"
|
||||
major, minor, patch = (int(p) for p in base.split("."))
|
||||
return (major, minor, patch)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+66
-5
@@ -1,7 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
@@ -12,6 +13,54 @@ from backend.indexer import IGNORED_DIRS, SUPPORTED_EXTENSIONS
|
||||
|
||||
logger = logging.getLogger("obsigate.watcher")
|
||||
|
||||
# Filesystem types where inotify does NOT fire for changes made by other
|
||||
# clients (the edit happens on another machine — e.g. Obsidian on Windows
|
||||
# writing to an NFS/SMB export). For those mounts watchdog's native
|
||||
# Observer silently misses everything and polling is the only option.
|
||||
NETWORK_FSTYPES = {
|
||||
"nfs", "nfs4", "cifs", "smbfs", "smb2", "smb3",
|
||||
"fuse", "fuse.sshfs", "glusterfs", "9p", "virtiofs", "lustre",
|
||||
}
|
||||
|
||||
|
||||
def find_mount_fstype(resolved_path: str, mount_lines: Iterable[str]) -> str:
|
||||
"""Return the fstype of the longest /proc/mounts entry covering the path.
|
||||
|
||||
Pure function (mount_lines are text lines) so it can be unit-tested
|
||||
without /proc. Returns "" when no mount matches.
|
||||
"""
|
||||
best_point = ""
|
||||
best_fstype = ""
|
||||
for line in mount_lines:
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
mount_point, fstype = parts[1], parts[2]
|
||||
# /proc/mounts escapes special chars in octal
|
||||
mount_point = mount_point.replace("\\040", " ").replace("\\011", "\t")
|
||||
root = mount_point.rstrip("/") or "/"
|
||||
if (resolved_path == root or resolved_path.startswith(root + "/")) and len(mount_point) > len(best_point):
|
||||
best_point = mount_point
|
||||
best_fstype = fstype
|
||||
return best_fstype
|
||||
|
||||
|
||||
def is_network_mount(path: str) -> bool:
|
||||
"""True if *path* sits on a network/fuse filesystem (Linux only).
|
||||
|
||||
inotify events are local to the client that made the write, so vaults
|
||||
shared over NFS/SMB never see Obsidian's edits. On those mounts we
|
||||
must fall back to PollingObserver, which detects changes by stat().
|
||||
"""
|
||||
if os.name != "posix":
|
||||
return False
|
||||
try:
|
||||
with open("/proc/mounts", "r", encoding="utf-8") as fh:
|
||||
fstype = find_mount_fstype(os.path.realpath(path), fh)
|
||||
except OSError:
|
||||
return False
|
||||
return fstype in NETWORK_FSTYPES
|
||||
|
||||
|
||||
class VaultEventHandler(FileSystemEventHandler):
|
||||
"""Gestionnaire d'événements filesystem pour une vault Obsidian.
|
||||
@@ -114,7 +163,12 @@ class VaultWatcher:
|
||||
vault_path: str,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
):
|
||||
"""Créer et démarrer un observer pour une vault."""
|
||||
"""Créer et démarrer un observer pour une vault.
|
||||
|
||||
Le mode (natif inotify vs polling) est choisi par vault :
|
||||
les mounts réseau (NFS/SMB/fuse) sont surveillés en polling car
|
||||
inotify ne voit pas les écritures faites depuis d'autres clients.
|
||||
"""
|
||||
path = Path(vault_path)
|
||||
if not path.exists():
|
||||
logger.warning(f"Vault '{vault_name}' path not found: {vault_path}")
|
||||
@@ -122,16 +176,23 @@ class VaultWatcher:
|
||||
|
||||
handler = VaultEventHandler(vault_name, self.event_queue, loop)
|
||||
|
||||
ObserverClass = PollingObserver if self.use_polling else Observer
|
||||
polling = self.use_polling or is_network_mount(vault_path)
|
||||
if polling and not self.use_polling:
|
||||
logger.info(
|
||||
f"Vault '{vault_name}' is on a network mount — "
|
||||
"using polling watcher (inotify cannot see remote edits)"
|
||||
)
|
||||
|
||||
ObserverClass = PollingObserver if polling else Observer
|
||||
try:
|
||||
observer = ObserverClass(
|
||||
timeout=self.polling_interval if self.use_polling else 1
|
||||
timeout=self.polling_interval if polling else 1
|
||||
)
|
||||
observer.schedule(handler, str(path), recursive=True)
|
||||
observer.daemon = True
|
||||
observer.start()
|
||||
self.observers[vault_name] = observer
|
||||
mode = "polling" if self.use_polling else "native"
|
||||
mode = "polling" if polling else "native"
|
||||
logger.info(f"Watching ({mode}): {vault_name} -> {vault_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start watcher for '{vault_name}': {e}")
|
||||
|
||||
Generated
+2
@@ -2644,7 +2644,9 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -26,6 +26,20 @@ log = "0.4"
|
||||
env_logger = "0.11"
|
||||
chrono = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Variant",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_Shell_Common",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
+245
-7
@@ -5,15 +5,46 @@
|
||||
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
mod jumplist;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
// ── Backend port (ROADMAP #77 B: auto-increment if 17890 is busy) ────
|
||||
|
||||
const DEFAULT_BACKEND_PORT: u16 = 17890;
|
||||
const PORT_SCAN_ATTEMPTS: u16 = 10;
|
||||
|
||||
static BACKEND_PORT: AtomicU16 = AtomicU16::new(DEFAULT_BACKEND_PORT);
|
||||
|
||||
fn backend_url() -> String {
|
||||
format!("http://127.0.0.1:{}", BACKEND_PORT.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// First free TCP port starting at DEFAULT_BACKEND_PORT.
|
||||
fn pick_free_port() -> u16 {
|
||||
for offset in 0..PORT_SCAN_ATTEMPTS {
|
||||
let port = DEFAULT_BACKEND_PORT + offset;
|
||||
if TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
||||
if offset > 0 {
|
||||
info!("Port {} busy — using {} instead", DEFAULT_BACKEND_PORT, port);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
}
|
||||
warn!("No free port in {}..{}, falling back to default",
|
||||
DEFAULT_BACKEND_PORT, DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS - 1);
|
||||
DEFAULT_BACKEND_PORT
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use tauri::Manager;
|
||||
@@ -172,12 +203,15 @@ fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
|
||||
cmd.env(format!("DIR_{}_PATH", n), &dir.path);
|
||||
}
|
||||
|
||||
let port = pick_free_port();
|
||||
BACKEND_PORT.store(port, Ordering::Relaxed);
|
||||
|
||||
let child = cmd
|
||||
.args([
|
||||
"-m", "uvicorn",
|
||||
"backend.main:app",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", "17890",
|
||||
"--port", &port.to_string(),
|
||||
"--log-level", "info",
|
||||
])
|
||||
.stdout(Stdio::from(log.try_clone().map_err(|e| format!("{}", e))?))
|
||||
@@ -191,10 +225,10 @@ fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
|
||||
|
||||
async fn wait_for_backend() -> Result<(), String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = "http://127.0.0.1:17890/api/health";
|
||||
let url = format!("{}/api/health", backend_url());
|
||||
|
||||
for i in 0..30 {
|
||||
match client.get(url).timeout(Duration::from_secs(2)).send().await {
|
||||
match client.get(&url).timeout(Duration::from_secs(2)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
info!("Backend ready (attempt {})", i + 1);
|
||||
return Ok(());
|
||||
@@ -235,7 +269,7 @@ fn kill_backend(child: &mut Child) {
|
||||
|
||||
#[tauri::command]
|
||||
fn get_backend_url() -> String {
|
||||
"http://127.0.0.1:17890".to_string()
|
||||
backend_url()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -390,7 +424,7 @@ async fn check_backend_health(state: tauri::State<'_, BackendProcess>) -> Result
|
||||
}
|
||||
|
||||
// Health check HTTP
|
||||
match reqwest::get("http://127.0.0.1:17890/api/health").await {
|
||||
match reqwest::get(format!("{}/api/health", backend_url())).await {
|
||||
Ok(resp) if resp.status().is_success() => Ok("healthy".to_string()),
|
||||
_ => Ok("unhealthy".to_string()),
|
||||
}
|
||||
@@ -547,6 +581,10 @@ fn main() {
|
||||
// Build native menu bar (File / Edit / Help)
|
||||
let _ = build_native_menu(app);
|
||||
|
||||
// Windows taskbar jump list — recent vaults (best-effort).
|
||||
let jumplist_vaults = jumplist::build_vault_args(&config.vaults);
|
||||
jumplist::update_jumplist(&jumplist_vaults);
|
||||
|
||||
// Restore window position/size
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let (Some(x), Some(y)) = (config.window_x, config.window_y) {
|
||||
@@ -570,7 +608,7 @@ fn main() {
|
||||
let _ = window.eval(
|
||||
r#"if (window.__setBootStatus) window.__setBootStatus("Backend prêt — chargement de l'interface…")"#,
|
||||
);
|
||||
match window.eval("window.location.href = 'http://127.0.0.1:17890'") {
|
||||
match window.eval(&format!("window.location.href = '{}'", backend_url())) {
|
||||
Ok(_) => info!("Redirect to backend OK"),
|
||||
Err(e) => error!("Redirect eval failed: {}", e),
|
||||
}
|
||||
@@ -580,8 +618,9 @@ fn main() {
|
||||
error!("Backend failed: {}", e);
|
||||
if let Some(window) = handle.get_webview_window("main") {
|
||||
let _ = window.eval(&format!(
|
||||
r#"document.body.innerHTML = '<div style="padding:40px;text-align:center;font-family:sans-serif"><h2 style="color:#c0392b">ObsiGate n\'a pas pu démarrer</h2><p style="color:#666">{}</p><p><button onclick="location.reload()" style="padding:10px 20px;font-size:16px;cursor:pointer;background:#7C3AED;color:white;border:none;border-radius:6px">Réessayer</button></p><p style="margin-top:20px;font-size:12px;color:#999">Backend: http://127.0.0.1:17890 | Logs: {}</p></div>'"#,
|
||||
r#"document.body.innerHTML = '<div style="padding:40px;text-align:center;font-family:sans-serif"><h2 style="color:#c0392b">ObsiGate n\'a pas pu démarrer</h2><p style="color:#666">{}</p><p><button onclick="location.reload()" style="padding:10px 20px;font-size:16px;cursor:pointer;background:#7C3AED;color:white;border:none;border-radius:6px">Réessayer</button></p><p style="margin-top:20px;font-size:12px;color:#999">Backend: {} | Logs: {}</p></div>'"#,
|
||||
e.replace('\'', "\\'").replace('"', "\\\""),
|
||||
backend_url(),
|
||||
backend_log_path().display().to_string().replace('\\', "\\\\"),
|
||||
));
|
||||
}
|
||||
@@ -832,3 +871,202 @@ fn build_tray_menu(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Serializes the `pick_free_port` tests: they bind real TCP ports, so
|
||||
/// running them in parallel makes them clobber each other's port.
|
||||
static PORT_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
|
||||
/// Helper: write config to a temp dir and read it back
|
||||
fn roundtrip_config(config: &AppConfig) -> AppConfig {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("config.json");
|
||||
let json = serde_json::to_string_pretty(config).unwrap();
|
||||
fs::write(&path, &json).unwrap();
|
||||
let raw = fs::read_to_string(&path).unwrap();
|
||||
serde_json::from_str(&raw).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let c = AppConfig::default();
|
||||
assert!(c.vault_path.is_none());
|
||||
assert!(c.vaults.is_empty());
|
||||
assert!(c.dirs.is_empty());
|
||||
assert_eq!(c.window_width, Some(1200.0));
|
||||
assert_eq!(c.window_height, Some(800.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_roundtrip() {
|
||||
let c = AppConfig {
|
||||
vault_path: Some("/test/vault".into()),
|
||||
vaults: vec![
|
||||
VaultConfig { name: "Personal".into(), path: "/v/personal".into() },
|
||||
VaultConfig { name: "Work".into(), path: "/v/work".into() },
|
||||
],
|
||||
dirs: vec![DirConfig { name: "home".into(), path: "/home".into() }],
|
||||
window_x: Some(100.0),
|
||||
window_y: Some(200.0),
|
||||
window_width: Some(1400.0),
|
||||
window_height: Some(900.0),
|
||||
};
|
||||
let loaded = roundtrip_config(&c);
|
||||
assert_eq!(loaded.vault_path, Some("/test/vault".into()));
|
||||
assert_eq!(loaded.vaults.len(), 2);
|
||||
assert_eq!(loaded.vaults[0].name, "Personal");
|
||||
assert_eq!(loaded.vaults[1].path, "/v/work");
|
||||
assert_eq!(loaded.dirs.len(), 1);
|
||||
assert_eq!(loaded.window_x, Some(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_empty_json() {
|
||||
// serde requires vaults/dirs fields — missing fields use unwrap_or_default
|
||||
let c: AppConfig = serde_json::from_str("{}").unwrap_or_default();
|
||||
assert!(c.vault_path.is_none());
|
||||
assert!(c.vaults.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_partial_json() {
|
||||
// serde requires all fields — partial JSON uses unwrap_or_default
|
||||
let c: AppConfig = serde_json::from_str(
|
||||
r#"{"vaults":[{"name":"t","path":"/t"}]}"#
|
||||
).unwrap_or_default();
|
||||
// Will fall to default since dirs is missing
|
||||
assert!(c.vaults.is_empty() || c.vaults.len() == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_corrupted_json_fallback() {
|
||||
let result: Result<AppConfig, _> = serde_json::from_str("not json");
|
||||
assert!(result.is_err());
|
||||
// Caller should use unwrap_or_default()
|
||||
let c = result.unwrap_or_default();
|
||||
assert!(c.vaults.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_dedup() {
|
||||
let mut c = AppConfig::default();
|
||||
c.vaults.retain(|v| v.name != "test");
|
||||
c.vaults.push(VaultConfig { name: "test".into(), path: "/first".into() });
|
||||
c.vaults.retain(|v| v.name != "test");
|
||||
c.vaults.push(VaultConfig { name: "test".into(), path: "/second".into() });
|
||||
assert_eq!(c.vaults.len(), 1);
|
||||
assert_eq!(c.vaults[0].path, "/second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dir_remove() {
|
||||
let mut c = AppConfig::default();
|
||||
c.dirs.push(DirConfig { name: "a".into(), path: "/a".into() });
|
||||
c.dirs.push(DirConfig { name: "b".into(), path: "/b".into() });
|
||||
c.dirs.retain(|d| d.name != "a");
|
||||
assert_eq!(c.dirs.len(), 1);
|
||||
assert_eq!(c.dirs[0].name, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backend_url() {
|
||||
let port = BACKEND_PORT.load(Ordering::Relaxed);
|
||||
assert_eq!(get_backend_url(), format!("http://127.0.0.1:{}", port));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pick_free_port_is_free() {
|
||||
let _guard = PORT_TEST_LOCK.lock().unwrap();
|
||||
let port = pick_free_port();
|
||||
assert!((DEFAULT_BACKEND_PORT..DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS).contains(&port));
|
||||
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pick_free_port_skips_busy_port() {
|
||||
let _guard = PORT_TEST_LOCK.lock().unwrap();
|
||||
let blocker = match TcpListener::bind(("127.0.0.1", DEFAULT_BACKEND_PORT)) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return, // default port already busy on this machine — skip
|
||||
};
|
||||
let port = pick_free_port();
|
||||
assert_ne!(port, DEFAULT_BACKEND_PORT);
|
||||
assert!(port < DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS);
|
||||
drop(blocker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backend_url_reflects_port() {
|
||||
let saved = BACKEND_PORT.load(Ordering::Relaxed);
|
||||
BACKEND_PORT.store(17893, Ordering::Relaxed);
|
||||
assert_eq!(backend_url(), "http://127.0.0.1:17893");
|
||||
BACKEND_PORT.store(saved, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_about_msg_contains_branding() {
|
||||
assert!(ABOUT_MSG.contains("ObsiGate Desktop"));
|
||||
assert!(ABOUT_MSG.contains("Bruno Charest"));
|
||||
assert!(ABOUT_MSG.contains("Tauri"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_data_dir_nonempty() {
|
||||
let dir = app_data_dir();
|
||||
assert!(!dir.to_string_lossy().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logs_dir_under_app_data() {
|
||||
let logs = logs_dir();
|
||||
assert!(logs.starts_with(app_data_dir()));
|
||||
assert_eq!(logs.file_name().unwrap(), "logs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backend_log_path() {
|
||||
let p = backend_log_path();
|
||||
assert_eq!(p.file_name().unwrap(), "backend.log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_format_keys() {
|
||||
let c = AppConfig::default();
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
assert!(json.contains("\"vaults\""));
|
||||
assert!(json.contains("\"dirs\""));
|
||||
assert!(json.contains("\"window_width\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backend_health_check_url() {
|
||||
// Verify the health check targets the correct endpoint
|
||||
let url = "http://127.0.0.1:17890/api/health";
|
||||
assert!(url.contains("17890"));
|
||||
assert!(url.contains("/api/health"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_config_clone() {
|
||||
let v = VaultConfig { name: "test".into(), path: "/t".into() };
|
||||
let v2 = v.clone();
|
||||
assert_eq!(v.name, v2.name);
|
||||
assert_eq!(v.path, v2.path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dir_config_debug() {
|
||||
let d = DirConfig { name: "x".into(), path: "/x".into() };
|
||||
let dbg = format!("{:?}", d);
|
||||
assert!(dbg.contains("x"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ services:
|
||||
obsigate:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# VERSION est injecte par build.sh/CI via `git describe`.
|
||||
# En `docker compose build` direct (sans build.sh), on retombe sur le
|
||||
# DERNIER TAG (v2.1.0) et non 0.0.0-dev, pour ne jamais afficher "0.0.0"
|
||||
# dans le header / la boîte À propos. Packagé par backend/VERSION.
|
||||
VERSION: ${VERSION:-2.1.0}
|
||||
image: obsigate:latest
|
||||
container_name: obsigate
|
||||
user: "1000:1000"
|
||||
|
||||
@@ -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.
|
||||
+278
-230
@@ -1,6 +1,7 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.0.0-dev | **Dernière mise à jour :** 2026-06-18
|
||||
> **Version :** 2.1.0-dev | **Dernière mise à jour :** 2026-09-10
|
||||
> Revue de cohérence roadmap ↔ code : cases cochées selon l'état réel vérifié dans le dépôt (#78 docs H1-H3, #67 push, #68 health, #74/#61 optionnels non retenus).
|
||||
> Voir aussi [CHANGELOG.md](./CHANGELOG.md), [AUDIT_TECHNIQUE.md](./docs/AUDIT_TECHNIQUE_2026-05-27.md)
|
||||
|
||||
---
|
||||
@@ -119,7 +120,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours (P1)
|
||||
## ✅ Complété (suite — v1.7 → v2.1)
|
||||
|
||||
### 58. Tests E2E Playwright ✅ FAIT
|
||||
- **Effort :** 2-3 jours | **Impact :** 🔴
|
||||
@@ -146,7 +147,7 @@
|
||||
|
||||
## ⚪ Backlog — Priorité 3 (P3)
|
||||
|
||||
### 59. Mode hors-ligne PWA complet ✅ FAIT
|
||||
### 59. Mode hors-ligne PWA complet — ✅ TERMINÉ
|
||||
- **Effort :** 3-4 jours | **Impact :** 🟡
|
||||
- **Description :** Service worker avancé avec IndexedDB pour permettre la navigation et la recherche en mode hors-ligne, avec file de synchronisation au retour réseau.
|
||||
- **Implémentation :** `offline-db.js` (377 lignes) + `offline.js` (209 lignes). IndexedDB 3 stores (files, content, pending). Badge hors-ligne dans le header. Modale résolution de conflits.
|
||||
@@ -159,17 +160,23 @@
|
||||
- [x] UI indicateur : badge « Hors-ligne » + compteur de modifications en attente
|
||||
- [x] Gestion des conflits : détection et résolution manuelle (choix version locale vs serveur)
|
||||
|
||||
### 61. Plugins système — Extensions utilisateur
|
||||
- **Effort :** 4-5 jours | **Impact :** 🟢
|
||||
### 61. Plugins système — Extensions utilisateur ✅
|
||||
- **Effort :** 4-5 jours | **Impact :** 🟢 | **Statut :** ✅ Livré (backend + frontend + tests + docs)
|
||||
- **Description :** Système de plugins permettant aux utilisateurs d'étendre ObsiGate avec des renderers personnalisés, des opérateurs de recherche, et des hooks d'UI. Inspiré du modèle de plugins Obsidian.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Spécification du format de plugin : `plugin.json` (name, version, hooks, permissions)
|
||||
- [ ] API de hooks : `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`
|
||||
- [ ] Sandbox d'exécution : Web Worker isolé pour le code plugin
|
||||
- [ ] UI : page « Plugins » dans les paramètres (installer, activer/désactiver, désinstaller)
|
||||
- [ ] Distribution : dépôt de plugins communautaire (fichier JSON index)
|
||||
- [ ] Hot-reload : activation/désactivation sans rechargement de page
|
||||
- [ ] Sécurité : manifest de permissions, validation de signature, CSP restrictif
|
||||
- [x] Spécification du format de plugin : `plugin.json` (name, version, hooks, permissions)
|
||||
- [x] API de hooks : `onFileRender`, `onSearchFilter`, `onEditorAction`, `onSidebarItem`, `onFileCreate`, `onFileDelete`, `onVaultMount`
|
||||
- [x] Sandbox d'exécution : Web Worker isolé pour le code plugin (blob URL, postMessage structuré, CSP sans importScripts)
|
||||
- [x] UI : page « Plugins » dans les paramètres (installer, activer/désactiver, désinstaller, template, viewer)
|
||||
- [ ] Distribution : dépôt de plugins communautaire (fichier JSON index) — ⚪ NON RETENU (backlog)
|
||||
- [x] Hot-reload : activation/désactivation sans rechargement de page (marker `.disabled`)
|
||||
- [x] Sécurité : manifest de permissions, validation path-traversal, CSP restrictif
|
||||
- **Livré :**
|
||||
- Backend `backend/plugins.py` — validation manifest (name regex, semver, hooks/permissions autorisés), stockage par vault `<vault>/.obsigate-plugins/`, lifecycle complet, validation ZIP (path traversal, limite 100 fichiers, 500KB/fichier), 9 endpoints `/api/plugins/*` (admin-gated pour install/uninstall/enable/disable), template API.
|
||||
- Frontend `frontend/js/plugins.js` — PluginManager, sandbox Web Worker (code via blob URL, protocole postMessage structuré), UI Settings > Plugins, hooks dispatch (`executeHook`/`onFileRender`/`onSearchFilter`/…).
|
||||
- Tests : `tests/test_plugins.py` (44) + `tests/frontend/plugins.test.mjs` (21) — validation, lifecycle, ZIP/dir sécurité, protocole sandbox, isolation DOM/CSP.
|
||||
- Docs : `docs/PLUGINS.md`.
|
||||
- **En backlog (non retenu) :** dépôt communautaire (index JSON), signature de code des plugins.
|
||||
|
||||
### 62. Collaboration temps réel — Édition simultanée
|
||||
- **Effort :** 5-7 jours | **Impact :** 🟢
|
||||
@@ -190,7 +197,7 @@
|
||||
- [ ] Gestion des déconnexions : reconnexion automatique, merge state au retour
|
||||
- [ ] Tests de charge : 5+ utilisateurs simultanés sur le même fichier
|
||||
|
||||
### 63. Internationalisation (i18n) — Multilingue
|
||||
### 63. Internationalisation (i18n) — Multilingue — ✅ TERMINÉ
|
||||
- **Effort :** 2-3 jours | **Impact :** 🟡 | **Statut :** ✅ Terminé
|
||||
- **Description :** Support de l'anglais et du français via un système de clés de traduction.
|
||||
- **Sous-tâches :**
|
||||
@@ -207,125 +214,133 @@
|
||||
- [x] Thèmes, palette de commandes, raccourcis, webhooks → EN/FR complet
|
||||
- [x] Messages système : toasts, statuts, événements → EN/FR complet
|
||||
|
||||
### 64. MFA — Authentification multi-facteurs
|
||||
- **Effort :** 2 jours | **Impact :** 🟡
|
||||
### 64. MFA — Authentification multi-facteurs — ✅ TERMINÉ (TOTP + WebAuthn + recovery codes)
|
||||
- **Effort :** 2 jours (réalisé) | **Impact :** 🟡
|
||||
- **Description :** Ajout d'un second facteur d'authentification obligatoire pour les comptes administrateur. Deux méthodes sont proposées :
|
||||
- **TOTP** (Time-based One-Time Password) : l'utilisateur scanne un QR code avec son app d'authentification (Google Authenticator, Authy, Bitwarden) qui génère un code à 6 chiffres renouvelé toutes les 30 secondes. Au login, après avoir saisi son mot de passe, l'utilisateur doit entrer le code affiché sur son téléphone. Même si le mot de passe est volé, le compte reste protégé car l'attaquant n'a pas le téléphone.
|
||||
- **WebAuthn** (clés de sécurité physiques) : l'utilisateur enregistre une clé USB (YubiKey, SoloKey) ou utilise la biométrie de son appareil (empreinte digitale, Face ID, Windows Hello). Au login, le navigateur demande de toucher la clé physique ou de scanner le doigt. C'est le niveau de sécurité le plus élevé — résistant au phishing car la clé vérifie le domaine du site avant de répondre.
|
||||
- **Codes de secours** : 8 codes à usage unique imprimables, à conserver en lieu sûr, qui permettent de se connecter même si on perd son téléphone ou sa clé. Chaque code ne fonctionne qu'une seule fois.
|
||||
- **Pourquoi c'est important :** Le vol de mot de passe est la cause #1 de brèches de sécurité. Avec un vault Obsidian contenant des notes personnelles, projets sensibles, secrets et tokens API, l'authentification par simple mot de passe n'est plus suffisante. Le MFA empêche 99.9% des attaques de prise de compte automatisées (source : Microsoft Security).
|
||||
- **Sous-tâches :**
|
||||
- [ ] TOTP : génération de secret, QR code, vérification code 6 chiffres
|
||||
- [ ] WebAuthn : enregistrement de clé, assertion, attestation
|
||||
- [ ] UI : page « Sécurité du compte » avec activation/désactivation MFA
|
||||
- [ ] Flow login : mot de passe → challenge TOTP si activé
|
||||
- [ ] Recovery codes : 8 codes de backup à usage unique
|
||||
- [ ] Stockage : `totp_secret` + `webauthn_credential_id` dans `users.json`
|
||||
- [x] TOTP : génération de secret, QR code, vérification code 6 chiffres
|
||||
- [x] WebAuthn : enregistrement de clé, assertion, attestation (`backend/auth/webauthn_mfa.py`, lib `webauthn==2.6.0`, challenges in-memory TTL 180s à usage unique) — FAIT en 2026-09 (commit ab795ec)
|
||||
- [x] UI : page « Sécurité du compte » avec activation/désactivation MFA + gestion des clés WebAuthn (liste, ajout, retrait)
|
||||
- [x] Flow login : mot de passe → challenge TOTP OU WebAuthn selon `mfa_method` retourné par /login
|
||||
- [x] Recovery codes : 8 codes de backup à usage unique (générés à l'activation, hachés SHA-256)
|
||||
- [x] Stockage : `mfa_secret` + `webauthn_credentials[]` dans `users.json`
|
||||
- [x] Tests : `tests/test_mfa.py` (29) + `tests/test_webauthn.py` (10, authentificateur virtuel CBOR/EC P-256)
|
||||
|
||||
### 65. Thèmes personnalisés — CSS variables
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟢
|
||||
### 65. Thèmes personnalisés — CSS variables — ✅ TERMINÉ
|
||||
- **Effort :** 1-2 jours (réalisé) | **Impact :** 🟢
|
||||
- **Description :** Exposition de variables CSS pour permettre aux utilisateurs de créer des thèmes personnalisés. Presets inclus : light, dark, high-contrast, sepia.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Audit des variables CSS existantes → liste des 30+ variables
|
||||
- [ ] Fichier `themes.json` avec presets (light, dark, high-contrast, sepia)
|
||||
- [ ] UI : sélecteur de thème dans les paramètres (aperçu live)
|
||||
- [ ] Import/export de thème personnalisé (JSON)
|
||||
- [ ] Application dynamique sans rechargement (`document.documentElement.style.setProperty`)
|
||||
- [x] Audit des variables CSS existantes → 40+ variables
|
||||
- [x] Presets: light, dark, high-contrast, sepia (générés dynamiquement)
|
||||
- [x] UI : sélecteur de thème dans les paramètres (swatches grid)
|
||||
- [x] Import/export de thème personnalisé (JSON)
|
||||
- [x] Application dynamique via document.documentElement.style.setProperty
|
||||
|
||||
### 66. Export multi-formats
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟢
|
||||
### 66. Export multi-formats — ✅ TERMINÉ
|
||||
- **Effort :** 1-2 jours (réalisé) | **Impact :** 🟢
|
||||
- **Description :** Export de notes individuelles ou de vaults entiers en HTML standalone, bundle Markdown (.zip), et ePub pour liseuses.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Export HTML standalone : CSS inliné, images en base64, navigation inter-fichiers
|
||||
- [ ] Export MD bundle : ZIP du vault avec structure préservée
|
||||
- [ ] Export ePub : conversion markdown → ePub via `markdown` + `ebooklib`
|
||||
- [ ] UI : bouton « Exporter » dans le viewer (fichier unique) + dans le menu vault (export complet)
|
||||
- [ ] Endpoints : `GET /api/export/html`, `GET /api/export/md-bundle`, `GET /api/export/epub`
|
||||
- [x] Export HTML standalone : CSS inliné, images en base64, navigation inter-fichiers
|
||||
- [x] Export MD bundle : ZIP du vault avec structure préservée
|
||||
- [x] Export ePub : conversion markdown → ePub (zipfile + mistune, 0 nouvelle dep)
|
||||
- [x] UI : dropdown Export dans toolbar viewer (HTML / MD bundle / ePub)
|
||||
- [x] Endpoints : `GET /api/export/html`, `GET /api/export/md-bundle`, `GET /api/export/epub`
|
||||
|
||||
### 74. Support complet des documents PDF
|
||||
- **Effort :** 4-5 jours | **Impact :** 🟡
|
||||
- **Description :** Prise en charge native des fichiers PDF dans ObsiGate avec parité fonctionnelle complète avec les documents Markdown : apparition dans l'arborescence, indexation full-text, visualisation inline dans le navigateur, recherche TF-IDF, et téléchargement. Actuellement, les PDF sont traités comme des fichiers binaires non supportés (message « Ce fichier est binaire et ne peut pas être affiché » + bouton download).
|
||||
- **Architecture actuelle :**
|
||||
- `SUPPORTED_EXTENSIONS` (`backend/indexer.py:56`) : ne contient pas `.pdf` → les PDF sont ignorés par l'indexeur, le file watcher, et l'arborescence
|
||||
- `api_file_view()` (`backend/main.py:2303`) : UnicodeDecodeError sur lecture → retourne `unsupported: true`
|
||||
- `frontend/js/viewer.js:377` : si `data.unsupported` → affiche le message binaire + bouton download
|
||||
- `pdf_export.py` : exporte du MD → PDF (WeasyPrint) — aucun rapport avec la lecture de PDF existants
|
||||
- Icone PDF déjà présente dans `EXT_ICONS` frontend (`.pdf` → `file-text`) — inutilisée
|
||||
### 74. Support complet des documents PDF — ✅ TERMINÉ
|
||||
- **Effort :** 4-5 jours | **Impact :** 🟡 | **Statut :** ✅ COMPLET (2026-09-07 — C3 + Range 206 + config G3 + indexation incrémentale, commit 7042307. Optionnels D2/E4/H2/I2 non retenus)
|
||||
- **Description :** Prise en charge native des fichiers PDF dans ObsiGate avec parité fonctionnelle complète avec les documents Markdown : apparition dans l'arborescence, indexation full-text, visualisation inline dans le navigateur, recherche TF-IDF, et téléchargement.
|
||||
- **Implémentation réelle (vérifiée 2026-09-07) :**
|
||||
- **Bugs corrigés (2026-09) :** `api_pdf_stream` crashait en 500 (`NameError: current_user` jamais injecté) ; l'indexation incrémentale du watcher faisait `read_text()` sur les PDFs (garbage) ; Range/206 et `pdf/info` absents malgré le texte ci-dessous.
|
||||
- `GET /api/file/{vault}/pdf/info` — métadonnées seules sans transférer le document (C3)
|
||||
- Stream avec `Accept-Ranges` + 206 Partial Content (single range, suffix-range, 416) (C2)
|
||||
- `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s via thread-pool) (B4/G3)
|
||||
- Backend `backend/pdf_reader.py` (existant) — extraction pypdf + pymupdf (fallback), métadonnées, TOC
|
||||
- `backend/indexer.py` — `.pdf` dans SUPPORTED_EXTENSIONS, extraction dans `index_document()`
|
||||
- `backend/main.py` — flag `is_pdf: True` retourné par `api_file_view`, endpoint `GET /api/file/{vault}/pdf/stream` avec support Range/206
|
||||
- `backend/search.py` — filtre `ext:pdf` (déjà implémenté avant cette PR)
|
||||
- `frontend/js/viewer.js:451-480` — branche `if (data.is_pdf)` + iframe + toolbar + TOC + bouton download
|
||||
- **Tests :** `tests/test_pdf.py` (26 tests verts) — text/metadata/TOC + indexation scan/incrémentale + filtre ext + stream 200/206/416 + /pdf/info + limite de taille
|
||||
- **Bug fixé dans cette PR :** `PdfReader` NameError dans `pdf_reader.py` quand pymupdf est installé (la variable `PdfReader` n'était déclarée que dans la branche `except ImportError`)
|
||||
- `backend/requirements-test.txt` (nouveau) — `reportlab` pour générer des PDFs de test
|
||||
- **Sous-tâches :**
|
||||
|
||||
##### A. Backend — Extraction de texte PDF (1-1.5 jour)
|
||||
- [ ] **A1. Dépendance** : Ajouter `pymupdf` (PyMuPDF/fitz) à `requirements.txt` — bibliothèque C performante avec extraction texte + métadonnées, déjà compatible avec l'image Docker (libs système GTK/Pango déjà présentes pour WeasyPrint). Alternative légère : `pypdf` (pure Python, pas de deps système) si pymupdf pose problème.
|
||||
- [ ] **A2. Module `backend/pdf_reader.py`** : Créer un module dédié avec les fonctions :
|
||||
- [x] **A1. Dépendance** : `pypdf>=4.0` retenu dans requirements (pure Python, simplicité Docker) ; PyMuPDF (`fitz`) utilisé automatiquement en priorité s'il est importable — l'inverse du plan initial, fonctionnellement équivalent.
|
||||
- [x] **A2. Module `backend/pdf_reader.py`** : Créer un module dédié avec les fonctions :
|
||||
- `extract_pdf_text(file_path: Path) -> str` : extrait tout le texte du PDF, page par page, avec séparateur `\f` entre pages. Gère les PDF encodés, protégés par mot de passe (retourne erreur explicite), et corrompus.
|
||||
- `extract_pdf_metadata(file_path: Path) -> dict` : extrait titre, auteur, sujet, nombre de pages, taille.
|
||||
- `extract_pdf_preview(file_path: Path, max_chars: int = 100000) -> str` : extrait les N premiers caractères pour l'indexation (limité par `SEARCH_CONTENT_LIMIT`).
|
||||
- [ ] **A3. Fallback pypdf** : Si pymupdf non disponible (exception d'import), fallback automatique sur `pypdf` avec un log warning. Code structuré avec une interface abstraite (`PdfReader` protocol) pour swap transparent.
|
||||
- [x] **A3. Fallback pypdf** : Si pymupdf non disponible (exception d'import), fallback automatique sur `pypdf` avec un log warning. Code structuré avec une interface abstraite (`PdfReader` protocol) pour swap transparent.
|
||||
|
||||
##### B. Backend — Indexation des PDF (1 jour)
|
||||
- [ ] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.pdf` au set dans `backend/indexer.py:56`. Déclencher un rebuild complet de l'index (incrémental via le file watcher pour les nouveaux PDFs).
|
||||
- [ ] **B2. Modification de `index_document()`** (`backend/indexer.py:524`) : Dans la fonction d'indexation, détecter l'extension `.pdf` et appeler `extract_pdf_text()` au lieu de `read_text()`. Le texte extrait alimente le pipeline TF-IDF existant — aucun changement nécessaire dans `search.py`.
|
||||
- [ ] **B3. Métadonnées PDF dans le document info** : Enrichir la structure de retour de `index_document()` avec les champs spécifiques PDF : `page_count`, `pdf_title` (titre extrait des métadonnées, prioritaire sur le nom de fichier), `pdf_author`.
|
||||
- [ ] **B4. Gestion d'erreur robuste** : PDF corrompu → log warning + skip (ne pas bloquer l'indexation). PDF volumineux (>50 Mo) → log info + extraction tronquée à `SEARCH_CONTENT_LIMIT`. Timeout d'extraction configurable (30s par défaut).
|
||||
- [x] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.pdf` au set dans `backend/indexer.py:56`. Déclencher un rebuild complet de l'index (incrémental via le file watcher pour les nouveaux PDFs).
|
||||
- [x] **B2. Lecture PDF dans les DEUX chemins d'indexation** (`_scan_vault` + `_index_single_file_sync`, utilisé par le watcher) : détection `.pdf` → `extract_pdf_text()`. Fix 2026-09 : seul le scan complet gérait les PDFs, l'incrémental indexait du garbage.
|
||||
- [x] **B3. Métadonnées PDF (adapté)** : titre PDF prioritaire sur le nom de fichier dans l'index ; `pages`/`author` exposés via `api_file_view` + `/pdf/info` (non stockés dans l'entrée d'index).
|
||||
- [x] **B4. Gestion d'erreur robuste** : PDF corrompu → log warning + skip (ne pas bloquer l'indexation). PDF volumineux (>50 Mo) → log info + extraction tronquée à `SEARCH_CONTENT_LIMIT`. Timeout d'extraction configurable (30s par défaut).
|
||||
|
||||
##### C. Backend — API endpoints PDF (0.5 jour)
|
||||
- [ ] **C1. Modification de `api_file_view()`** (`backend/main.py:2270`) : Avant la tentative de `read_text()`, détecter `.pdf` par extension. Pour les PDF :
|
||||
- [x] **C1. Modification de `api_file_view()`** (`backend/main.py:2270`) : Avant la tentative de `read_text()`, détecter `.pdf` par extension. Pour les PDF :
|
||||
- Extraire le texte avec `extract_pdf_text()`
|
||||
- Extraire les métadonnées (pages, auteur)
|
||||
- Retourner une réponse structurée : `is_pdf: true`, `page_count`, `pdf_metadata`, `html` (aperçu texte formaté), `raw_length`
|
||||
- Le champ `html` contient un rendu texte simple (pas de markdown) : texte paginé ou première page formatée
|
||||
- [ ] **C2. Nouvel endpoint `GET /api/file/{vault}/pdf/stream`** : Sert le fichier PDF brut avec `Content-Type: application/pdf` et `Content-Disposition: inline` pour visualisation dans le navigateur. Supporte le `Range` header (HTTP 206 Partial Content) pour le streaming progressif des gros PDFs — essentiel pour la performance sur des documents volumineux.
|
||||
- [ ] **C3. Nouvel endpoint `GET /api/file/{vault}/pdf/info`** : Retourne les métadonnées seules (pages, titre, auteur) sans le contenu — permet à l'UI d'afficher les infos avant de charger le PDF lourd.
|
||||
- [ ] **C4. Endpoint download** : Déjà fonctionnel (`/api/file/{vault}/download`) — aucun changement nécessaire.
|
||||
- [x] **C2. Nouvel endpoint `GET /api/file/{vault}/pdf/stream`** : Sert le fichier PDF brut avec `Content-Type: application/pdf` et `Content-Disposition: inline` pour visualisation dans le navigateur. Supporte le `Range` header (HTTP 206 Partial Content) pour le streaming progressif des gros PDFs — essentiel pour la performance sur des documents volumineux.
|
||||
- [x] **C3. Nouvel endpoint `GET /api/file/{vault}/pdf/info`** : Retourne les métadonnées seules (pages, titre, auteur) sans le contenu — permet à l'UI d'afficher les infos avant de charger le PDF lourd.
|
||||
- [x] **C4. Endpoint download** : Déjà fonctionnel (`/api/file/{vault}/download`) — aucun changement nécessaire.
|
||||
|
||||
##### D. Frontend — Arborescence de fichiers (0.5 jour)
|
||||
- [ ] **D1. Icône et filtre** : L'icône PDF (`file-text` de Lucide) est déjà mappée dans `EXT_ICONS` (`frontend/js/utils.js:129`). Une fois `.pdf` dans `SUPPORTED_EXTENSIONS`, les PDFs apparaissent automatiquement dans l'arborescence via l'API `list_directory`. Aucun changement UI nécessaire.
|
||||
- [ ] **D2. Distinction visuelle** (optionnel) : Sous-titre léger sous le nom du fichier dans l'arborescence indiquant le nombre de pages (ex: « 12 pages ») pour différencier rapidement les PDF des MD. Donnée disponible via l'API `pdf/info`.
|
||||
- [ ] **D3. Drag & drop et upload** : Le mécanisme d'upload existant (`POST /api/file/{vault}/upload`) fonctionne déjà pour tout type de fichier. Vérifier que le MIME type `application/pdf` est correctement détecté et que le watcher réindexe automatiquement.
|
||||
- [x] **D1. Icône et filtre** : L'icône PDF (`file-text` de Lucide) est déjà mappée dans `EXT_ICONS` (`frontend/js/utils.js:129`). Une fois `.pdf` dans `SUPPORTED_EXTENSIONS`, les PDFs apparaissent automatiquement dans l'arborescence via l'API `list_directory`. Aucun changement UI nécessaire.
|
||||
- [ ] **D2. Distinction visuelle** — ⚪ NON RETENU : Sous-titre léger sous le nom du fichier dans l'arborescence indiquant le nombre de pages (ex: « 12 pages ») pour différencier rapidement les PDF des MD. Donnée disponible via l'API `pdf/info`.
|
||||
- [x] **D3. Drag & drop et upload** : Le mécanisme d'upload existant (`POST /api/file/{vault}/upload`) fonctionne déjà pour tout type de fichier. Vérifier que le MIME type `application/pdf` est correctement détecté et que le watcher réindexe automatiquement.
|
||||
|
||||
##### E. Frontend — Viewer PDF (1 jour)
|
||||
- [ ] **E1. Rendu inline natif** : Utiliser le visualiseur PDF intégré du navigateur via `<iframe>` pointant sur `/api/file/{vault}/pdf/stream?path=...`. Approche optimale :
|
||||
- [x] **E1. Rendu inline natif** : Utiliser le visualiseur PDF intégré du navigateur via `<iframe>` pointant sur `/api/file/{vault}/pdf/stream?path=...`. Approche optimale :
|
||||
- Zéro dépendance JS supplémentaire
|
||||
- Rendu identique à Chrome/Firefox/Safari natif
|
||||
- Support natif du zoom, recherche dans le document, navigation par pages, rotation
|
||||
- L'iframe s'adapte en hauteur (`height: 100%` du content-area)
|
||||
- [ ] **E2. Détection dans le viewer** : Dans `frontend/js/viewer.js`, fonction `renderFileContent()` — ajouter une branche après la détection `data.unsupported` :
|
||||
- [x] **E2. Détection dans le viewer** : Dans `frontend/js/viewer.js`, fonction `renderFileContent()` — ajouter une branche après la détection `data.unsupported` :
|
||||
- Si `data.is_pdf === true` → render l'iframe PDF au lieu du viewer markdown
|
||||
- Si le navigateur ne supporte pas le rendu PDF inline → fallback sur l'UI « binaire » avec bouton download + bouton « Ouvrir dans un nouvel onglet »
|
||||
- [ ] **E3. Barre d'outils PDF** : Dans la barre d'outils du viewer (celle qui a déjà les boutons Copier, Source, .md, PDF, Éditer, pop-out), pour les fichiers PDF :
|
||||
- [x] **E3. Barre d'outils PDF** : Dans la barre d'outils du viewer (celle qui a déjà les boutons Copier, Source, .md, PDF, Éditer, pop-out), pour les fichiers PDF :
|
||||
- Remplacer « Copier » / « Source » / « Éditer » par des actions spécifiques PDF
|
||||
- Bouton « Télécharger » (.pdf) — déjà existant, fonctionne
|
||||
- Bouton « Plein écran » — ouvre le PDF dans un nouvel onglet en plein écran
|
||||
- Badge « N pages » indiquant le nombre de pages
|
||||
- Bouton « pop-out » — gardé, ouvre le viewer PDF dans une popup séparée
|
||||
- [ ] **E4. Thème** : L'iframe PDF est en dehors du DOM applicatif donc pas affecté par le thème dark/light. Ajouter un message discret « Le PDF s'affiche avec le thème de votre navigateur » si `_currentTheme === 'dark'` (les PDFs en fond blanc dans un thème sombre peuvent surprendre).
|
||||
- [ ] **E5. Responsive** : L'iframe s'adapte à la largeur du content-area. En mode mobile, hauteur ajustée à la viewport. La toolbar mobile existante fonctionne avec les actions PDF.
|
||||
- [ ] **E4. Thème** — ⚪ NON RETENU : L'iframe PDF est en dehors du DOM applicatif donc pas affecté par le thème dark/light. Ajouter un message discret « Le PDF s'affiche avec le thème de votre navigateur » si `_currentTheme === 'dark'` (les PDFs en fond blanc dans un thème sombre peuvent surprendre).
|
||||
- [x] **E5. Responsive** : L'iframe s'adapte à la largeur du content-area. En mode mobile, hauteur ajustée à la viewport. La toolbar mobile existante fonctionne avec les actions PDF.
|
||||
|
||||
##### F. Frontend — Recherche (0.5 jour)
|
||||
- [ ] **F1. Résultats de recherche** : Les PDFs apparaissent dans les résultats via le TF-IDF existant (le texte extrait est indexé). Ajouter un badge visuel « PDF » à côté du titre dans les résultats de recherche pour distinguer les PDFs des MD — utiliser l'icône `file-text`.
|
||||
- [ ] **F2. Snippets de recherche** : Les extraits de contexte montrent le texte extrait du PDF avec surlignage des termes recherchés — fonctionnement identique aux MD via le mécanisme de snippet existant dans `search.py`.
|
||||
- [ ] **F3. Filtres de recherche avancés** : Ajouter `ext:pdf` comme filtre pour limiter la recherche aux PDFs uniquement (complément aux filtres `created:`, `modified:`, `size:` déjà prévus #34).
|
||||
- [x] **F1. Résultats de recherche** : Les PDFs apparaissent dans les résultats via le TF-IDF existant (le texte extrait est indexé). Ajouter un badge visuel « PDF » à côté du titre dans les résultats de recherche pour distinguer les PDFs des MD — utiliser l'icône `file-text`.
|
||||
- [x] **F2. Snippets de recherche** : Les extraits de contexte montrent le texte extrait du PDF avec surlignage des termes recherchés — fonctionnement identique aux MD via le mécanisme de snippet existant dans `search.py`.
|
||||
- [x] **F3. Filtres de recherche avancés** : Ajouter `ext:pdf` comme filtre pour limiter la recherche aux PDFs uniquement (complément aux filtres `created:`, `modified:`, `size:` déjà prévus #34).
|
||||
|
||||
##### G. Docker & Dépendances (0.5 jour)
|
||||
- [ ] **G1. requirements.txt** : Ajouter `pymupdf>=1.24.0` (sinon `pypdf>=4.0` en fallback).
|
||||
- [ ] **G2. Dockerfile** : Vérifier que l'image `python:3.11-slim` dispose des libs système nécessaires pour pymupdf. Si besoin, ajouter `libmupdf-dev` ou utiliser `pypdf` (pure Python) pour éviter la complexité. Recommandation : pypdf pour la simplicité Docker, pymupdf en option pour la performance.
|
||||
- [ ] **G3. Configuration** : Ajouter `OBSIGATE_PDF_MAX_SIZE_MB` (défaut 50) pour limiter la taille des PDFs indexés et `OBSIGATE_PDF_EXTRACT_TIMEOUT` (défaut 30s).
|
||||
- [x] **G1. requirements.txt** : `pypdf>=4.0` retenu (pymupdf optionnel, utilisé s'il est importable).
|
||||
- [x] **G2. Dockerfile** : Vérifier que l'image `python:3.11-slim` dispose des libs système nécessaires pour pymupdf. Si besoin, ajouter `libmupdf-dev` ou utiliser `pypdf` (pure Python) pour éviter la complexité. Recommandation : pypdf pour la simplicité Docker, pymupdf en option pour la performance.
|
||||
- [x] **G3. Configuration** : `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s) — documentés dans `.env.example` et README FR/EN.
|
||||
|
||||
##### H. Tests (1 jour)
|
||||
- [ ] **H1. Tests unitaires backend** :
|
||||
- [x] **H1. Tests unitaires backend** :
|
||||
- `test_pdf_reader.py` : extraction texte PDF simple, PDF vide, PDF avec uniquement des images (OCR non requis — retourne chaîne vide), PDF protégé par mot de passe, PDF corrompu, extraction métadonnées
|
||||
- Fixtures : créer un PDF de test minimal (2 pages, texte simple) via `reportlab` dans les fixtures de test
|
||||
- `test_pdf_indexing.py` : vérifier qu'un PDF dans un vault est correctement indexé, que le texte est recherchable, que `index_document()` gère l'extension `.pdf`
|
||||
- `test_pdf_api.py` : endpoint `view` retourne `is_pdf: true`, endpoint `pdf/stream` retourne `application/pdf`, endpoint `pdf/info` retourne les métadonnées
|
||||
- [ ] **H2. Tests frontend** :
|
||||
- [ ] **H2. Tests frontend** — ⚪ NON RETENU :
|
||||
- Test d'intégration : naviguer vers un fichier PDF → l'iframe est rendue
|
||||
- Test : fichier PDF dans les résultats de recherche
|
||||
- Test : téléchargement de PDF fonctionnel
|
||||
- [ ] **H3. CI** : Ajouter la fixture PDF de test dans les artefacts de CI. Les tests PDF sont sautés si pymupdf/pypdf n'est pas disponible.
|
||||
- [x] **H3. CI** : Ajouter la fixture PDF de test dans les artefacts de CI. Les tests PDF sont sautés si pymupdf/pypdf n'est pas disponible.
|
||||
|
||||
##### I. Documentation utilisateur (inclus dans l'effort)
|
||||
- [ ] **I1.** Mettre à jour README.md : mentionner le support PDF dans les formats supportés
|
||||
- [ ] **I2.** Ajouter une note dans la FAQ : « Comment visualiser un PDF dans ObsiGate ? »
|
||||
- [ ] **I3.** Documenter les limitations : pas d'OCR (PDFs scannés non recherchables), pas d'annotation PDF, pas d'édition de PDF
|
||||
- [x] **I1.** Mettre à jour README.md : mentionner le support PDF dans les formats supportés
|
||||
- [ ] **I2.** Ajouter une note dans la FAQ : « Comment visualiser un PDF dans ObsiGate ? » — ⚪ NON RETENU (README + guide couvrent déjà le support PDF)
|
||||
- [x] **I3.** Documenter les limitations : pas d'OCR (PDFs scannés non recherchables), pas d'annotation PDF, pas d'édition de PDF
|
||||
|
||||
##### J. Points d'attention / Risques
|
||||
- **Performance** : Un PDF de 500 pages peut générer beaucoup de texte → `SEARCH_CONTENT_LIMIT` (100 Ko) limite l'indexation au début du document. Pour les PDFs volumineux, envisager une extraction paginée avec `SEARCH_CONTENT_LIMIT` réparti sur les N premières pages.
|
||||
@@ -336,9 +351,9 @@
|
||||
|
||||
---
|
||||
|
||||
### 75. Éditeur multi-panneaux (Split View) ✅ Complété
|
||||
### 75. Éditeur multi-panneaux (Split View) — ✅ TERMINÉ (E2E matriciels inclus)
|
||||
- **Effort :** 5-7 jours (réalisé) | **Impact :** 🟡
|
||||
- **Statut :** Fonctionnel — toutes les sous-tâches implémentées (reste tests I2/I3)
|
||||
- **Statut :** Fonctionnel — 100% implémenté + 37 tests E2E (16 split-view.spec.js + 21 split-view-matrix.spec.js, 2026-09-08)
|
||||
- **Fichiers clés :** `frontend/js/pane-manager.js` (1082 loc), `frontend/js/viewer.js` (modifié), `frontend/js/ui.js` (modifié), `frontend/js/dashboard.js` (modifié), `frontend/js/palette.js` (modifié), `frontend/style.css` (modifié), `tests/test_pane_manager.py` (22 tests)
|
||||
- **Description :** Système de panneaux divisés permettant d'afficher plusieurs documents côte à côte ou superposés, à la manière d'un éditeur de code (VS Code). Drag & drop des onglets entre les panneaux, drag-to-split, menu contextuel, raccourcis clavier, palette de commandes.
|
||||
- **Architecture :**
|
||||
@@ -397,22 +412,43 @@
|
||||
##### ✅ I. Tests
|
||||
- [x] **I1.** 22 tests statiques (structure, CSS, brace balance, régression backend)
|
||||
- [x] **I4.** 307 tests de régression passent
|
||||
- [ ] **I2.** Tests d'intégration frontend (JS DOM)
|
||||
- [ ] **I3.** Tests E2E Playwright (#58)
|
||||
- [x] **I2.** Tests d'intégration frontend (JSDOM) — `tests/frontend/pane-manager.test.mjs` (9 tests)
|
||||
- [x] **I3.** Tests E2E Playwright (#58) — 2026-09 : `split-view.spec.js` (16) + `split-view-matrix.spec.js` (21) = 37 tests verts en local + CI
|
||||
|
||||
##### Reste à faire (0)
|
||||
##### Reste à faire
|
||||
- [x] **C1b.** Sous-menu « Déplacer vers... » dans le menu contextuel des onglets
|
||||
- [x] **D2.** Drag & drop depuis l'arborescence vers un panneau spécifique
|
||||
- [x] **E3.** Rendu paresseux (mémoire) — contenu masqué via `display:none` sur panneaux inactifs
|
||||
- [x] **E4.** Verrouillage éditeur multi-panneau — même fichier ouvert dans 2 panneaux → focus le panneau existant
|
||||
- [x] **G3.** Bouton reset dans la palette de commandes (🔄 Réinitialiser les panneaux)
|
||||
- [ ] **I2.** Tests d'intégration frontend (JSDOM ou similaire)
|
||||
- [ ] **I3.** Tests E2E Playwright
|
||||
- [x] **I2.** Tests d'intégration frontend (JSDOM) — `tests/frontend/pane-manager.test.mjs` (9 tests, 100% verts)
|
||||
- [x] **I3.** Tests E2E Playwright — matrice complète ouverture/fermeture couverte (voir split-view-matrix.spec.js)
|
||||
|
||||
##### Matrice de couverture ouverture/fermeture (validée 2026-09-08, 37/37 verts)
|
||||
| Cas | Surface testée | Spec |
|
||||
|---|---|---|
|
||||
| Ouverture fichier → onglet | clic arbre, double-clic preview vs persistant | split-view |
|
||||
| Split droit | raccourci Ctrl+Alt+\ · bouton ⊞→ · menu contextuel · palette · drag-to-split bord droit | les 5 |
|
||||
| Split bas | raccourci Ctrl+Alt+Shift+\ · bouton ⊞↓ · menu contextuel · drag-to-split bord bas | les 4 |
|
||||
| Déplacer onglet | drag cross-pane · flyout « Déplacer vers... » · drag depuis arbre vers panneau | les 3 |
|
||||
| Navigation | Ctrl+Alt+←/→ (horizontal) · Ctrl+Alt+↑/↓ (vertical) · palette suivant/précédent | les 3 |
|
||||
| Fermeture onglet | bouton X · double-clic · clic molette · Ctrl+W (single) | les 4 |
|
||||
| Fermeture menu ctx | Fermer · Fermer les autres · Fermer à droite · Fermer tout → dashboard | les 4 |
|
||||
| Fermeture panneau | Ctrl+Alt+W (clavier) · menu « Fermer le panneau » · Ctrl+Shift+Alt+W (autres) · auto-collapse panneau vidé (C4) · fermeture pane 0 → voisin survit · onglets du panneau fermé perdus (documenté) | les 5 |
|
||||
| Persistance | layout restauré au reload · reset palette vide localStorage | les 2 |
|
||||
| Edge | plafond 4 panneaux (PANE_MAX) · pas de doublon à la réouverture (E4) | les 2 |
|
||||
|
||||
Non couvert par E2E (hors périmètre ouverture/fermeture) : poignée de redimensionnement drag/dblclick-reset (A3) — logique vérifiée par les 22 tests statiques + JSDOM.
|
||||
|
||||
---
|
||||
|
||||
### 76. BooksLM — Console AI contextuelle par répertoire (style NotebookLM)
|
||||
- **Effort :** 5-6 jours | **Impact :** 🟡
|
||||
### 76. BooksLM — Console AI contextuelle par répertoire (style NotebookLM) — ✅ TERMINÉ
|
||||
> **Extension (2026-09-11) :** l'assistant AI est désormais **multi-contexte** — Répertoire
|
||||
> (mode historique), Documents (documents ouverts, via le bouton flottant) et Général (aucun
|
||||
> document ouvert : aide sur l'app + création de fichiers/dossiers par cartes d'action). Header
|
||||
> refondu (sélecteur fournisseur/modèle sur une ligne dédiée), `Entrée` envoie / `Ctrl+Entrée`
|
||||
> insère un saut de ligne. Voir CHANGELOG.
|
||||
- **Effort :** 5-6 jours (réalisé) | **Impact :** 🟡
|
||||
- **Description :** Console de chat AI contextuelle accessible via le menu contextuel des répertoires dans l'arborescence. Au clic sur « BooksLM », un panneau de chat s'ouvre à droite du viewer et indexe automatiquement toutes les ressources markdown (et PDF via #74) du répertoire courant et de ses sous-répertoires récursivement comme contexte pour un assistant AI. L'assistant peut répondre à des questions, résumer, synthétiser, et croiser l'information à travers tous les documents du scope — exactement comme NotebookLM de Google, mais pour n'importe quel répertoire de votre vault Obsidian.
|
||||
- **Fonctionnement général :**
|
||||
- L'utilisateur fait un clic-droit sur un répertoire dans l'arborescence → option « BooksLM »
|
||||
@@ -423,65 +459,65 @@
|
||||
- L'historique de chat est optionnellement sauvegardé (localStorage ou fichier `.books-lm.json` dans le répertoire)
|
||||
- **Sous-tâches :**
|
||||
|
||||
##### A. Backend — Collecte et préparation du contexte (1.5-2 jours)
|
||||
- [ ] **A1. Nouvel endpoint `POST /api/ai/bookslm/context`** : Reçoit `{vault, directory}` → parcourt récursivement le répertoire → lit tous les fichiers supportés → retourne un objet `{files: [{path, title, content, type: "md"|"pdf"}], total_chars, file_count, directory_tree}`
|
||||
- [ ] **A2. Limites configurables** : `BOOKSLM_MAX_FILES` (défaut 200), `BOOKSLM_MAX_TOTAL_CHARS` (défaut 200 000), `BOOKSLM_MAX_FILE_CHARS` (défaut 30 000 par fichier). Les fichiers au-delà sont tronqués avec un message `[... continue dans le fichier]`.
|
||||
- [ ] **A3. Filtrage intelligent** : Ignorer les fichiers cachés (`.` préfixe), les dossiers `_attachments/`, les fichiers binaires non-supportés. Respecter `.gitignore` ou `.obsigate-ignore` si présent.
|
||||
- [ ] **A4. Streaming du contexte** : Pour les très gros répertoires, l'endpoint supporte le streaming SSE pour informer l'UI de la progression (« Indexation de 45/127 fichiers... »).
|
||||
##### A. Backend — Collecte et préparation du contexte (1.5-2 jours) — ✅ livré (backend/bookslm.py, bookslm_routes.py)
|
||||
- [x] **A1. Nouvel endpoint `POST /api/ai/bookslm/context`** : Reçoit `{vault, directory}` → parcourt récursivement le répertoire → lit tous les fichiers supportés → retourne un objet `{files: [{path, title, content, type: "md"|"pdf"}], total_chars, file_count, directory_tree}`
|
||||
- [x] **A2. Limites configurables** : `BOOKSLM_MAX_FILES` (défaut 200), `BOOKSLM_MAX_TOTAL_CHARS` (défaut 200 000), `BOOKSLM_MAX_FILE_CHARS` (défaut 30 000 par fichier). Les fichiers au-delà sont tronqués avec un message `[... continue dans le fichier]`.
|
||||
- [x] **A3. Filtrage intelligent** : Ignorer les fichiers cachés (`.` préfixe), les dossiers `_attachments/`, les fichiers binaires non-supportés. Respecter `.gitignore` ou `.obsigate-ignore` si présent.
|
||||
- [ ] **A4. Streaming du contexte (non retenu — collecte rapide, indicateur simple côté UI)** : Pour les très gros répertoires, l'endpoint supporte le streaming SSE pour informer l'UI de la progression (« Indexation de 45/127 fichiers... »).
|
||||
|
||||
##### B. Backend — Endpoint chat BooksLM (1 jour)
|
||||
- [ ] **B1. Endpoint `POST /api/ai/bookslm/chat`** : Reçoit `{vault, directory, message, conversation_history: [{role, content}]}` → construit le contexte système à partir des fichiers du répertoire → appelle le provider AI configuré → stream la réponse via SSE.
|
||||
- [ ] **B2. Prompt système** : Template par défaut optimisé : « Tu es un assistant de recherche qui aide à comprendre et analyser les documents d'un répertoire. Voici le contenu de tous les documents disponibles. Réponds en te basant UNIQUEMENT sur ces documents. Cite tes sources avec le nom du fichier. Si l'information n'est pas dans les documents, dis-le clairement. »
|
||||
- [ ] **B3. Mode « Sources »** : Chaque réponse inclut les fichiers référencés (détectés via mention de titre ou contenu). L'UI affiche des badges de source cliquables.
|
||||
- [ ] **B4. Mise en cache du contexte** : Le contexte du répertoire est caché en mémoire (hash du contenu) pour éviter de re-parser tous les fichiers à chaque message. Invalidé si un fichier est modifié (watcher).
|
||||
- [ ] **B5. Provider** : Utilise la même abstraction provider que l'AI Editor (#27) — DeepSeek, OpenRouter, Gemini. Ajouter `BOOKSLM_DEFAULT_MODEL` dans `.env` (défaut : `DEEPSEEK_MODEL`).
|
||||
##### B. Backend — Endpoint chat BooksLM (1 jour) — ✅ livré
|
||||
- [x] **B1. Endpoint `POST /api/ai/bookslm/chat`** : Reçoit `{vault, directory, message, conversation_history: [{role, content}]}` → construit le contexte système à partir des fichiers du répertoire → appelle le provider AI configuré → stream la réponse via SSE.
|
||||
- [x] **B2. Prompt système** : Template par défaut optimisé : « Tu es un assistant de recherche qui aide à comprendre et analyser les documents d'un répertoire. Voici le contenu de tous les documents disponibles. Réponds en te basant UNIQUEMENT sur ces documents. Cite tes sources avec le nom du fichier. Si l'information n'est pas dans les documents, dis-le clairement. »
|
||||
- [x] **B3. Mode « Sources »** : Chaque réponse inclut les fichiers référencés (détectés via mention de titre ou contenu). L'UI affiche des badges de source cliquables.
|
||||
- [x] **B4. Mise en cache du contexte** : Le contexte du répertoire est caché en mémoire (hash du contenu) pour éviter de re-parser tous les fichiers à chaque message. Invalidé si un fichier est modifié (watcher).
|
||||
- [x] **B5. Provider** : Utilise la même abstraction provider que l'AI Editor (#27) — DeepSeek, OpenRouter, Gemini. Ajouter `BOOKSLM_DEFAULT_MODEL` dans `.env` (défaut : `DEEPSEEK_MODEL`).
|
||||
|
||||
##### C. Frontend — Panneau de chat BooksLM (2 jours)
|
||||
- [ ] **C1. Module `frontend/js/bookslm.js`** : Nouveau module ES avec la classe `BooksLM` :
|
||||
##### C. Frontend — Panneau de chat BooksLM (2 jours) — ✅ livré (frontend/js/bookslm.js)
|
||||
- [x] **C1. Module `frontend/js/bookslm.js`** : Nouveau module ES avec la classe `BooksLM` :
|
||||
- Gère l'état : `_isOpen`, `_currentDirectory`, `_messages[]`, `_contextFiles[]`, `_isLoading`
|
||||
- Crée le DOM du panneau : conteneur latéral `.bookslm-panel` (450px, redimensionnable via poignée)
|
||||
- Header : titre « BooksLM », nom du répertoire courant, bouton fermer, bouton « Nouvelle conversation »
|
||||
- Zone de messages : scrollable, bulles utilisateur (droite) et assistant (gauche) avec Markdown rendu
|
||||
- Zone d'entrée : `textarea` avec Ctrl+Enter pour envoyer, bouton envoyer
|
||||
- Barre d'état : nombre de fichiers indexés, nombre total de caractères
|
||||
- [ ] **C2. Intégration au menu contextuel** : Dans `frontend/js/context-menu.js`, ajouter l'option « 🧠 BooksLM » pour les nœuds de type `directory` dans l'arborescence. Visible seulement si le vault est accessible.
|
||||
- [ ] **C3. Rendu Markdown dans le chat** : Utiliser le renderer Markdown existant (ou un sous-ensemble simplifié) pour afficher les réponses de l'AI avec support du **gras**, *italique*, `code`, listes, et tableaux.
|
||||
- [ ] **C4. Streaming des réponses** : Connexion SSE pour afficher la réponse de l'AI token par token (effet « typing » naturel).
|
||||
- [ ] **C5. Badges de sources** : Après chaque réponse, afficher les fichiers sources mentionnés sous forme de badges cliquables qui ouvrent le fichier dans le viewer principal.
|
||||
- [ ] **C6. Mode plein écran** : Bouton pour basculer en mode plein écran (cache la sidebar, le panneau prend tout l'espace). Utile pour les sessions de recherche intense.
|
||||
- [x] **C2. Intégration au menu contextuel** : Dans `frontend/js/context-menu.js`, ajouter l'option « 🧠 BooksLM » pour les nœuds de type `directory` dans l'arborescence. Visible seulement si le vault est accessible.
|
||||
- [x] **C3. Rendu Markdown dans le chat** : Utiliser le renderer Markdown existant (ou un sous-ensemble simplifié) pour afficher les réponses de l'AI avec support du **gras**, *italique*, `code`, listes, et tableaux.
|
||||
- [x] **C4. Streaming des réponses** : Connexion SSE pour afficher la réponse de l'AI token par token (effet « typing » naturel).
|
||||
- [x] **C5. Badges de sources** : Après chaque réponse, afficher les fichiers sources mentionnés sous forme de badges cliquables qui ouvrent le fichier dans le viewer principal.
|
||||
- [x] **C6. Mode plein écran** : Bouton pour basculer en mode plein écran (cache la sidebar, le panneau prend tout l'espace). Utile pour les sessions de recherche intense.
|
||||
|
||||
##### D. Frontend — Actions et UX (0.5-1 jour)
|
||||
- [ ] **D1. Copier la réponse** : Bouton copie sur chaque message assistant.
|
||||
- [ ] **D2. Régénérer** : Bouton pour régénérer la dernière réponse (utile si la réponse est hors-sujet).
|
||||
- [ ] **D3. Exporter la conversation** : Bouton pour exporter l'historique en Markdown → sauvegarder comme note dans le répertoire courant.
|
||||
- [ ] **D4. Historique des conversations** : Stockage dans `localStorage` par clé `bookslm-history-{vault}-{directory}`. Liste déroulante dans le header pour charger une conversation précédente.
|
||||
- [ ] **D5. Indicateur de contexte** : Barre de progression montrant l'utilisation du contexte (% de la limite `BOOKSLM_MAX_TOTAL_CHARS`). Si le répertoire est trop gros, suggérer de réduire le scope.
|
||||
- [ ] **D6. Suggestions de questions** : Après l'indexation, afficher 3 questions suggérées basées sur les titres et métadonnées des fichiers (« Résume ce répertoire », « Quels sont les thèmes principaux ? », « Y a-t-il des contradictions entre ces documents ? »).
|
||||
##### D. Frontend — Actions et UX (0.5-1 jour) — ✅ livré (D5 partiel)
|
||||
- [x] **D1. Copier la réponse** : Bouton copie sur chaque message assistant.
|
||||
- [x] **D2. Régénérer** : Bouton pour régénérer la dernière réponse (utile si la réponse est hors-sujet).
|
||||
- [x] **D3. Exporter la conversation** : Bouton pour exporter l'historique en Markdown → sauvegarder comme note dans le répertoire courant.
|
||||
- [x] **D4. Historique des conversations** : Stockage dans `localStorage` par clé `bookslm-history-{vault}-{directory}`. Liste déroulante dans le header pour charger une conversation précédente.
|
||||
- [x] **D5. Indicateur de contexte (barre de progression %) — non retenu, compteur fichiers/caractères affiché)** : Barre de progression montrant l'utilisation du contexte (% de la limite `BOOKSLM_MAX_TOTAL_CHARS`). Si le répertoire est trop gros, suggérer de réduire le scope.
|
||||
- [x] **D6. Suggestions de questions** : Après l'indexation, afficher 3 questions suggérées basées sur les titres et métadonnées des fichiers (« Résume ce répertoire », « Quels sont les thèmes principaux ? », « Y a-t-il des contradictions entre ces documents ? »).
|
||||
|
||||
##### E. CSS & Design (0.5 jour)
|
||||
- [ ] **E1. Panneau latéral** : Animation slide-in depuis la droite (300ms ease-out). Ombre portée pour séparation visuelle.
|
||||
- [ ] **E2. Poignée de redimensionnement** : Similaire à `.sidebar-resize-handle`, curseur `col-resize`, largeur min 350px, max 800px. Persistance dans localStorage.
|
||||
- [ ] **E3. Bulles de chat** : Style cohérent avec le thème actuel. Messages utilisateur avec accent-color, messages assistant avec fond `var(--surface2)`.
|
||||
- [ ] **E4. Responsive** : Sur mobile (<768px), le panneau passe en plein écran (pas de split view). Navigation par swipe pour revenir au viewer.
|
||||
- [ ] **E5. Thème sombre/clair** : Toutes les variables CSS utilisent les customs properties existantes → compatibilité automatique.
|
||||
##### E. CSS & Design (0.5 jour) — ✅ livré
|
||||
- [x] **E1. Panneau latéral** : Animation slide-in depuis la droite (300ms ease-out). Ombre portée pour séparation visuelle.
|
||||
- [x] **E2. Poignée de redimensionnement** : Similaire à `.sidebar-resize-handle`, curseur `col-resize`, largeur min 350px, max 800px. Persistance dans localStorage.
|
||||
- [x] **E3. Bulles de chat** : Style cohérent avec le thème actuel. Messages utilisateur avec accent-color, messages assistant avec fond `var(--surface2)`.
|
||||
- [x] **E4. Responsive** : Sur mobile (<768px), le panneau passe en plein écran (pas de split view). Navigation par swipe pour revenir au viewer.
|
||||
- [x] **E5. Thème sombre/clair** : Toutes les variables CSS utilisent les customs properties existantes → compatibilité automatique.
|
||||
|
||||
##### F. Intégration et compatibilité (0.5 jour)
|
||||
- [ ] **F1. Compatibilité Split View (#75)** : Si le split view est actif, BooksLM s'ouvre en remplacement du panneau le plus à droite (ou en 3e colonne). Le panneau BooksLM est traité comme un type spécial de pane dans le PaneManager.
|
||||
- [ ] **F2. Compatibilité AI Editor (#26-29)** : BooksLM utilise le même système de provider AI. Les clés API configurées pour l'AI Editor fonctionnent pour BooksLM.
|
||||
- [ ] **F3. Compatibilité PDF (#74)** : Si le support PDF est implémenté, les PDFs dans le répertoire sont inclus dans le contexte (texte extrait).
|
||||
- [ ] **F4. Palette de commandes (#31)** : Ajouter les commandes « BooksLM: Ouvrir pour le répertoire courant » et « BooksLM: Nouvelle conversation ».
|
||||
##### F. Intégration et compatibilité (0.5 jour) — ✅ livré (F1 adapté : panneau dédié, pas un pane du PaneManager)
|
||||
- [ ] **F1. Compatibilité Split View (#75) (adapté : panneau latéral indépendant, cohabite avec le split view)** : Si le split view est actif, BooksLM s'ouvre en remplacement du panneau le plus à droite (ou en 3e colonne). Le panneau BooksLM est traité comme un type spécial de pane dans le PaneManager.
|
||||
- [x] **F2. Compatibilité AI Editor (#26-29)** : BooksLM utilise le même système de provider AI. Les clés API configurées pour l'AI Editor fonctionnent pour BooksLM.
|
||||
- [x] **F3. Compatibilité PDF (#74)** : Si le support PDF est implémenté, les PDFs dans le répertoire sont inclus dans le contexte (texte extrait).
|
||||
- [x] **F4. Palette de commandes (#31)** : Ajouter les commandes « BooksLM: Ouvrir pour le répertoire courant » et « BooksLM: Nouvelle conversation ».
|
||||
|
||||
##### G. Tests (1 jour)
|
||||
- [ ] **G1. Tests unitaires backend** :
|
||||
##### G. Tests (1 jour) — ✅ G1 livré (28 tests), G2/G3 non retenus
|
||||
- [x] **G1. Tests unitaires backend** :
|
||||
- `test_bookslm_context.py` : collecte récursive, respect des limites, filtrage fichiers cachés, streaming SSE
|
||||
- `test_bookslm_chat.py` : construction du prompt, caching du contexte, invalidation après modification
|
||||
- [ ] **G2. Tests d'intégration frontend** :
|
||||
- [ ] **G2. Tests d'intégration frontend (non retenus)** :
|
||||
- Ouverture du panneau BooksLM depuis le menu contextuel
|
||||
- Envoi d'un message et affichage de la réponse
|
||||
- Badges de sources cliquables
|
||||
- Export de conversation
|
||||
- Redimensionnement du panneau
|
||||
- [ ] **G3. Tests E2E (Playwright, #58)** :
|
||||
- [ ] **G3. Tests E2E (Playwright) (non retenus à ce jour)** :
|
||||
- Test : clic-droit sur répertoire → BooksLM → panneau visible
|
||||
- Test : chat fonctionnel → message envoyé → réponse reçue
|
||||
- Test : fermeture et réouverture → historique restauré
|
||||
@@ -498,7 +534,7 @@
|
||||
|
||||
### 78. Éditeur Excalidraw — Ouverture et édition de fichiers .excalidraw
|
||||
|
||||
- **Effort :** 3-4 jours | **Impact :** 🟡 | **Statut :** ⚪ Prévu
|
||||
- **Effort :** 3-4 jours | **Impact :** 🟡 | **Statut :** ✅ TERMINÉ (2026-09-10 — éditeur iframe complet, détection, création, autosave, support `.excalidraw.md`, B5 extraction texte pour la recherche, C8 création via menu contextuel, F3 E2E `tests/e2e/excalidraw.spec.js`, doc H1-H3. F2 non retenu. BUG-002 corrigé)
|
||||
|
||||
- **Description :** Prise en charge native des fichiers `.excalidraw` dans ObsiGate avec un éditeur visuel complet intégré. L'utilisateur peut ouvrir un fichier `.excalidraw` depuis l'arborescence et obtenir l'éditeur de diagrammes Excalidraw directement dans ObsiGate — dessiner, modifier, sauvegarder, comme dans l'app Excalidraw standalone, mais intégré au flux de travail du vault Obsidian.
|
||||
|
||||
@@ -532,8 +568,8 @@
|
||||
- **Sous-tâches :**
|
||||
|
||||
##### A. Fichier `frontend/excalidraw-editor.html` — Éditeur autonome (1.5 jour)
|
||||
- [ ] **A1. Structure HTML** : Page minimale avec un `<div id="excalidraw-container">` en plein écran. Pas de header ObsiGate — tout l'espace est pour le canvas.
|
||||
- [ ] **A2. Import Excalidraw** :
|
||||
- [x] **A1. Structure HTML** : Page minimale avec un `<div id="excalidraw-container">` en plein écran. Pas de header ObsiGate — tout l'espace est pour le canvas.
|
||||
- [x] **A2. Import Excalidraw** :
|
||||
```html
|
||||
<script type="module">
|
||||
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/excalidraw@0.18.0";
|
||||
@@ -541,8 +577,8 @@
|
||||
</script>
|
||||
```
|
||||
Version épinglée (`@0.18.0`) pour la stabilité. Mise à jour manuelle testée.
|
||||
- [ ] **A3. Configuration du chemin d'assets** : Définir `window.EXCALIDRAW_ASSET_PATH` pour pointer vers le CDN des fonts/polices d'Excalidraw (nécessaire pour le rendu des polices handwriting).
|
||||
- [ ] **A4. Initialisation React** : Excalidraw nécessite React + ReactDOM. Les importer depuis esm.sh également :
|
||||
- [x] **A3. Configuration du chemin d'assets** : Définir `window.EXCALIDRAW_ASSET_PATH` pour pointer vers le CDN des fonts/polices d'Excalidraw (nécessaire pour le rendu des polices handwriting).
|
||||
- [x] **A4. Initialisation React** (import map esm.sh → React 18.3.1 épinglé) : Excalidraw nécessite React + ReactDOM. Les importer depuis esm.sh également :
|
||||
```html
|
||||
<script type="module">
|
||||
import React from "https://esm.sh/react@18";
|
||||
@@ -551,37 +587,37 @@
|
||||
window.ReactDOM = ReactDOM;
|
||||
</script>
|
||||
```
|
||||
- [ ] **A5. Rendu du composant** : Monter `<ExcalidrawLib.Excalidraw>` dans le conteneur avec les `initialData` reçues. Configurer les callbacks `onChange` pour détecter les modifications.
|
||||
- [ ] **A6. Barre d'outils minimaliste** (dans l'iframe, superposée en haut à droite) :
|
||||
- [x] **A5. Rendu du composant** : Monter `<ExcalidrawLib.Excalidraw>` dans le conteneur avec les `initialData` reçues. Configurer les callbacks `onChange` pour détecter les modifications.
|
||||
- [x] **A6. Barre d'outils minimaliste** (dans l'iframe, superposée en haut à droite) :
|
||||
- Bouton « 💾 Sauvegarder » → envoie les données au parent
|
||||
- Badge « Modifié » (disparaît après sauvegarde)
|
||||
- Indicateur de thème 🌙/☀️
|
||||
- Optionnel : bouton « Export PNG » et « Export SVG » (natif Excalidraw)
|
||||
- [ ] **A7. Communication postMessage** :
|
||||
- [x] **A7. Communication postMessage** :
|
||||
- Réception : écouter `message` → si `type === "init"`, charger `data.elements` + `data.appState` + `data.files` dans l'état Excalidraw. Si `type === "theme"`, basculer `theme` (dark/light).
|
||||
- Émission : `postMessage({type: "save", data: {elements, appState, files}}, "*")` quand l'utilisateur sauvegarde.
|
||||
- Émission : `postMessage({type: "ready"}, "*")` au chargement pour signaler que l'iframe est prête.
|
||||
- Émission : `postMessage({type: "modified", dirty: true/false}, "*")` pour l'indicateur de modification.
|
||||
- [ ] **A8. Gestion des erreurs** : Si les données sont invalides (JSON corrompu, pas un fichier Excalidraw), afficher un message d'erreur stylisé dans l'iframe.
|
||||
- [x] **A8. Gestion des erreurs** : Si les données sont invalides (JSON corrompu, pas un fichier Excalidraw), afficher un message d'erreur stylisé dans l'iframe.
|
||||
|
||||
##### B. Backend — Détection et API (0.5 jour)
|
||||
- [ ] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés.
|
||||
- [ ] **B2. Icône** : Ajouter `.excalidraw` dans `EXT_ICONS` (`frontend/js/utils.js`) → icône `pen-tool` ou `edit-3` (Lucide).
|
||||
- [ ] **B3. Détection dans `api_file_view()`** : Dans `backend/main.py`, pour les fichiers `.excalidraw` :
|
||||
- [x] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés.
|
||||
- [x] **B2. Icône** : Ajouter `.excalidraw` dans `EXT_ICONS` (`frontend/js/utils.js`) → icône `pen-tool` ou `edit-3` (Lucide).
|
||||
- [x] **B3. Détection dans `api_file_view()`** : Dans `backend/main.py`, pour les fichiers `.excalidraw` :
|
||||
- Lire le JSON
|
||||
- Vérifier `data.get("type") === "excalidraw"`
|
||||
- Retourner `is_excalidraw: true` + les données parsées (`elements`, `appState`, `files`)
|
||||
- Si le JSON est invalide ou n'est pas un fichier Excalidraw valide → fallback sur le viewer JSON standard
|
||||
- [ ] **B4. Endpoint de sauvegarde** : Le endpoint existant `PUT /api/file/{vault}` fonctionne déjà pour écrire du contenu. L'iframe envoie le JSON modifié via postMessage → le parent appelle l'API existante. Aucun nouvel endpoint nécessaire.
|
||||
- [ ] **B5. Indexation du contenu texte** : Extraire le texte des éléments Excalidraw (`element.text` pour les éléments de type `text`) pour l'indexation TF-IDF. Permet de rechercher du texte présent dans les diagrammes.
|
||||
- [ ] **B6. Contenu initial pour nouveaux fichiers** : Définir le squelette JSON minimum pour un fichier `.excalidraw` vide :
|
||||
- [x] **B4. Endpoint de sauvegarde** : Le endpoint existant `PUT /api/file/{vault}` fonctionne déjà pour écrire du contenu. L'iframe envoie le JSON modifié via postMessage → le parent appelle l'API existante. Aucun nouvel endpoint nécessaire.
|
||||
- [x] **B5. Indexation du contenu texte** (FAIT 2026-09) : `extract_excalidraw_indexable()` extrait `element.text` des éléments (JSON pur et `.excalidraw.md` compressé) → recherche TF-IDF fonctionnelle.
|
||||
- [x] **B6. Contenu initial pour nouveaux fichiers** : Définir le squelette JSON minimum pour un fichier `.excalidraw` vide :
|
||||
```json
|
||||
{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}
|
||||
```
|
||||
Ce squelette est retourné par le backend quand on crée un fichier `.excalidraw` (utilisé par `POST /api/file/{vault}`).
|
||||
|
||||
##### C. Frontend — Intégration dans le viewer (1 jour)
|
||||
- [ ] **C1. Module `frontend/js/excalidraw-viewer.js`** (nouveau) : Fonction `renderExcalidraw(container, data, vault, path)` :
|
||||
- [x] **C1. Module `frontend/js/excalidraw-viewer.js`** (nouveau) : Fonction `renderExcalidraw(container, data, vault, path)` :
|
||||
- Crée une `<iframe>` avec `src="/frontend/excalidraw-editor.html"` et `sandbox="allow-scripts allow-same-origin"`
|
||||
- Stocke une référence à l'iframe pour la communication
|
||||
- Attend le message `ready` de l'iframe
|
||||
@@ -589,46 +625,46 @@
|
||||
- Écoute les messages `save` → appelle `saveFile(vault, path, JSON.stringify(data))` via l'API existante
|
||||
- Écoute les messages `modified` → met à jour l'indicateur dans la barre d'onglets
|
||||
- Gère le thème : écoute `themeChanged` → envoie `postMessage({type: "theme", theme})` à l'iframe
|
||||
- [ ] **C2. Dispatch dans `viewer.js`** : Dans `renderFileContent()` ou `renderFile()` :
|
||||
- [x] **C2. Dispatch dans `viewer.js`** : Dans `renderFileContent()` ou `renderFile()` :
|
||||
- Après la détection `data.is_json`, ajouter une branche : si `data.is_excalidraw === true` → appeler `renderExcalidraw(container, data, vaultName, filePath)`
|
||||
- Ne PAS passer par le viewer markdown standard
|
||||
- [ ] **C3. Barre d'outils contextuelle** : Dans la toolbar du viewer (celle avec Copier/Source/Éditer/PDF/pop-out) :
|
||||
- [x] **C3. Barre d'outils contextuelle** : Dans la toolbar du viewer (celle avec Copier/Source/Éditer/PDF/pop-out) :
|
||||
- Pour les fichiers `.excalidraw` : remplacer « Éditer (Forge) » par « Ouvrir dans Excalidraw.com » (lien externe, nouvel onglet)
|
||||
- Garder « Télécharger » (.excalidraw) et « pop-out »
|
||||
- Badge « Excalidraw » avec icône `pen-tool`
|
||||
- [ ] **C4. Auto-save** : Débounce 2 secondes après la dernière modification dans l'iframe → sauvegarde automatique silencieuse (comme l'éditeur markdown #29). L'iframe émet `modified` → le parent démarre un timer → au bout de 2s sans nouvelle modification → `postMessage({type: "requestSave"})` → l'iframe répond avec `save` → le parent écrit via l'API.
|
||||
- [ ] **C5. Raccourci Ctrl+S** : L'iframe intercepte Ctrl+S → envoie `save` au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »).
|
||||
- [ ] **C6. Compatibilité Split View (#75)** : L'iframe s'affiche dans le content-area du panneau actif. Le `PaneTabManager` gère le cache : quand on switch d'onglet, l'état de l'iframe est préservé (elle reste dans le DOM, juste masquée). Plusieurs iframes Excalidraw peuvent coexister dans différents panneaux.
|
||||
- [ ] **C7. Création via la modale « Nouveau fichier »** : Dans `frontend/js/ui.js`, fonction `showCreateFileModal()` :
|
||||
- [x] **C4. Auto-save** : Débounce 2 secondes après la dernière modification dans l'iframe → sauvegarde automatique silencieuse (comme l'éditeur markdown #29). L'iframe émet `modified` → le parent démarre un timer → au bout de 2s sans nouvelle modification → `postMessage({type: "requestSave"})` → l'iframe répond avec `save` → le parent écrit via l'API.
|
||||
- [x] **C5. Raccourci Ctrl+S** : L'iframe intercepte Ctrl+S → envoie `save` au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »).
|
||||
- [x] **C6. Compatibilité Split View (#75)** : L'iframe s'affiche dans le content-area du panneau actif. Le `PaneTabManager` gère le cache : quand on switch d'onglet, l'état de l'iframe est préservé (elle reste dans le DOM, juste masquée). Plusieurs iframes Excalidraw peuvent coexister dans différents panneaux.
|
||||
- [x] **C7. Création via la modale « Nouveau fichier »** : Dans `frontend/js/ui.js`, fonction `showCreateFileModal()` :
|
||||
- Ajouter `<option value=".excalidraw">Excalidraw (.excalidraw)</option>` dans le `<select id="file-ext-select">` (après `.json`)
|
||||
- Quand l'extension `.excalidraw` est sélectionnée, le backend crée le fichier avec le squelette JSON minimum (B6)
|
||||
- Après création → `openFile(vault, path)` → le viewer détecte `is_excalidraw: true` → l'iframe s'ouvre avec le canvas vierge
|
||||
- Fonctionne aussi via la palette de commandes `Ctrl+Alt+Space` → « Nouveau fichier » (action `create-file` existante)
|
||||
- [ ] **C8. Création via le menu contextuel de l'arborescence** : Dans `frontend/js/context-menu.js`, ajouter une option « 🎨 Nouveau diagramme Excalidraw » dans le menu contextuel des répertoires → ouvre directement la modale avec `.excalidraw` pré-sélectionné.
|
||||
- [x] **C8. Création via le menu contextuel** (FAIT 2026-09 — `frontend/js/ui.js` `_createExcalidraw()`, option « Nouveau diagramme Excalidraw » sur les répertoires, E2E couvert) : ouvre la modale avec `.excalidraw` pré-sélectionné.
|
||||
|
||||
##### D. CSS & Design (0.5 jour)
|
||||
- [ ] **D1. Styles de l'iframe dans ObsiGate** : L'iframe occupe 100% du content-area (`width: 100%; height: 100%; border: none;`). Aucun padding ni marge.
|
||||
- [ ] **D2. Thème dark/light** : L'iframe reçoit le thème courant → Excalidraw applique son thème interne (`theme="dark"` ou `theme="light"`). Les couleurs sont cohérentes avec ObsiGate grâce à la palette d'Excalidraw.
|
||||
- [ ] **D3. Écran de chargement** : Pendant le chargement de l'iframe (React + Excalidraw ~2 Mo), afficher un spinner « Chargement de l'éditeur Excalidraw... » dans le content-area. L'iframe envoie `ready` → le spinner disparaît.
|
||||
- [ ] **D4. Responsive** : L'iframe s'adapte à la largeur du panneau. En mode mobile (<768px), l'éditeur Excalidraw est utilisable (UI tactile native).
|
||||
- [x] **D1. Styles de l'iframe dans ObsiGate** : L'iframe occupe 100% du content-area (`width: 100%; height: 100%; border: none;`). Aucun padding ni marge.
|
||||
- [x] **D2. Thème dark/light** : L'iframe reçoit le thème courant → Excalidraw applique son thème interne (`theme="dark"` ou `theme="light"`). Les couleurs sont cohérentes avec ObsiGate grâce à la palette d'Excalidraw.
|
||||
- [x] **D3. Écran de chargement** : Pendant le chargement de l'iframe (React + Excalidraw ~2 Mo), afficher un spinner « Chargement de l'éditeur Excalidraw... » dans le content-area. L'iframe envoie `ready` → le spinner disparaît.
|
||||
- [x] **D4. Responsive** : L'iframe s'adapte à la largeur du panneau. En mode mobile (<768px), l'éditeur Excalidraw est utilisable (UI tactile native).
|
||||
|
||||
##### E. Gestion des conflits et edge cases (0.5 jour)
|
||||
- [ ] **E1. Fichier modifié à l'extérieur** : Si le fichier est modifié par Syncthing/watcher pendant l'édition → détecter via le watcher → afficher un bandeau « Ce fichier a été modifié à l'extérieur. Recharger ? » avec boutons [Recharger] [Ignorer].
|
||||
- [ ] **E2. Plusieurs onglets** : Deux onglets sur le même fichier `.excalidraw` → le second détecte que le fichier est déjà ouvert → focus l'onglet existant (comportement existant du `TabManager` #E4).
|
||||
- [ ] **E3. Fichier vide ou nouveau** : Couvert par C7/C8 — la création d'un `.excalidraw` produit un canvas vierge avec le squelette JSON minimum (B6). L'iframe gère nativement le cas `elements: []`.
|
||||
- [ ] **E4. Fichier corrompu** : Si le JSON ne contient pas `type: "excalidraw"` ou est invalide → fallback sur le viewer JSON standard avec un message « Ce fichier .excalidraw semble corrompu ».
|
||||
- [ ] **E5. Pop-out** : Le bouton pop-out fonctionne — il ouvre l'éditeur dans une popup séparée avec sa propre iframe. Utile pour éditer sur un deuxième écran.
|
||||
- [ ] **E6. Annulation (Ctrl+Z)** : Natif dans Excalidraw — l'historique d'annulation est géré par l'état interne de l'iframe. Pas besoin d'interaction avec le parent.
|
||||
- [x] **E1. Fichier modifié à l'extérieur** : Si le fichier est modifié par Syncthing/watcher pendant l'édition → détecter via le watcher → afficher un bandeau « Ce fichier a été modifié à l'extérieur. Recharger ? » avec boutons [Recharger] [Ignorer].
|
||||
- [x] **E2. Plusieurs onglets** : Deux onglets sur le même fichier `.excalidraw` → le second détecte que le fichier est déjà ouvert → focus l'onglet existant (comportement existant du `TabManager` #E4).
|
||||
- [x] **E3. Fichier vide ou nouveau** : Couvert par C7/C8 — la création d'un `.excalidraw` produit un canvas vierge avec le squelette JSON minimum (B6). L'iframe gère nativement le cas `elements: []`.
|
||||
- [x] **E4. Fichier corrompu** (fallback viewer JSON + tests `test_invalid_json_excalidraw_fallback`/`test_json_without_excalidraw_type`) : Si le JSON ne contient pas `type: "excalidraw"` ou est invalide → fallback sur le viewer JSON standard avec un message « Ce fichier .excalidraw semble corrompu ».
|
||||
- [x] **E5. Pop-out** : Le bouton pop-out fonctionne — il ouvre l'éditeur dans une popup séparée avec sa propre iframe. Utile pour éditer sur un deuxième écran.
|
||||
- [x] **E6. Annulation (Ctrl+Z)** : Natif dans Excalidraw — l'historique d'annulation est géré par l'état interne de l'iframe. Pas besoin d'interaction avec le parent.
|
||||
|
||||
##### F. Tests (0.5 jour)
|
||||
- [ ] **F1. Tests backend** :
|
||||
##### F. Tests (0.5 jour) — F1 ✅ (6 tests test_excalidraw.py)
|
||||
- [x] **F1. Tests backend** :
|
||||
- `test_excalidraw_detection.py` : fichier `.excalidraw` valide → `is_excalidraw: true`, JSON invalide → fallback JSON, fichier sans `type: excalidraw` → fallback
|
||||
- `test_excalidraw_search.py` : texte extrait des éléments → recherchable via TF-IDF
|
||||
- [ ] **F2. Tests frontend** :
|
||||
- [ ] **F2. Tests frontend** — ⚪ NON RETENU :
|
||||
- Chargement de l'iframe avec des données de test
|
||||
- Communication postMessage (init → ready → save)
|
||||
- Changement de thème propagé à l'iframe
|
||||
- [ ] **F3. Tests E2E (Playwright, #58)** :
|
||||
- [x] **F3. Tests E2E (Playwright)** (FAIT 2026-09 — `tests/e2e/excalidraw.spec.js`) :
|
||||
- Ouvrir un fichier `.excalidraw` → l'iframe se charge → le canvas Excalidraw est visible
|
||||
- Dessiner un rectangle → sauvegarder → recharger → le rectangle est toujours là
|
||||
- Basculer thème sombre → l'iframe passe en dark mode
|
||||
@@ -643,9 +679,9 @@
|
||||
- **Pas d'édition collaborative** : Cette implémentation est mono-utilisateur. La collaboration temps réel (#62) pourra être étendue aux fichiers `.excalidraw` ultérieurement via le même mécanisme Yjs.
|
||||
|
||||
##### H. Documentation utilisateur
|
||||
- [ ] **H1.** Mettre à jour README : ajouter `.excalidraw` dans les formats supportés
|
||||
- [ ] **H2.** Ajouter dans le guide d'utilisation (Quick Help) : section « Diagrammes Excalidraw »
|
||||
- [ ] **H3.** Note : « Les fichiers .excalidraw créés avec le plugin Obsidian Excalidraw sont compatibles »
|
||||
- [x] **H1.** Mettre à jour README : ajouter `.excalidraw` dans les formats supportés (FR + EN)
|
||||
- [x] **H2.** Ajouter dans le guide d'utilisation (Quick Help) : section « Diagrammes Excalidraw » (i18n FR/EN)
|
||||
- [x] **H3.** Note : « Les fichiers .excalidraw créés avec le plugin Obsidian Excalidraw sont compatibles »
|
||||
|
||||
---
|
||||
|
||||
@@ -718,32 +754,38 @@
|
||||
- [ ] UI : toggle « Recherche sémantique » dans la barre de recherche
|
||||
- [ ] UI : score de similarité dans les résultats
|
||||
|
||||
### 71. Tableau de bord administrateur
|
||||
- **Effort :** 2 jours | **Impact :** 🟢
|
||||
### 71. Tableau de bord administrateur — Backend ✅, Frontend ✅
|
||||
- **Effort :** 2 jours | **Impact :** 🟢 | **Statut :** ✅ Terminé (2026-08, commits 46be24f / 88ab8db / 01453bc)
|
||||
- **Description :** Une page web dédiée accessible uniquement aux administrateurs qui centralise tout le monitoring et la gestion du serveur ObsiGate en un seul endroit. Un cockpit de pilotage pour le sysadmin.
|
||||
- **Widgets temps réel** (rafraîchis via SSE) :
|
||||
- **CPU / RAM / Disque** : jauges visuelles avec seuils d'alerte (vert < 70%, orange < 90%, rouge > 90%). Permet de voir en un coup d'œil si le serveur est en surcharge.
|
||||
- **Requêtes par minute** : graphique sparkline des dernières 24h. Permet de détecter les pics d'activité anormaux (attaques, bots, bug qui spam l'API).
|
||||
- **Utilisateurs actifs** : nombre de sessions connectées en ce moment, compteur de recherches en cours.
|
||||
- **Gestion des utilisateurs** :
|
||||
- Tableau triable/filtrable de tous les comptes (nom, rôle, date de création, dernière connexion, nombre de vaults).
|
||||
- Création, édition, suppression d'utilisateurs. Attribution de rôles (admin/user/readonly).
|
||||
- Réinitialisation de mot de passe administrateur.
|
||||
- **Logs d'audit visuels** :
|
||||
- Tableau chronologique des 500 dernières actions : qui a fait quoi, quand, depuis quelle IP.
|
||||
- Filtres par utilisateur, type d'action (login, création fichier, suppression, modification settings), plage de dates.
|
||||
- Export CSV pour analyse externe.
|
||||
- **Statistiques backups** : graphique d'évolution du nombre et de la taille des backups par vault. Détection automatique des vaults sans backup récent.
|
||||
- **Pourquoi c'est important :** Actuellement, administrer ObsiGate nécessite de se connecter en SSH au serveur et de lire des fichiers JSON. Le dashboard rend toutes ces opérations accessibles depuis l'interface web, avec des visuels qui permettent de diagnostiquer un problème en 10 secondes au lieu de 10 minutes de CLI.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Widgets temps réel : CPU, mémoire, espace disque, requêtes/min (rafraîchissement SSE)
|
||||
- [ ] Gestion utilisateurs : tableau triable, création/édition/suppression, filtre par rôle
|
||||
- [ ] Logs d'audit : visualisation des 500 dernières entrées, filtre par utilisateur/action/date
|
||||
- [ ] Backup stats : graphique d'évolution (taille totale, nombre par vault, âge moyen)
|
||||
- [ ] Protection : accès restreint au rôle `admin` uniquement
|
||||
- **Implémentation réelle (vérifiée) :**
|
||||
- **Backend `backend/admin.py`** (nouveau, 261 lignes) — 4 endpoints admin-gated (`require_admin`) :
|
||||
- `GET /api/admin/stats` — CPU/RAM/Disk/Uptime via psutil
|
||||
- `GET /api/admin/audit` — 500 dernières entrées d'audit avec filtres `user`/`action`/`limit`/`offset`
|
||||
- `GET /api/admin/backup-stats` — compte + taille + age par vault
|
||||
- `GET /api/admin/stream` — Server-Sent Events qui push les stats toutes les 5s
|
||||
- `backend/main.py` — routeur monté + middleware gzip bypass pour `/api/admin/stream`
|
||||
- `backend/requirements.txt` — ajout `psutil>=5.9`
|
||||
- **Tests :** `tests/test_admin.py` (13 tests, 100% verts) — couvrent auth + filtres + format SSE
|
||||
- **Complété (2026-08) :**
|
||||
- `frontend/admin.html` (472 l.) + `frontend/js/admin.js` (544 l.) — dashboard standalone avec navigation par sections sticky + thèmes
|
||||
- Lien « Admin » dans le user menu (`#admin-menu-row`, gating `role === "admin"`)
|
||||
- Widgets temps réel via EventSource `/api/admin/stream` + snapshot `/api/admin/stats`
|
||||
- CRUD users + fix routing `/admin.html` + fix scroll
|
||||
|
||||
### 72. API publique documentée — OpenAPI 3.1
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟢
|
||||
### 72. API publique documentée — OpenAPI 3.1 ✅ TERMINÉ
|
||||
- **Effort :** 1-2 jours (réalisé) | **Impact :** 🟢 | **Statut :** ✅ Livré (2026-09-11)
|
||||
- **Implémentation réelle :**
|
||||
- `backend/openapi_docs.py` — 18 tags documentés + assignation automatique par préfixe de route
|
||||
(`tag_for_path`, `canonical_tag`), enrichissement du schéma (`enrich_openapi_schema` :
|
||||
sécurité `bearerAuth`/`cookieAuth`, erreurs 401/403/404/422/500, exemples requête/réponse,
|
||||
serveur, `externalDocs`), page `/api` autonome (`render_api_landing`).
|
||||
- `backend/schemas.py` — `response_model` Pydantic pour ~35 endpoints qui n'en avaient pas.
|
||||
- `backend/main.py` — `FastAPI(...)` enrichi (description Markdown, contact, licence, tags) +
|
||||
override `app.openapi` ; routes `/api` et `/api/`.
|
||||
- `backend/ai_routes.py` / `backend/bookslm_routes.py` — `response_model` (AI status, BooksLM
|
||||
context) + documentation SSE.
|
||||
- Frontend — entrée « API » du menu d'options (i18n FR/EN) ouvrant `/docs`.
|
||||
- Tests — `tests/test_openapi.py` (58 tests) + `tests/frontend/ai.test.mjs` (7 tests).
|
||||
- **Description :** Une page de documentation interactive et auto-générée de toutes les API REST d'ObsiGate, accessible via un bouton dans l'interface. L'équivalent d'un manuel technique mais qui se teste en direct.
|
||||
- **OpenAPI 3.1** : c'est le format standard mondial pour décrire une API REST. Un seul fichier JSON/YAML contient la description de tous les endpoints, leurs paramètres, les formats de réponse, les codes d'erreur, et les modèles de données. Ce standard est supporté par des centaines d'outils.
|
||||
- **Swagger UI** : une interface web qui lit le fichier OpenAPI et génère automatiquement une documentation interactive. L'utilisateur voit chaque endpoint, peut remplir les paramètres dans un formulaire, cliquer « Execute » et voir la réponse réelle de l'API en direct. Parfait pour les développeurs qui veulent intégrer ObsiGate à leurs scripts ou comprendre comment fonctionne l'API.
|
||||
@@ -756,12 +798,12 @@
|
||||
- **Auto-génération** : FastAPI génère déjà partiellement le schéma OpenAPI. Le travail consiste à compléter les docstrings manquantes, ajouter `response_model` sur les endpoints qui n'en ont pas, et enrichir avec des exemples.
|
||||
- **Pourquoi c'est important :** Une API sans documentation est comme un logiciel sans interface — techniquement fonctionnel mais inutilisable. Avec une doc OpenAPI, ObsiGate devient intégrable dans n'importe quel écosystème. Un développeur peut en 5 minutes comprendre comment uploader un fichier, chercher dans un vault, ou récupérer le contenu d'une note — et écrire un script qui automatise ses workflows.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Audit des endpoints existants → compléter les docstrings manquants
|
||||
- [ ] Ajout de `response_model` sur tous les endpoints (40+ actuellement, ~15 sans modèle)
|
||||
- [ ] Exemples dans les schémas : `examples=[...]` pour les endpoints clés
|
||||
- [ ] Tagging des endpoints par catégorie (Files, Vaults, Search, Auth, AI, Backups)
|
||||
- [ ] Serveur mock : `prism` ou `openapi-generator` pour tests sans backend
|
||||
- [ ] Page de documentation intégrée : lien dans le menu header (« API »)
|
||||
- [x] Audit des endpoints existants → compléter les docstrings manquants
|
||||
- [x] Ajout de `response_model` sur tous les endpoints (40+ actuellement, ~15 sans modèle)
|
||||
- [x] Exemples dans les schémas : `examples=[...]` pour les endpoints clés
|
||||
- [x] Tagging des endpoints par catégorie (Files, Vaults, Search, Auth, AI, Backups)
|
||||
- [ ] Serveur mock : `prism` ou `openapi-generator` pour tests sans backend — ⚪ NON RETENU (le schéma 3.1 est validé par `tests/test_openapi.py`)
|
||||
- [x] Page de documentation intégrée : lien dans le menu header (« API »)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
- **Effort :** 6-8 jours | **Impact :** 🟢
|
||||
@@ -805,19 +847,19 @@
|
||||
|
||||
- **Sous-tâches :**
|
||||
|
||||
##### A. Initialisation du projet Tauri (1 jour)
|
||||
- [ ] Installer Rust + toolchain Tauri : `cargo install tauri-cli`
|
||||
- [ ] Initialiser `tauri init` dans `/desktop/` avec config Windows/Linux
|
||||
- [ ] Configurer `tauri.conf.json` : fenêtre 1200×800, sans cadre, titre "ObsiGate"
|
||||
- [ ] Configurer le build : cibles `.msi`/`.nsis` (Windows), `.deb`/`.AppImage` (Linux)
|
||||
- [ ] Ajouter les icônes desktop (`.ico` Windows, `.png` Linux) dans `desktop/icons/`
|
||||
##### A. Initialisation du projet Tauri (1 jour) — ✅ livré (vérifié 2026-09)
|
||||
- [x] Toolchain : tauri-cli 2.11.4 / rustc 1.94.1
|
||||
- [x] Projet Tauri v2 dans `desktop/` (Cargo.toml, build.rs)
|
||||
- [x] `tauri.conf.json` : fenêtre 1200×800 (min 800×600), titre "ObsiGate"
|
||||
- [x] Build : cibles `.msi`/`.nsis` (Windows), `.deb`/`.AppImage` (Linux)
|
||||
- [x] Icônes desktop dans `desktop/icons/` (.ico, .icns, .png)
|
||||
|
||||
##### B. Intégration du backend Python (2-3 jours)
|
||||
- [ ] Bundle Python : créer un dossier `python-embed/` avec `python3.11-embed` + `site-packages/` (requirements.txt gelés)
|
||||
- [ ] Script `sidecar.py` : lance uvicorn sur `localhost:17890`, log dans `%APPDATA%/ObsiGate/logs/`
|
||||
- [ ] Code Rust `main.rs` : spawn le sidecar comme processus fils, health check (boucle `GET /api/health` avec timeout 10s), kill propre au `SIGTERM`
|
||||
- [ ] Menu tray : icône dans la barre des tâches avec options « Ouvrir ObsiGate », « Quitter »
|
||||
- [ ] Gestion du port : détecter si 17890 est déjà utilisé → incrémenter (17891, 17892...)
|
||||
##### B. Intégration du backend Python (2-3 jours) — ✅ livré (variante : uvicorn spawné directement, pas de sidecar.py)
|
||||
- [x] Bundle Python : `desktop/python-embed/` (python3.11-embed + site-packages, validé par validate-structure.sh)
|
||||
- [x] Lancement backend : `spawn_backend()` Rust lance `python-embed -m uvicorn backend.main:app` (équivalent sidecar), logs dans `%APPDATA%/ObsiGate/logs/backend.log`
|
||||
- [x] `main.rs` : spawn processus fils, health check (`GET /api/health`, 30 essais × 2s), kill propre (SIGTERM→wait→kill)
|
||||
- [x] Menu tray (voir section C ✅)
|
||||
- [x] Gestion du port : `pick_free_port()` scan 17890..17899 si occupé (commit c066b2c, 3 tests Rust)
|
||||
|
||||
##### C. Fonctionnalités desktop natives (2-3 jours) — ✅ COMPLÉTÉ
|
||||
- [x] **Sélecteur de dossier** : `pick_vault_folder` via `tauri_plugin_dialog` → ajoute le vault dans config.json
|
||||
@@ -832,28 +874,32 @@
|
||||
- [x] **Pas de terminal visible** : `#![windows_subsystem = "windows"]` + `CREATE_NO_WINDOW` sur le processus Python
|
||||
- [x] **Persistance fenêtre** : position/taille sauvegardée dans `config.json`
|
||||
|
||||
##### D. Build et distribution (2 jours)
|
||||
##### D. Build et distribution (2 jours) — ✅ COMPLÉTÉ
|
||||
- [x] **CI/CD automatisé** : workflow Gitea Actions `.gitea/workflows/desktop-build.yml` — build Windows + Linux à chaque push sur `main` (si `desktop/` modifié), upload des artefacts `.msi`/`.AppImage`/`.deb` en release
|
||||
- [ ] **Build Windows** : `tauri build --target x86_64-pc-windows-msvc` → `.msi` + `.exe` installer
|
||||
- [ ] **Build Linux** : `tauri build --target x86_64-unknown-linux-gnu` → `.deb`, `.rpm`, `.AppImage`
|
||||
- [x] **Auto-update** : `tauri-plugin-updater` → vérifie `https://git.dracodev.net/api/v1/repos/Projets/ObsiGate/releases/latest`
|
||||
- [x] **Build Windows local** : `cargo build --release` vérifié (rustc 1.94.1) — `cargo tauri build --bundles msi` prêt
|
||||
- [x] **Build Linux local** : workflow CI couvre `.deb`, `.rpm`, `.AppImage`
|
||||
- [x] **Auto-update** : `tauri-plugin-updater` configuré → vérifie `https://git.dracodev.net/api/v1/repos/Projets/ObsiGate/releases/latest`
|
||||
- [ ] **Signature de code** : configurer le certificat (optionnel mais recommandé pour Windows)
|
||||
- [ ] **Page de release** : intégrer le build desktop dans les releases Gitea + README d'installation
|
||||
- [x] **Page de release** : README desktop existe (`desktop/README.md`)
|
||||
|
||||
##### E. Expérience utilisateur (1 jour)
|
||||
##### E. Expérience utilisateur (1 jour) — ✅ COMPLÉTÉ
|
||||
- [x] Écran de chargement pendant le démarrage du backend (« ObsiGate démarre... » avec spinner) — splash inline `#boot-splash` dans `index.html`, retiré quand `app.js` signale le boot ; statut mis à jour depuis Rust
|
||||
- [ ] Gestion des erreurs : backend crash → message explicite + bouton « Redémarrer »
|
||||
- [ ] Sauvegarde des préférences desktop (taille fenêtre, position, dernier vault)
|
||||
- [ ] Première expérience : wizard « Choisissez votre vault » au premier lancement
|
||||
- [ ] Icône dans le menu Démarrer / dock Linux avec jumplist (vaults récents)
|
||||
- [x] Gestion des erreurs : backend crash → `showBackendCrashBanner()` appelé par le monitor loop toutes les 5s
|
||||
- [x] Sauvegarde des préférences desktop : position/taille fenêtre sauvées dans `%APPDATA%/ObsiGate/config.json` au close + restauration au startup
|
||||
- [x] Première expérience : config par défaut auto-créée au premier lancement (vault `~/voute_obsidian`, dir `~USERPROFILE`)
|
||||
- [ ] Wizard interactif « Choisissez votre vault » au premier lancement (optionnel — config auto suffisante)
|
||||
- [ ] Jumplist vaults récents dans le menu Démarrer (optionnel)
|
||||
|
||||
##### F. Tests (1 jour)
|
||||
- [ ] Test : installation → premier lancement → wizard vault → ouverture fichier
|
||||
- [ ] Test : tray icon → réduire dans la barre → restaurer
|
||||
- [ ] Test : notifications natives → fichier modifié → popup OS
|
||||
- [ ] Test : association `.md` → double-clic → ouvre dans ObsiGate
|
||||
- [ ] Test : auto-update → nouvelle version dispo → téléchargement → installation
|
||||
- [ ] Test : cleanup → désinstallation propre (pas de fichiers résiduels)
|
||||
##### F. Tests (1 jour) — ✅ COMPLÉTÉ
|
||||
- [x] 16 tests Rust unitaires : config roundtrip, JSON parsing (empty/partial/corrupted), vault dedup, dir remove, backend URL, paths, branding, edge cases
|
||||
- [x] Build debug + release vérifié (rustc 1.94.1, tauri-cli 2.11.4)
|
||||
- [x] CI desktop workflow existant (desktop-build.yml)
|
||||
- [ ] Test E2E : installation → premier lancement → wizard vault → ouverture fichier (manuel)
|
||||
- [ ] Test E2E : tray icon → réduire → restaurer (manuel)
|
||||
- [ ] Test E2E : notifications natives → fichier modifié → popup OS (manuel)
|
||||
- [ ] Test E2E : association `.md` → double-clic → ouvre dans ObsiGate (manuel)
|
||||
- [ ] Test E2E : auto-update → nouvelle version → install (manuel)
|
||||
- [ ] Test E2E : désinstallation propre (manuel)
|
||||
|
||||
- **Prérequis techniques :**
|
||||
- Rust ≥ 1.75 (stable) — installé via `rustup`
|
||||
@@ -868,12 +914,13 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #57 | ~65 jours |
|
||||
| 🔵 P1 | ✅ #58 (Playwright E2E) | Terminé |
|
||||
| 🔵 P2 | 🔨 #77 (Tauri Desktop) | 8-12 jours |
|
||||
| ⚪ P3 | #59, #61-66, #74, #75, #76, #78 (11 items) | 35-46 jours |
|
||||
| ⚪ P4 | #67 → #73 (7 items) | 18-23 jours |
|
||||
| **Total restant** | **20 items** | **63-84 jours** |
|
||||
| ✅ Complété | #1 → #59, #63-68, #71, #72 (OpenAPI 3.1), #74, #75, #76, #78 (58 E2E, 59 offline, 63 i18n, 64 MFA TOTP+WebAuthn, 65 thèmes, 66 export, 67 push, 68 health, 71 admin, 72 OpenAPI, 74 PDF, 75 split view, 76 BooksLM, 78 Excalidraw) | ~82 jours réalisés |
|
||||
| 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours |
|
||||
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) | ~5-7 jours |
|
||||
| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours |
|
||||
| **Total restant** | **5 items + finitions** | **~17-25 jours** |
|
||||
|
||||
#75 : E2E matriciels 37/37 verts (split-view.spec.js + split-view-matrix.spec.js) — 2026-09-08.
|
||||
|
||||
---
|
||||
|
||||
@@ -882,4 +929,5 @@
|
||||
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
|
||||
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
|
||||
- Les items marqués 🟢 (nice-to-have) sont de bons candidats pour des contributions externes.
|
||||
- Le mode hors-ligne (#59) et l'i18n (#63) sont les P3 ayant le meilleur rapport effort/valeur.
|
||||
- #59 (hors-ligne), #63 (i18n), #64 (MFA), #65-66, #67-68, #71, #72 (OpenAPI), #74-76, #78 sont livrés — les P3 restants à plus fort rapport effort/valeur : #62 (collaboration).
|
||||
- #78 est terminé (B5 recherche texte, C8 menu contextuel, F3 E2E, doc H1-H3) ; F2 non retenu. BUG-002 (loading infini Excalidraw) corrigé par les commits a4ea322/185d603.
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
<!doctype html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title data-i18n="admin.page_title">ObsiGate — Administration</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
|
||||
|
||||
<!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS -->
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
try {
|
||||
var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
|
||||
root.setAttribute("data-theme", mode);
|
||||
var light = mode === "light" || mode === "sepia";
|
||||
root.style.setProperty("--bg-primary", light ? "#ffffff" : "#0f1117");
|
||||
root.style.setProperty("--text-primary", light ? "#1a1a1a" : "#e6edf3");
|
||||
} catch (e) { root.setAttribute("data-theme", "dark"); }
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
|
||||
<style>
|
||||
/* ── Admin page — local styles (kept inline to limit impact on /static/style.css) ── */
|
||||
:root {
|
||||
--admin-card-bg: var(--bg-secondary, #161b22);
|
||||
--admin-card-border: var(--border, #21262d);
|
||||
--admin-success: var(--success, #3fb950);
|
||||
--admin-warning: var(--warning, #f59e0b);
|
||||
--admin-danger: var(--danger, #ff7b72);
|
||||
}
|
||||
body.admin-page {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
overflow: auto; /* override SPA style.css: body{overflow:hidden} */
|
||||
height: auto; /* override SPA style.css: body{height:100vh} */
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Sticky section nav ── */
|
||||
.admin-subnav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding: 8px 24px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
border-bottom: 1px solid var(--border, #21262d);
|
||||
backdrop-filter: blur(4px);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.admin-subnav::-webkit-scrollbar { height: 6px; }
|
||||
.admin-subnav a {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.admin-subnav a:hover {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
.admin-subnav a.active {
|
||||
background: var(--accent-bg, #1f2a3a);
|
||||
color: var(--accent-text, #79c0ff);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.admin-section { scroll-margin-top: 64px; }
|
||||
@media (max-width: 600px) {
|
||||
.admin-subnav { padding: 8px 12px; }
|
||||
.admin-subnav a { font-size: 0.8rem; padding: 6px 10px; }
|
||||
}
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 24px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
border-bottom: 1px solid var(--admin-card-border);
|
||||
}
|
||||
.admin-header-left { display: flex; align-items: center; gap: 14px; }
|
||||
.admin-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
.admin-header .admin-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
.admin-header-right { display: flex; align-items: center; gap: 12px; }
|
||||
#admin-role-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: var(--accent-bg, #1f2a3a);
|
||||
color: var(--accent-text, #79c0ff);
|
||||
border: 1px solid var(--accent, #58a6ff);
|
||||
}
|
||||
#admin-role-badge[data-role="admin"] {
|
||||
background: var(--success-bg, #1a3d1f);
|
||||
color: var(--success, #3fb950);
|
||||
border-color: var(--success, #3fb950);
|
||||
}
|
||||
.admin-back-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--bg-hover, #1f2430);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.admin-back-btn:hover { background: var(--bg-sidebar, #13161d); }
|
||||
|
||||
.admin-main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
.admin-section {
|
||||
background: var(--admin-card-bg);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
.admin-section h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-section h2::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: var(--accent, #58a6ff);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Stats grid ── */
|
||||
#admin-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.admin-stat-card {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
.admin-stat-unit {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
.admin-stat-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-stat-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease, background 0.4s ease;
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-table th, .admin-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--admin-card-border);
|
||||
}
|
||||
.admin-table th {
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
}
|
||||
.admin-table code {
|
||||
background: var(--bg-primary, #0f1117);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-text, #79c0ff);
|
||||
}
|
||||
|
||||
/* ── Filters ── */
|
||||
.admin-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-filters input, .admin-filters select {
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-filters input:focus, .admin-filters select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.admin-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent, #58a6ff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.admin-btn:hover { opacity: 0.85; }
|
||||
.admin-btn-secondary {
|
||||
background: var(--bg-hover, #1f2430);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border: 1px solid var(--admin-card-border);
|
||||
}
|
||||
|
||||
/* ── Backups ── */
|
||||
#admin-backups-summary {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
#admin-backups-summary > div {
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--admin-card-border);
|
||||
}
|
||||
.admin-backup-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-primary, #0f1117);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 120px;
|
||||
}
|
||||
.admin-backup-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent, #58a6ff), var(--accent-text, #79c0ff));
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* ── Users management ── */
|
||||
#section-users .admin-users-hint {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.admin-footer {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #484f58);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--admin-card-border);
|
||||
}
|
||||
#admin-stats-timestamp { color: var(--text-secondary, #8b949e); }
|
||||
|
||||
/* ── Forbidden screen ── */
|
||||
#admin-forbidden {
|
||||
display: none;
|
||||
max-width: 500px;
|
||||
margin: 100px auto;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
background: var(--admin-card-bg);
|
||||
border: 1px solid var(--admin-danger);
|
||||
border-radius: 10px;
|
||||
}
|
||||
#admin-forbidden h2 { color: var(--admin-danger); margin-top: 0; }
|
||||
|
||||
/* ── Loading state ── */
|
||||
.admin-loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<header class="admin-header">
|
||||
<div class="admin-header-left">
|
||||
<h1>ObsiGate — Admin</h1>
|
||||
<span class="admin-subtitle" data-i18n="admin.header_subtitle">Tableau de bord administrateur</span>
|
||||
</div>
|
||||
<div class="admin-header-right">
|
||||
<span id="admin-role-badge">…</span>
|
||||
<a href="/" class="admin-back-btn" data-i18n="admin.back_btn">← Retour</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="admin-subnav" id="admin-subnav" aria-label="Sections admin">
|
||||
<a href="#section-stats" class="active" data-section="stats">📊 <span data-i18n="admin.stats_title">Statistiques</span></a>
|
||||
<a href="#section-audit" data-section="audit">🕒 <span data-i18n="admin.audit_title">Logs d'audit</span></a>
|
||||
<a href="#section-backups" data-section="backups">💾 <span data-i18n="admin.backups_title">Backups</span></a>
|
||||
<a href="#section-users" data-section="users">👥 <span data-i18n="admin.users_title">Utilisateurs</span></a>
|
||||
</nav>
|
||||
|
||||
<main id="admin-main" class="admin-main">
|
||||
<!-- Loading state shown until init() resolves -->
|
||||
<div id="admin-loading" class="admin-loading" data-i18n="common.loading">Chargement…</div>
|
||||
|
||||
<!-- Section 1 — Stats temps réel -->
|
||||
<section class="admin-section" id="section-stats">
|
||||
<h2 data-i18n="admin.stats_title">Statistiques temps réel</h2>
|
||||
<div id="admin-stats-grid" class="admin-loading">…</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 2 — Logs d'audit -->
|
||||
<section class="admin-section" id="section-audit">
|
||||
<h2 data-i18n="admin.audit_title">Logs d'audit</h2>
|
||||
<div class="admin-filters">
|
||||
<input type="text" id="admin-audit-user" data-i18n-placeholder="admin.audit_filter_user" placeholder="Filtrer par utilisateur" />
|
||||
<select id="admin-audit-action">
|
||||
<option value="" data-i18n="admin.audit_all_actions">Toutes les actions</option>
|
||||
</select>
|
||||
<button id="admin-audit-refresh" class="admin-btn admin-btn-secondary" data-i18n="admin.audit_refresh">Rafraîchir</button>
|
||||
</div>
|
||||
<div id="admin-audit-empty" class="admin-loading" style="display:none;" data-i18n="admin.audit_empty">Aucun log disponible</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Vault</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-audit-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Section 3 — Statistiques backups -->
|
||||
<section class="admin-section" id="section-backups">
|
||||
<h2 data-i18n="admin.backups_title">Statistiques backups</h2>
|
||||
<div id="admin-backups-summary"></div>
|
||||
<div id="admin-backups-empty" class="admin-loading" style="display:none;" data-i18n="admin.backups_vault_empty">Aucun backup</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.backups_per_vault">Par vault</th>
|
||||
<th>Count</th>
|
||||
<th>Size</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-backups-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Section 4 — Gestion utilisateurs -->
|
||||
<section class="admin-section" id="section-users">
|
||||
<h2 data-i18n="admin.users_title">Gestion utilisateurs</h2>
|
||||
<p class="admin-users-hint" data-i18n="admin.users_manage_hint">Ouvrir la modale de gestion des utilisateurs (CRUD complet)</p>
|
||||
<button id="admin-users-manage-btn" class="admin-btn" data-i18n="admin.users_manage_btn">Gérer les utilisateurs</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="admin-forbidden">
|
||||
<h2 data-i18n="admin.forbidden_title">Accès refusé</h2>
|
||||
<p data-i18n="admin.forbidden_msg">Vous devez être administrateur pour accéder à cette page.</p>
|
||||
<a href="/" class="admin-back-btn" data-i18n="admin.back_btn">← Retour</a>
|
||||
</div>
|
||||
|
||||
<footer class="admin-footer">
|
||||
<span>ObsiGate Admin — ROADMAP #71</span>
|
||||
<span id="admin-stats-timestamp">—</span>
|
||||
</footer>
|
||||
|
||||
<script type="module">
|
||||
// Theme: applique le thème/mode sauvegardé dans l'app principale
|
||||
// (même origine => localStorage partagé). Fait AVANT le rendu pour
|
||||
// éviter que la page admin n'utilise les vars CSS par défaut.
|
||||
import { initThemes } from "/static/js/themes.js";
|
||||
// We import admin.js which provides init() — but the i18n module
|
||||
// is global (window.t), so we can use t() right away to translate
|
||||
// static DOM. admin.js will then call init() after DOMContentLoaded.
|
||||
import { initI18n } from "/static/js/i18n.js";
|
||||
import * as Admin from "/static/js/admin.js";
|
||||
|
||||
initThemes();
|
||||
await initI18n();
|
||||
// Re-apply DOM translations now that locale strings are loaded
|
||||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
el.textContent = window.t(el.getAttribute("data-i18n"));
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||
el.setAttribute("placeholder", window.t(el.getAttribute("data-i18n-placeholder")));
|
||||
});
|
||||
// Remove loading hint now that i18n is ready
|
||||
const loading = document.getElementById("admin-loading");
|
||||
if (loading) loading.style.display = "none";
|
||||
|
||||
if (typeof Admin.init === "function") {
|
||||
Admin.init();
|
||||
} else {
|
||||
console.error("admin.js did not export init()");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -92,26 +92,20 @@
|
||||
<button id="btn-export-svg" title="Export SVG">📐 SVG</button>
|
||||
</div>
|
||||
|
||||
<!-- Import map: force ALL modules (Excalidraw + sub-deps) to use React 18.3.1 -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/[email protected]",
|
||||
"react-dom": "https://esm.sh/[email protected]",
|
||||
"react-dom/": "https://esm.sh/[email protected]/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Single module script -->
|
||||
<!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query.
|
||||
The alias gets propagated by esm.sh to every transitive dependency; for
|
||||
range-version deps (e.g. jotai@>=2.9.2) esm.sh then tries to rebuild the
|
||||
range + alias variant and times out (HTTP 408) → the whole import fails →
|
||||
the diagram stays stuck on "Loading Excalidraw…". Dropping the alias lets
|
||||
esm.sh resolve a consistent React 19 (Excalidraw 0.18's default), which
|
||||
loads reliably. -->
|
||||
<script type="module">
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { decompressFromBase64 } from "https://esm.sh/[email protected]";
|
||||
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]?alias=react:[email protected],react-dom:[email protected]";
|
||||
import React from "https://esm.sh/react@^19.2.0?target=es2022";
|
||||
import { createRoot } from "https://esm.sh/react-dom@^19.2.0/client?target=es2022";
|
||||
import { decompressFromBase64, compressToBase64 } from "https://esm.sh/[email protected]";
|
||||
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]";
|
||||
|
||||
window.React = React;
|
||||
window.ReactDOM = ReactDOM;
|
||||
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/[email protected]/dist/prod/";
|
||||
window.ExcalidrawLib = ExcalidrawLib;
|
||||
|
||||
@@ -122,6 +116,12 @@
|
||||
let isDirty = false;
|
||||
let currentTheme = "light";
|
||||
let saveTimeout = null;
|
||||
// Excalidraw fires onChange on initial mount; ignore those so simply
|
||||
// opening a diagram does not trigger a spurious autosave (which would
|
||||
// rewrite a .excalidraw.md file as raw JSON and destroy its format).
|
||||
let ignoreChanges = true;
|
||||
// Source format: "md" for the Obsidian .excalidraw.md (compressed-json).
|
||||
let sourceIsMd = false;
|
||||
|
||||
// --- DOM refs ---
|
||||
const loadingEl = document.getElementById("loading");
|
||||
@@ -159,16 +159,23 @@
|
||||
const elements = excalidrawAPI.getSceneElements();
|
||||
const appState = excalidrawAPI.getAppState();
|
||||
const files = excalidrawAPI.getFiles();
|
||||
sendToParent({
|
||||
type: "save",
|
||||
data: {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements: elements,
|
||||
appState: appState,
|
||||
files: files,
|
||||
},
|
||||
});
|
||||
const data = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements: elements,
|
||||
appState: appState,
|
||||
files: files,
|
||||
};
|
||||
// Preserve the Obsidian .excalidraw.md format: ship the compressed
|
||||
// scene so the parent can rewrite the frontmatter + compressed-json block.
|
||||
if (sourceIsMd) {
|
||||
try {
|
||||
data.compressed = compressToBase64(JSON.stringify(data));
|
||||
} catch (e) {
|
||||
console.error("Failed to compress Excalidraw scene:", e);
|
||||
}
|
||||
}
|
||||
sendToParent({ type: "save", data });
|
||||
markClean();
|
||||
btnSave.textContent = "💾 Saved!";
|
||||
setTimeout(() => { btnSave.textContent = "💾 Save"; }, 1200);
|
||||
@@ -186,22 +193,29 @@
|
||||
function App({ initialData, theme }) {
|
||||
const [appState, setAppState] = React.useState(null);
|
||||
|
||||
const excalidrawRef = React.useCallback((api) => {
|
||||
// Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI`
|
||||
// prop, called with the API object once mounted (NOT the legacy
|
||||
// `excalidrawRef` name). Without this the loading overlay never hides
|
||||
// and save/export stay dead.
|
||||
const onReady = React.useCallback((api) => {
|
||||
if (api) {
|
||||
excalidrawAPI = api;
|
||||
loadingEl.classList.add("hidden");
|
||||
if (theme === "dark") {
|
||||
api.updateScene({ appState: { theme: "dark" } });
|
||||
}
|
||||
// Stop ignoring changes once the initial mount settles.
|
||||
setTimeout(() => { ignoreChanges = false; }, 800);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const onChange = React.useCallback((elements, state, files) => {
|
||||
if (ignoreChanges) return;
|
||||
markDirty();
|
||||
}, []);
|
||||
|
||||
return React.createElement(Excalidraw, {
|
||||
excalidrawRef: excalidrawRef,
|
||||
excalidrawAPI: onReady,
|
||||
initialData: initialData,
|
||||
onChange: onChange,
|
||||
theme: theme,
|
||||
@@ -227,6 +241,7 @@
|
||||
let elements, appState, files;
|
||||
// Handle compressed .excalidraw.md format
|
||||
if (msg.data.compressed) {
|
||||
sourceIsMd = true;
|
||||
try {
|
||||
const decompressed = decompressFromBase64(msg.data.compressed);
|
||||
const parsed = JSON.parse(decompressed);
|
||||
@@ -247,10 +262,10 @@
|
||||
setTheme(currentTheme);
|
||||
|
||||
try {
|
||||
if (typeof ReactDOM.createRoot !== "function") {
|
||||
throw new Error("ReactDOM.createRoot is not available — check react-dom/client import");
|
||||
if (typeof createRoot !== "function") {
|
||||
throw new Error("createRoot is not available — check react-dom/client import");
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
createRoot(document.getElementById("root")).render(
|
||||
React.createElement(App, {
|
||||
initialData: initialData,
|
||||
theme: currentTheme,
|
||||
|
||||
+295
-7
@@ -627,6 +627,30 @@
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-list-row menu-list-button"
|
||||
id="api-docs-menu-row"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<span
|
||||
class="menu-list-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i
|
||||
data-lucide="code-2"
|
||||
style="width: 17px; height: 17px"
|
||||
></i>
|
||||
</span>
|
||||
<span class="menu-list-content">
|
||||
<span class="menu-list-title" data-i18n="header.menu_api"
|
||||
>API</span
|
||||
>
|
||||
<span class="menu-list-subtitle" data-i18n="header.menu_api_desc"
|
||||
>Documentation OpenAPI</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-list-row menu-list-button"
|
||||
id="config-open-btn"
|
||||
@@ -1448,6 +1472,9 @@
|
||||
<li><a href="#cfg-ai" class="help-nav-link" data-i18n="settings.ai"></a></li>
|
||||
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
|
||||
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
|
||||
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
|
||||
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
|
||||
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
|
||||
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
|
||||
<li><a href="#cfg-webhooks" class="help-nav-link" data-i18n="config.section_webhooks"></a></li>
|
||||
<li><a href="#cfg-partages-publics" class="help-nav-link" data-i18n="config.section_shares"></a></li>
|
||||
@@ -2011,6 +2038,90 @@
|
||||
<option value="">-- Modele --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-nvidia-key"
|
||||
>NVIDIA API Key</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="cfg-nvidia-key"
|
||||
class="config-input"
|
||||
placeholder="nvapi-..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<select
|
||||
id="cfg-nvidia-model"
|
||||
class="config-select"
|
||||
style="width: 200px"
|
||||
>
|
||||
<option value="">-- Modele --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-qwencloud-key"
|
||||
>QwenCloud API Key</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="cfg-qwencloud-key"
|
||||
class="config-input"
|
||||
placeholder="sk-..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<select
|
||||
id="cfg-qwencloud-model"
|
||||
class="config-select"
|
||||
style="width: 200px"
|
||||
>
|
||||
<option value="">-- Modele --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-xiaomi-key"
|
||||
>Xiaomi API Key</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="cfg-xiaomi-key"
|
||||
class="config-input"
|
||||
placeholder="xm-..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<select
|
||||
id="cfg-xiaomi-model"
|
||||
class="config-select"
|
||||
style="width: 200px"
|
||||
>
|
||||
<option value="">-- Modele --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label
|
||||
class="config-label"
|
||||
for="cfg-mistral-key"
|
||||
>Mistral API Key</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="cfg-mistral-key"
|
||||
class="config-input"
|
||||
placeholder="sk-..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<select
|
||||
id="cfg-mistral-model"
|
||||
class="config-select"
|
||||
style="width: 200px"
|
||||
>
|
||||
<option value="">-- Modele --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
class="config-actions-row"
|
||||
style="margin-top: 16px"
|
||||
@@ -2042,7 +2153,7 @@
|
||||
<h2 data-i18n="auto.c14b5603">🎨 Thèmes</h2>
|
||||
<p class="config-description" data-i18n="auto.caac40b9">
|
||||
Choisissez un thème visuel. Chaque thème
|
||||
offre un mode sombre et clair.
|
||||
offre un mode sombre, clair, contraste élevé et sépia.
|
||||
</p>
|
||||
<div class="theme-grid" id="theme-grid">
|
||||
<div class="config-diag-loading" data-i18n="common.loading">Chargement...</div>
|
||||
@@ -2080,11 +2191,61 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- À propos -->
|
||||
<!-- Sécurité du compte (MFA) -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-about"
|
||||
id="cfg-security"
|
||||
>
|
||||
<h2 data-i18n="settings.security">🔒 Sécurité du compte</h2>
|
||||
<p class="config-description" data-i18n="settings.security_desc">
|
||||
Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.
|
||||
</p>
|
||||
<div id="mfa-settings">
|
||||
<div id="mfa-status" class="mfa-status-section">
|
||||
<div class="mfa-status-row">
|
||||
<span class="mfa-status-label" data-i18n="mfa.status_label">Authentification 2FA</span>
|
||||
<span id="mfa-status-badge" class="mfa-badge mfa-badge-off" data-i18n="mfa.disabled">Désactivée</span>
|
||||
</div>
|
||||
<div id="mfa-setup-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications push -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-push"
|
||||
>
|
||||
<h2 data-i18n="config.section_push">🔔 Notifications push</h2>
|
||||
<p class="config-description">
|
||||
Recevez des notifications web push
|
||||
lorsque des fichiers changent dans vos
|
||||
vaults, même si ObsiGate n'est pas ouvert.
|
||||
</p>
|
||||
<div id="push-settings">
|
||||
<div class="config-diag-loading" data-i18n="common.loading">
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Plugins -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-plugins"
|
||||
>
|
||||
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
|
||||
<p class="config-description" data-i18n="plugins.description">
|
||||
Extend ObsiGate with custom renderers, search filters, and editor actions.
|
||||
</p>
|
||||
<div id="plugins-settings-container"></div>
|
||||
</section>
|
||||
|
||||
<!-- À propos -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-about"
|
||||
>
|
||||
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
|
||||
<div
|
||||
id="config-about"
|
||||
@@ -2667,6 +2828,11 @@
|
||||
data-i18n="help.nav_files">📁 Fichiers</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-excalidraw" class="help-nav-link"
|
||||
data-i18n="help.nav_excalidraw">🎨 Excalidraw</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-edition" class="help-nav-link"
|
||||
data-i18n="help.nav_editing">✏️ Édition</a
|
||||
@@ -2678,7 +2844,7 @@
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-ia" class="help-nav-link"
|
||||
<a href="#help-ai" class="help-nav-link"
|
||||
data-i18n="help.nav_ai">🤖 IA Éditeur</a
|
||||
>
|
||||
</li>
|
||||
@@ -2726,6 +2892,11 @@
|
||||
data-i18n="help.nav_tips">💡 Astuces</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-plugins" class="help-nav-link"
|
||||
data-i18n="help.nav_plugins">🧩 Plugins</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="help-content">
|
||||
@@ -3323,6 +3494,13 @@
|
||||
<li>
|
||||
<strong>Web</strong> : .html, .css, .scss
|
||||
</li>
|
||||
<li>
|
||||
<strong>PDF</strong> : .pdf (visualiseur natif, texte indexé)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Excalidraw</strong> : .excalidraw,
|
||||
.excalidraw.md
|
||||
</li>
|
||||
<li>
|
||||
<strong data-i18n="help.other">Autres</strong> : .txt, .log, .sql,
|
||||
.csv
|
||||
@@ -3330,6 +3508,50 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 🎨 Excalidraw -->
|
||||
<section class="help-section" id="help-excalidraw">
|
||||
<h2 data-i18n="help.excalidraw_title">🎨 Diagrammes Excalidraw</h2>
|
||||
<p data-i18n-html="help.excalidraw_intro">
|
||||
Les fichiers <code>.excalidraw</code> et
|
||||
<code>.excalidraw.md</code> s'ouvrent dans un éditeur visuel
|
||||
Excalidraw complet, intégré dans une iframe sandboxée.
|
||||
</p>
|
||||
|
||||
<h3 data-i18n="help.excalidraw_open_title">Ouverture</h3>
|
||||
<ul>
|
||||
<li data-i18n-html="help.excalidraw_open">
|
||||
Cliquez sur un fichier <code>.excalidraw</code> dans
|
||||
l'arborescence : l'éditeur s'ouvre à la place du viewer markdown.
|
||||
</li>
|
||||
<li data-i18n="help.excalidraw_edit">
|
||||
Dessinez, ajoutez des formes, du texte, des flèches et des
|
||||
images — l'expérience Excalidraw complète.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 data-i18n="help.excalidraw_save_title">Sauvegarde</h3>
|
||||
<ul>
|
||||
<li data-i18n-html="help.excalidraw_save">
|
||||
Les modifications sont sauvegardées automatiquement (2 s) ou
|
||||
avec <strong>Ctrl+S</strong>. L'éditeur suit le thème
|
||||
clair/sombre.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 data-i18n="help.excalidraw_search_title">Recherche</h3>
|
||||
<ul>
|
||||
<li data-i18n="help.excalidraw_search">
|
||||
Le texte des éléments du diagramme est extrait à l'indexation :
|
||||
il est donc recherchable via la recherche full-text.
|
||||
</li>
|
||||
<li data-i18n-html="help.excalidraw_compat">
|
||||
Les fichiers créés avec le plugin Obsidian Excalidraw (y
|
||||
compris le format <code>.excalidraw.md</code> compressé) sont
|
||||
compatibles.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ✏️ Édition -->
|
||||
<section class="help-section" id="help-edition">
|
||||
<h2 data-i18n="auto.f346076a">✏️ Édition avancée</h2>
|
||||
@@ -4620,10 +4842,75 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 🧩 Plugins -->
|
||||
<section class="help-section" id="help-plugins">
|
||||
<h2 data-i18n="help.plugins_title">🧩 Plugins</h2>
|
||||
<p data-i18n="help.plugins_intro">
|
||||
Les plugins étendent ObsiGate : affichage personnalisé des
|
||||
fichiers, filtres de recherche, actions d'édition, widgets
|
||||
et intégrations. Ils s'exécutent dans des Web Workers
|
||||
sandboxés — isolés de la page pour la sécurité.
|
||||
</p>
|
||||
|
||||
<h3 data-i18n="help.plugins_install">Installation</h3>
|
||||
<ol>
|
||||
<li data-i18n="help.plugins_install_1">Ouvrez Réglages → 🧩 Plugins.</li>
|
||||
<li data-i18n="help.plugins_install_2">Cliquez « Installer un plugin ».</li>
|
||||
<li data-i18n="help.plugins_install_3">Sélectionnez un fichier <code>.zip</code> contenant le manifest
|
||||
<code>plugin.json</code> + le code du plugin.</li>
|
||||
<li data-i18n="help.plugins_install_4">Le manifest est validé (nom, version semver, hooks, permissions)
|
||||
avant installation.</li>
|
||||
</ol>
|
||||
|
||||
<h3 data-i18n="help.plugins_manage">Gestion</h3>
|
||||
<ul>
|
||||
<li data-i18n="help.plugins_manage_1"><strong>Activer / Désactiver</strong> : boutons sur chaque plugin — les
|
||||
hooks ne tournent que si le plugin est activé.</li>
|
||||
<li data-i18n="help.plugins_manage_2"><strong>Désinstaller</strong> : supprime définitivement le plugin du vault.</li>
|
||||
<li data-i18n="help.plugins_manage_3"><strong>Code</strong> : visualisez le code source du plugin installé.</li>
|
||||
</ul>
|
||||
|
||||
<h3 data-i18n="help.plugins_dev">Développer un plugin</h3>
|
||||
<p data-i18n="help.plugins_dev_intro">
|
||||
Téléchargez le modèle depuis Réglages → Plugins → « Obtenir le
|
||||
modèle ». Un plugin se compose d'un manifest
|
||||
<code>plugin.json</code> et d'un fichier principal (ex.
|
||||
<code>index.js</code>).
|
||||
</p>
|
||||
|
||||
<h3 data-i18n="help.plugins_hooks">Hooks disponibles</h3>
|
||||
<ul>
|
||||
<li><strong data-i18n="help.plugins_hook_render">onFileRender</strong> :
|
||||
<span data-i18n="help.plugins_hook_render_desc">transforme le contenu affiché d'un fichier.</span></li>
|
||||
<li><strong data-i18n="help.plugins_hook_search">onSearchFilter</strong> :
|
||||
<span data-i18n="help.plugins_hook_search_desc">filtre les résultats de recherche.</span></li>
|
||||
<li><strong data-i18n="help.plugins_hook_editor">onEditorAction</strong> :
|
||||
<span data-i18n="help.plugins_hook_editor_desc">intercepte les actions de l'éditeur.</span></li>
|
||||
<li><strong data-i18n="help.plugins_hook_sidebar">onSidebarItem</strong> :
|
||||
<span data-i18n="help.plugins_hook_sidebar_desc">ajoute des éléments à la barre latérale.</span></li>
|
||||
<li><strong data-i18n="help.plugins_hook_file">onFileCreate / onFileDelete</strong> :
|
||||
<span data-i18n="help.plugins_hook_file_desc">réagit à la création / suppression de fichiers.</span></li>
|
||||
<li><strong data-i18n="help.plugins_hook_vault">onVaultMount</strong> :
|
||||
<span data-i18n="help.plugins_hook_vault_desc">exécuté au chargement d'un vault.</span></li>
|
||||
</ul>
|
||||
|
||||
<h3 data-i18n="help.plugins_security">Sécurité</h3>
|
||||
<ul>
|
||||
<li data-i18n="help.plugins_security_1"><strong>Sandbox Web Worker</strong> : le code plugin tourne isolé, sans
|
||||
accès direct au DOM.</li>
|
||||
<li data-i18n="help.plugins_security_2"><strong>Permissions</strong> : chaque hook déclaré doit être satisfait
|
||||
par les permissions du manifest.</li>
|
||||
<li data-i18n="help.plugins_security_3"><strong>Empaquetage sécurisé</strong> : le .zip est vérifié contre le
|
||||
path traversal à l'installation.</li>
|
||||
<li data-i18n="help.plugins_security_4"><strong>Admin uniquement</strong> : l'installation et la suppression
|
||||
requièrent le rôle admin.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="help-footer">
|
||||
<p>
|
||||
<strong>ObsiGate v1.7.0</strong> - Porte
|
||||
d'entrée web pour vos vaults Obsidian
|
||||
<strong>ObsiGate <span id="help-version"></span></strong>
|
||||
<span data-i18n="help.footer_tagline">- Porte d'entrée web pour vos vaults Obsidian</span>
|
||||
</p>
|
||||
<p>
|
||||
Pour plus d'informations, consultez le
|
||||
@@ -4634,6 +4921,7 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4845,7 +5133,7 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
</div>
|
||||
<h1 class="about-title" data-i18n="header.logo">ObsiGate</h1>
|
||||
<p class="about-tagline">Vos notes Obsidian,<br>simplement accessibles.</p>
|
||||
<span class="about-version">v2.0.0-dev</span>
|
||||
<span class="about-version" id="about-version">—</span>
|
||||
</div>
|
||||
<div class="about-body">
|
||||
<div class="about-stats">
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
// frontend/js/admin.js — Admin Dashboard frontend module (ROADMAP #71)
|
||||
//
|
||||
// Standalone ES module loaded by frontend/admin.html.
|
||||
// Public API:
|
||||
// init() — DOMContentLoaded entry point. Verifies auth, fires initial loads.
|
||||
// connectSSE() — Opens EventSource on /api/admin/stream; updates widgets on every "stats" event.
|
||||
// loadStatsOnce() — Initial GET /api/admin/stats (snapshot before SSE delivers first frame).
|
||||
// loadAuditLogs(filters) — GET /api/admin/audit with optional {user, action}.
|
||||
// loadBackupStats() — GET /api/admin/backup-stats.
|
||||
// renderStatsWidget(stats) — Paint CPU/RAM/Disk/Uptime/Sessions cards.
|
||||
// renderAuditTable(entries) — Paint audit log table.
|
||||
// renderBackups(byVault) — Paint per-vault backup bars.
|
||||
// getAuthHeaders() — Read Bearer token from sessionStorage (same source as auth.js).
|
||||
// formatBytes(mb) — Human-readable size formatter.
|
||||
// formatUptime(seconds) — "Xd Yh Zm" formatter.
|
||||
// severityColor(pct) — "success" | "warning" | "danger" based on thresholds.
|
||||
|
||||
import { api, AdminPanel } from "./auth.js";
|
||||
import { state } from "./state.js";
|
||||
import { escapeHtml, safeCreateIcons } from "./utils.js";
|
||||
import { t, getLocale } from "./i18n.js";
|
||||
|
||||
// ── Module state ─────────────────────────────────────────────────────────
|
||||
let _eventSource = null;
|
||||
let _pollTimer = null;
|
||||
let _auditFilters = { user: "", action: "" };
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the current Bearer token from sessionStorage (same key auth.js uses).
|
||||
* Returns an `{Authorization: "Bearer ..."}` object or null.
|
||||
*/
|
||||
export function getAuthHeaders() {
|
||||
try {
|
||||
const token = sessionStorage.getItem("obsigate_access_token");
|
||||
if (!token) return null;
|
||||
return { Authorization: "Bearer " + token };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a size in MB as a human-readable string. */
|
||||
export function formatBytes(mb) {
|
||||
if (mb == null || isNaN(mb)) return "—";
|
||||
if (mb < 1) return `${Math.round(mb * 1024)} KB`;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** Format an uptime in seconds as "Xd Yh Zm" (or "Xh Ym" / "Xm"). */
|
||||
export function formatUptime(seconds) {
|
||||
if (seconds == null || isNaN(seconds) || seconds < 0) return "—";
|
||||
const s = Math.floor(seconds);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a percentage to a CSS severity token.
|
||||
* <70 → success (green)
|
||||
* <90 → warning (orange)
|
||||
* ≥90 → danger (red)
|
||||
*/
|
||||
export function severityColor(pct) {
|
||||
if (pct == null || isNaN(pct)) return "success";
|
||||
if (pct >= 90) return "danger";
|
||||
if (pct >= 70) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
/** Format an ISO timestamp with the user's locale. */
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString(
|
||||
getLocale() === "fr" ? "fr-FR" : "en-US"
|
||||
);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/** Discreet error helper — never crashes the page. */
|
||||
function _showInlineError(containerId, msg) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="admin-inline-error" style="color:var(--danger);padding:8px 12px;font-size:0.85rem;">${escapeHtml(msg)}</div>`;
|
||||
}
|
||||
|
||||
// ── Stats widget ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Paint the 5 stats cards.
|
||||
* @param {object} stats {cpu_pct, mem_used_mb, mem_total_mb, disk_used_gb, disk_total_gb, uptime_seconds, active_sessions}
|
||||
*/
|
||||
export function renderStatsWidget(stats) {
|
||||
if (!stats) return;
|
||||
const grid = document.getElementById("admin-stats-grid");
|
||||
if (!grid) return;
|
||||
|
||||
const cpuPct = stats.cpu_pct ?? 0;
|
||||
const memPct = stats.mem_total_mb > 0 ? (stats.mem_used_mb / stats.mem_total_mb) * 100 : 0;
|
||||
const diskPct = stats.disk_total_gb > 0 ? (stats.disk_used_gb / stats.disk_total_gb) * 100 : 0;
|
||||
|
||||
const cpuSev = severityColor(cpuPct);
|
||||
const memSev = severityColor(memPct);
|
||||
const diskSev = severityColor(diskPct);
|
||||
|
||||
const memTotal = stats.mem_total_mb ?? 0;
|
||||
const diskTotal = stats.disk_total_gb ?? 0;
|
||||
|
||||
grid.innerHTML = `
|
||||
<div class="admin-stat-card" data-sev="${cpuSev}">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_cpu"))}</div>
|
||||
<div class="admin-stat-value">${cpuPct.toFixed(1)}<span class="admin-stat-unit">%</span></div>
|
||||
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(cpuPct, 100).toFixed(1)}%;background:var(--${cpuSev});"></div></div>
|
||||
</div>
|
||||
<div class="admin-stat-card" data-sev="${memSev}">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_memory"))}</div>
|
||||
<div class="admin-stat-value">${stats.mem_used_mb?.toFixed(0) ?? "—"}<span class="admin-stat-unit"> / ${memTotal.toFixed(0)} MB</span></div>
|
||||
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(memPct, 100).toFixed(1)}%;background:var(--${memSev});"></div></div>
|
||||
</div>
|
||||
<div class="admin-stat-card" data-sev="${diskSev}">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_disk"))}</div>
|
||||
<div class="admin-stat-value">${stats.disk_used_gb?.toFixed(1) ?? "—"}<span class="admin-stat-unit"> / ${diskTotal.toFixed(1)} GB</span></div>
|
||||
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(diskPct, 100).toFixed(1)}%;background:var(--${diskSev});"></div></div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_uptime"))}</div>
|
||||
<div class="admin-stat-value" style="font-size:1.4rem;">${escapeHtml(formatUptime(stats.uptime_seconds))}</div>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<div class="admin-stat-label">${escapeHtml(t("admin.stats_sessions"))}</div>
|
||||
<div class="admin-stat-value">${stats.active_sessions ?? 0}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Update footer timestamp
|
||||
const ts = stats.timestamp ? new Date(stats.timestamp) : new Date();
|
||||
const stamp = document.getElementById("admin-stats-timestamp");
|
||||
if (stamp) {
|
||||
stamp.textContent = t("admin.stats_refresh", { time: ts.toLocaleTimeString(getLocale() === "fr" ? "fr-FR" : "en-US") });
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch /api/admin/stats and paint. */
|
||||
export async function loadStatsOnce() {
|
||||
try {
|
||||
const data = await api("/api/admin/stats");
|
||||
renderStatsWidget(data);
|
||||
} catch (err) {
|
||||
_showInlineError("admin-stats-grid", err.message || "stats load failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a Server-Sent Events connection on /api/admin/stream.
|
||||
* The endpoint emits `event: stats\ndata: {...}` every 5 seconds.
|
||||
*
|
||||
* The browser's EventSource doesn't support custom headers, so we rely on
|
||||
* the httpOnly cookie (set by /api/auth/login with samesite=lax) to authenticate.
|
||||
* `withCredentials: true` ensures the cookie is sent.
|
||||
*
|
||||
* If the connection fails to open (4xx/5xx or network), we fall back to
|
||||
* periodic polling of /api/admin/stats every 5s so the UI keeps updating.
|
||||
*/
|
||||
export function connectSSE() {
|
||||
if (_eventSource) {
|
||||
try { _eventSource.close(); } catch { /* */ }
|
||||
_eventSource = null;
|
||||
}
|
||||
if (_pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
let sseReady = false;
|
||||
try {
|
||||
_eventSource = new EventSource("/api/admin/stream", { withCredentials: true });
|
||||
_eventSource.addEventListener("stats", (ev) => {
|
||||
sseReady = true;
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
renderStatsWidget(data);
|
||||
} catch (err) {
|
||||
console.warn("admin SSE parse error", err);
|
||||
}
|
||||
});
|
||||
_eventSource.onopen = () => {
|
||||
sseReady = true;
|
||||
// Stop the polling fallback once SSE works.
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
};
|
||||
_eventSource.onerror = () => {
|
||||
// EventSource auto-reconnects. Only start polling fallback if we
|
||||
// never received the first event yet (e.g. 401/403/network blocked).
|
||||
if (!sseReady) {
|
||||
_startPollingFallback();
|
||||
}
|
||||
console.warn("admin SSE error (will retry)");
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("admin SSE init failed", err);
|
||||
_startPollingFallback();
|
||||
}
|
||||
// Kick off a single initial fetch right away so the UI doesn't show
|
||||
// zeros for 5 seconds waiting for the first SSE event.
|
||||
loadStatsOnce();
|
||||
}
|
||||
|
||||
function _startPollingFallback() {
|
||||
if (_pollTimer) return;
|
||||
console.warn("admin: falling back to polling /api/admin/stats every 5s");
|
||||
_pollTimer = setInterval(() => {
|
||||
loadStatsOnce().catch(() => { /* ignore — error already shown */ });
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/** Close the SSE connection if any (useful before navigation). */
|
||||
export function disconnectSSE() {
|
||||
if (_eventSource) {
|
||||
try { _eventSource.close(); } catch { /* */ }
|
||||
_eventSource = null;
|
||||
}
|
||||
if (_pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audit log ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the audit log table.
|
||||
* @param {Array<object>} entries Array of audit entries (timestamp, action, username, vault, ip, ...).
|
||||
*/
|
||||
export function renderAuditTable(entries) {
|
||||
const tbody = document.getElementById("admin-audit-tbody");
|
||||
const empty = document.getElementById("admin-audit-empty");
|
||||
if (!tbody) return;
|
||||
|
||||
if (!entries || !entries.length) {
|
||||
tbody.innerHTML = "";
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
|
||||
tbody.innerHTML = entries.map((e) => {
|
||||
const ts = formatDate(e.timestamp);
|
||||
const action = escapeHtml(e.action || "—");
|
||||
const user = escapeHtml(e.username || e.user || "—");
|
||||
const vault = escapeHtml(e.vault || "—");
|
||||
const ip = escapeHtml(e.ip || "—");
|
||||
return `<tr>
|
||||
<td>${escapeHtml(ts)}</td>
|
||||
<td><code>${action}</code></td>
|
||||
<td>${user}</td>
|
||||
<td>${vault}</td>
|
||||
<td>${ip}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch /api/admin/audit with optional filters.
|
||||
* @param {{user?: string, action?: string}} filters
|
||||
*/
|
||||
export async function loadAuditLogs(filters = {}) {
|
||||
if (filters.user !== undefined) _auditFilters.user = filters.user;
|
||||
if (filters.action !== undefined) _auditFilters.action = filters.action;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (_auditFilters.user) params.set("user", _auditFilters.user);
|
||||
if (_auditFilters.action) params.set("action", _auditFilters.action);
|
||||
params.set("limit", "200");
|
||||
|
||||
const qs = params.toString();
|
||||
try {
|
||||
const data = await api(`/api/admin/audit?${qs}`);
|
||||
renderAuditTable(data.entries || []);
|
||||
} catch (err) {
|
||||
_showInlineError("admin-audit-tbody", err.message || "audit load failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Populate the action <select> with the values we know about.
|
||||
* The endpoint doesn't list all possible actions, so we hardcode the
|
||||
* common ones and let the user pick "All". */
|
||||
function _populateAuditActions() {
|
||||
const select = document.getElementById("admin-audit-action");
|
||||
if (!select) return;
|
||||
const ACTIONS = [
|
||||
"file_save", "file_delete", "file_view", "file_rename",
|
||||
"share_create", "share_revoke", "auth_login", "auth_logout",
|
||||
"auth_failed", "user_create", "user_update", "user_delete",
|
||||
"backup_create", "config_update",
|
||||
];
|
||||
select.innerHTML =
|
||||
`<option value="">${escapeHtml(t("admin.audit_all_actions"))}</option>` +
|
||||
ACTIONS.map((a) => `<option value="${a}">${escapeHtml(a)}</option>`).join("");
|
||||
}
|
||||
|
||||
// ── Backups ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render per-vault backup bars.
|
||||
* @param {object} byVault { vault: {count, size_mb} }
|
||||
*/
|
||||
export function renderBackups(byVault) {
|
||||
const tbody = document.getElementById("admin-backups-tbody");
|
||||
const empty = document.getElementById("admin-backups-empty");
|
||||
if (!tbody) return;
|
||||
|
||||
const entries = byVault ? Object.entries(byVault) : [];
|
||||
if (!entries.length) {
|
||||
tbody.innerHTML = "";
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
|
||||
// Largest vault for proportional bars
|
||||
const maxSize = Math.max(...entries.map(([, v]) => v.size_mb || 0), 1);
|
||||
|
||||
tbody.innerHTML = entries
|
||||
.sort((a, b) => (b[1].size_mb || 0) - (a[1].size_mb || 0))
|
||||
.map(([vault, data]) => {
|
||||
const count = data.count || 0;
|
||||
const size = data.size_mb || 0;
|
||||
const widthPct = Math.min((size / maxSize) * 100, 100).toFixed(1);
|
||||
return `<tr>
|
||||
<td>${escapeHtml(vault)}</td>
|
||||
<td>${count}</td>
|
||||
<td>${escapeHtml(formatBytes(size))}</td>
|
||||
<td>
|
||||
<div class="admin-backup-bar"><div class="admin-backup-bar-fill" style="width:${widthPct}%;"></div></div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Fetch /api/admin/backup-stats and paint. */
|
||||
export async function loadBackupStats() {
|
||||
try {
|
||||
const data = await api("/api/admin/backup-stats");
|
||||
const summary = document.getElementById("admin-backups-summary");
|
||||
if (summary) {
|
||||
summary.innerHTML = `
|
||||
<div>${escapeHtml(t("admin.backups_total", { count: data.total_backups ?? 0, size: formatBytes(data.total_size_mb) }))}</div>
|
||||
<div>${escapeHtml(t("admin.backups_oldest", { days: (data.oldest_age_days ?? 0).toFixed(2) }))}</div>
|
||||
<div>${escapeHtml(t("admin.backups_newest", { days: (data.newest_age_days ?? 0).toFixed(2) }))}</div>
|
||||
`;
|
||||
}
|
||||
renderBackups(data.by_vault || {});
|
||||
} catch (err) {
|
||||
_showInlineError("admin-backups-tbody", err.message || "backups load failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ── User management integration ─────────────────────────────────────────
|
||||
|
||||
/** Wire the "Manage users" button to open the existing AdminPanel modal. */
|
||||
function _wireUserMgmt() {
|
||||
const btn = document.getElementById("admin-users-manage-btn");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
try {
|
||||
AdminPanel.show();
|
||||
} catch (err) {
|
||||
console.error("AdminPanel.show failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filters wiring ──────────────────────────────────────────────────────
|
||||
|
||||
function _wireAuditFilters() {
|
||||
const userInput = document.getElementById("admin-audit-user");
|
||||
const actionSelect = document.getElementById("admin-audit-action");
|
||||
const refreshBtn = document.getElementById("admin-audit-refresh");
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", () => loadAuditLogs());
|
||||
}
|
||||
if (userInput) {
|
||||
userInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") loadAuditLogs();
|
||||
});
|
||||
}
|
||||
if (actionSelect) {
|
||||
actionSelect.addEventListener("change", () => loadAuditLogs());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section nav (scrollspy) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wire the sticky section nav: smooth-scroll on click + highlight the section
|
||||
* currently in view (IntersectionObserver). Falls back to default anchor jump
|
||||
* if the observer is unavailable.
|
||||
*/
|
||||
function _wireSectionNav() {
|
||||
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
|
||||
if (!links.length) return;
|
||||
const targets = links
|
||||
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
|
||||
.filter(Boolean);
|
||||
|
||||
// Smooth scroll on click
|
||||
links.forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
const href = a.getAttribute("href");
|
||||
if (!href || !href.startsWith("#")) return;
|
||||
const t = document.getElementById(href.slice(1));
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
t.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
// Scrollspy: mark the section currently at the top of the viewport
|
||||
let active = null;
|
||||
const setActive = (id) => {
|
||||
if (active === id) return;
|
||||
active = id;
|
||||
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
|
||||
};
|
||||
|
||||
if ("IntersectionObserver" in window) {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((en) => {
|
||||
if (en.isIntersecting) setActive(en.target.id);
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
targets.forEach((t) => io.observe(t));
|
||||
} else {
|
||||
// Fallback: highlight the first section only (no scroll observation)
|
||||
links[0] && links[0].classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth gate ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify the current session and admin role.
|
||||
* Uses /api/auth/me (which returns the current user) and falls back to
|
||||
* the cached user in sessionStorage if the request fails.
|
||||
* Redirects to / if not authenticated or not admin.
|
||||
* Returns the user object on success, null otherwise.
|
||||
*/
|
||||
async function _gateAdmin() {
|
||||
const container = document.getElementById("admin-main");
|
||||
const forbidden = document.getElementById("admin-forbidden");
|
||||
// First check whether auth is even enabled (public endpoint).
|
||||
let authEnabled = false;
|
||||
try {
|
||||
const statusRes = await fetch("/api/auth/status", { credentials: "include" });
|
||||
if (statusRes.ok) {
|
||||
const status = await statusRes.json();
|
||||
authEnabled = !!status.auth_enabled;
|
||||
}
|
||||
} catch { /* network error — fall through */ }
|
||||
|
||||
if (!authEnabled) {
|
||||
// Server is wide open, treat current visitor as allowed.
|
||||
return { username: "anonymous", role: "admin" };
|
||||
}
|
||||
|
||||
// Auth is enabled — try to load the current user.
|
||||
try {
|
||||
const meRes = await fetch("/api/auth/me", {
|
||||
credentials: "include",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (meRes.status === 401 || meRes.status === 403) {
|
||||
window.location.href = "/";
|
||||
return null;
|
||||
}
|
||||
if (!meRes.ok) throw new Error("HTTP " + meRes.status);
|
||||
const user = await meRes.json();
|
||||
// Cache for later fallback
|
||||
try {
|
||||
sessionStorage.setItem("obsigate_user", JSON.stringify(user));
|
||||
} catch { /* */ }
|
||||
if (user.role !== "admin") {
|
||||
if (container) container.style.display = "none";
|
||||
if (forbidden) forbidden.style.display = "";
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
} catch (err) {
|
||||
// Network error — try to use cached sessionStorage user as fallback
|
||||
try {
|
||||
const cached = sessionStorage.getItem("obsigate_user");
|
||||
if (cached) {
|
||||
const u = JSON.parse(cached);
|
||||
if (u.role === "admin") return u;
|
||||
}
|
||||
} catch { /* */ }
|
||||
window.location.href = "/";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Page entry point. Called on DOMContentLoaded by admin.html.
|
||||
* Verifies auth, populates the action select, wires events, kicks off loads.
|
||||
*/
|
||||
export async function init() {
|
||||
const user = await _gateAdmin();
|
||||
if (!user) return;
|
||||
|
||||
// Show role badge
|
||||
const badge = document.getElementById("admin-role-badge");
|
||||
if (badge) {
|
||||
badge.textContent = user.role || "user";
|
||||
badge.dataset.role = user.role || "user";
|
||||
}
|
||||
|
||||
_populateAuditActions();
|
||||
_wireAuditFilters();
|
||||
_wireUserMgmt();
|
||||
_wireSectionNav();
|
||||
|
||||
// Initial loads (in parallel — none depends on another)
|
||||
loadStatsOnce();
|
||||
loadAuditLogs();
|
||||
loadBackupStats();
|
||||
|
||||
// Then start the live SSE stream for stats
|
||||
connectSSE();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// ObsiGate — Floating Action Button (FAB) for the AI Assistant sidebar.
|
||||
// Always visible in the bottom-right corner; hides while the assistant panel
|
||||
// is expanded. Clicking it opens the assistant in the context that matches
|
||||
// the current application state (open documents, or General when none).
|
||||
import booksLM from './bookslm.js';
|
||||
import { t } from './i18n.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
let _fab = null;
|
||||
|
||||
// The panel is visible when it has `.open` without `.hidden`.
|
||||
function isBooksLmVisible() {
|
||||
const panel = document.querySelector('.bookslm-panel');
|
||||
return !!(panel && panel.classList.contains('open') && !panel.classList.contains('hidden'));
|
||||
}
|
||||
|
||||
function syncVisibility() {
|
||||
if (!_fab) return;
|
||||
const visible = isBooksLmVisible();
|
||||
_fab.hidden = visible;
|
||||
const label = visible ? t('fab.close') : t('fab.open');
|
||||
_fab.title = label;
|
||||
_fab.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
const panel = document.querySelector('.bookslm-panel');
|
||||
// Collapsed panel: reveal it instead of reopening.
|
||||
if (panel && panel.classList.contains('open') && panel.classList.contains('hidden')) {
|
||||
booksLM._toggleSidebar();
|
||||
return;
|
||||
}
|
||||
// Otherwise open (or re-focus) the assistant for the current context.
|
||||
await booksLM.openForCurrentContext();
|
||||
}
|
||||
|
||||
export function initAIFab() {
|
||||
if (_fab) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.id = 'ai-fab';
|
||||
btn.className = 'ai-fab';
|
||||
btn.innerHTML = '<i data-lucide="bot" style="width:24px;height:24px"></i>';
|
||||
document.body.appendChild(btn);
|
||||
_fab = btn;
|
||||
|
||||
btn.addEventListener('click', onClick);
|
||||
document.addEventListener('bookslm:opened', syncVisibility);
|
||||
document.addEventListener('bookslm:closed', syncVisibility);
|
||||
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
syncVisibility();
|
||||
}
|
||||
|
||||
export default initAIFab;
|
||||
+309
-49
@@ -13,7 +13,14 @@ import { t } from './i18n.js';
|
||||
|
||||
// ── API call helper ──
|
||||
async function aiAction(endpoint, text, extra = {}) {
|
||||
const body = { text, ...extra };
|
||||
// Inject current provider + model from the per-section picker.
|
||||
const pickerState = _readPicker();
|
||||
const body = {
|
||||
text,
|
||||
...extra,
|
||||
...(pickerState.provider ? { provider: pickerState.provider } : {}),
|
||||
...(pickerState.model ? { model: pickerState.model } : {}),
|
||||
};
|
||||
const data = await api(`/api/ai/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -22,6 +29,165 @@ async function aiAction(endpoint, text, extra = {}) {
|
||||
return data.result;
|
||||
}
|
||||
|
||||
// ── Per-section provider/model picker ───────────────────────────────────────
|
||||
// State is stored in localStorage so the user choice persists across sections.
|
||||
const PICKER_STORAGE_KEY = 'obsigate_ai_picker';
|
||||
|
||||
function _readPicker() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}');
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function _writePicker(state) {
|
||||
try {
|
||||
localStorage.setItem(PICKER_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a provider+model picker that appears in every section's AI toolbar
|
||||
* (Forge editor, BooksLM, etc.). The picker reads /api/ai/status to discover
|
||||
* available providers, then /api/config/ai-models?provider=X to list models.
|
||||
*/
|
||||
async function _buildPickerUI() {
|
||||
const pickerState = _readPicker();
|
||||
|
||||
// Fetch configured providers
|
||||
let availableProviders = {};
|
||||
try {
|
||||
const status = await api('/api/ai/status');
|
||||
availableProviders = status.providers || {};
|
||||
} catch { /* */ }
|
||||
|
||||
const providerNames = Object.keys(availableProviders).filter(
|
||||
(p) => availableProviders[p]?.available
|
||||
);
|
||||
if (!providerNames.length) return null;
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ai-picker';
|
||||
Object.assign(wrap.style, {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
marginLeft: '8px',
|
||||
paddingLeft: '8px',
|
||||
borderLeft: '1px solid var(--border)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--text-muted)',
|
||||
flexShrink: '0',
|
||||
zIndex: '1',
|
||||
});
|
||||
|
||||
// Provider select
|
||||
const providerLabel = document.createElement('span');
|
||||
providerLabel.textContent = t('ai.provider') + ':';
|
||||
wrap.appendChild(providerLabel);
|
||||
|
||||
const providerSelect = document.createElement('select');
|
||||
providerSelect.className = 'ai-picker-select';
|
||||
Object.assign(providerSelect.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 4px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = t('ai.provider_default');
|
||||
providerSelect.appendChild(defaultOpt);
|
||||
providerNames.forEach((p) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p;
|
||||
opt.textContent = p;
|
||||
if (p === pickerState.provider) opt.selected = true;
|
||||
providerSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Model select (sibling of provider)
|
||||
const modelSelect = document.createElement('select');
|
||||
modelSelect.className = 'ai-picker-model';
|
||||
Object.assign(modelSelect.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 4px',
|
||||
cursor: 'pointer',
|
||||
minWidth: '120px',
|
||||
maxWidth: '220px',
|
||||
});
|
||||
const placeholderOpt = document.createElement('option');
|
||||
placeholderOpt.value = '';
|
||||
placeholderOpt.textContent = t('ai.model_default');
|
||||
modelSelect.appendChild(placeholderOpt);
|
||||
|
||||
async function _loadModels(provider) {
|
||||
modelSelect.innerHTML = '';
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = t('ai.model_loading');
|
||||
modelSelect.appendChild(ph);
|
||||
try {
|
||||
const data = await api(`/api/config/ai-models?provider=${encodeURIComponent(provider)}`);
|
||||
modelSelect.innerHTML = '';
|
||||
const def = document.createElement('option');
|
||||
def.value = '';
|
||||
def.textContent = t('ai.model_default') + (data.source === 'fallback' ? ` (${t('ai.model_offline')})` : '');
|
||||
modelSelect.appendChild(def);
|
||||
(data.models || []).forEach((m) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m;
|
||||
if (m === pickerState.model && provider === pickerState.provider) opt.selected = true;
|
||||
modelSelect.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
modelSelect.innerHTML = '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = t('ai.model_load_error');
|
||||
modelSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
providerSelect.addEventListener('change', () => {
|
||||
pickerState.provider = providerSelect.value || null;
|
||||
// Reset model when provider changes
|
||||
pickerState.model = null;
|
||||
_writePicker(pickerState);
|
||||
if (pickerState.provider) {
|
||||
_loadModels(pickerState.provider);
|
||||
} else {
|
||||
modelSelect.innerHTML = '';
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = t('ai.model_default');
|
||||
modelSelect.appendChild(ph);
|
||||
}
|
||||
});
|
||||
|
||||
modelSelect.addEventListener('change', () => {
|
||||
pickerState.model = modelSelect.value || null;
|
||||
_writePicker(pickerState);
|
||||
});
|
||||
|
||||
// Load models for the initial provider selection
|
||||
if (pickerState.provider && providerNames.includes(pickerState.provider)) {
|
||||
_loadModels(pickerState.provider);
|
||||
}
|
||||
|
||||
wrap.appendChild(providerSelect);
|
||||
wrap.appendChild(modelSelect);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ── Get selected text from CodeMirror ──
|
||||
function getSelection(editorView) {
|
||||
if (!editorView) return '';
|
||||
@@ -80,12 +246,12 @@ function createMenu(items, parentEl) {
|
||||
const menuLeft = rect.left;
|
||||
|
||||
Object.assign(menu.style, {
|
||||
position: 'fixed',
|
||||
top: menuTop + 'px',
|
||||
left: menuLeft + 'px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
position: 'fixed',
|
||||
top: menuTop + 'px',
|
||||
left: menuLeft + 'px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 0',
|
||||
minWidth: '200px',
|
||||
maxHeight: '60vh',
|
||||
@@ -172,12 +338,12 @@ function createSubMenu(items, parentEl) {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'ai-submenu';
|
||||
Object.assign(menu.style, {
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: '0',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: '0',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 0',
|
||||
minWidth: '180px',
|
||||
zIndex: '101',
|
||||
@@ -210,6 +376,76 @@ function createSubMenu(items, parentEl) {
|
||||
}
|
||||
|
||||
// ── Main AI Toolbar ──
|
||||
let _activeAiBtn = null;
|
||||
let _aiShortcutBound = false;
|
||||
|
||||
/**
|
||||
* Bind the Ctrl/Cmd+J inline-completion shortcut exactly once, regardless of
|
||||
* how many times the editor (and therefore the toolbar) is re-created.
|
||||
* Without this guard every editor open added a new listener, so pressing the
|
||||
* shortcut fired the AI request N times.
|
||||
*/
|
||||
function _bindAiShortcut() {
|
||||
if (_aiShortcutBound) return;
|
||||
_aiShortcutBound = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') {
|
||||
const modal = document.getElementById('editor-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
if (!_activeAiBtn) return;
|
||||
e.preventDefault();
|
||||
_activeAiBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Professional replacement for `window.prompt()` for the custom-rewrite
|
||||
* instruction. Returns a Promise resolving to the entered instruction or
|
||||
* `null` when cancelled. Escape cancels, Ctrl/Cmd+Enter submits.
|
||||
*/
|
||||
function _promptRewriteInstruction() {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ai-modal-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', t('ai.rewrite_title'));
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'ai-modal';
|
||||
modal.innerHTML = `
|
||||
<h3 class="ai-modal-title">${t('ai.custom_rewrite')}</h3>
|
||||
<p class="ai-modal-hint">${t('ai.rewrite_prompt')}</p>
|
||||
<textarea class="ai-modal-input" rows="3" placeholder="${t('ai.rewrite_placeholder')}"></textarea>
|
||||
<div class="ai-modal-actions">
|
||||
<button type="button" class="ai-modal-btn ai-modal-cancel">${t('button.cancel')}</button>
|
||||
<button type="button" class="ai-modal-btn primary ai-modal-ok">${t('ai.rewrite')}</button>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = modal.querySelector('.ai-modal-input');
|
||||
const close = (value) => {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
resolve(value);
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(null); }
|
||||
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
close(input.value.trim() || null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
modal.querySelector('.ai-modal-cancel').addEventListener('click', () => close(null));
|
||||
modal.querySelector('.ai-modal-ok').addEventListener('click', () => close(input.value.trim() || null));
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); });
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAIToolbar(container, getEditorView) {
|
||||
// Check if AI is configured
|
||||
let aiConfigured = false;
|
||||
@@ -220,7 +456,7 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
|
||||
if (!aiConfigured) {
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border-color)';
|
||||
hint.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border)';
|
||||
hint.textContent = t('ai.not_configured');
|
||||
container.appendChild(hint);
|
||||
return;
|
||||
@@ -233,7 +469,7 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '4px 8px',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg-secondary)',
|
||||
flexWrap: 'wrap',
|
||||
position: 'sticky',
|
||||
@@ -258,6 +494,7 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
borderRadius: '4px',
|
||||
});
|
||||
aiBtn.title = t('ai.inline_completion_title');
|
||||
aiBtn.setAttribute('aria-label', t('ai.inline_completion_title'));
|
||||
aiBtn.addEventListener('click', async () => {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
@@ -279,22 +516,22 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
});
|
||||
|
||||
// ── Edit menu ──
|
||||
const editBtn = createDropdownBtn(t('ai.edit'), [
|
||||
{ label: '🪄 Improve writing', action: () => action('improve'), hint: t('ai.hint_improve') },
|
||||
{ label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling'), hint: 'Corrige les fautes' },
|
||||
{ label: '📏 Make shorter', action: () => action('make-shorter'), hint: 'Rend le texte plus concis' },
|
||||
{ label: '📐 Make longer', action: () => action('make-longer'), hint: t('ai.hint_add_details') },
|
||||
{ label: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' },
|
||||
const editBtn = createDropdownBtn(t('ai.toolbar_edit'), [
|
||||
{ label: '🪄 ' + t('ai.improve'), action: () => action('improve'), hint: t('ai.hint_improve') },
|
||||
{ label: '🔤 ' + t('ai.fix_spelling'), action: () => action('fix-spelling'), hint: t('ai.fix_spelling_hint') },
|
||||
{ label: '📏 ' + t('ai.shorter'), action: () => action('make-shorter'), hint: t('ai.shorter_hint') },
|
||||
{ label: '📐 ' + t('ai.longer'), action: () => action('make-longer'), hint: t('ai.hint_add_details') },
|
||||
{ label: '📋 ' + t('ai.simplify'), action: () => action('simplify'), hint: t('ai.simplify_hint') },
|
||||
], t('ai.hint_edit_selection'));
|
||||
|
||||
// ── Tone menu ──
|
||||
const toneBtn = createDropdownBtn('Ton', [
|
||||
{ label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) },
|
||||
{ label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) },
|
||||
], 'Change le ton du texte');
|
||||
const toneBtn = createDropdownBtn(t('ai.toolbar_tone'), [
|
||||
{ label: '💼 ' + t('ai.professional'), action: () => action('tone', { tone: 'professional' }) },
|
||||
{ label: '💬 ' + t('ai.casual'), action: () => action('tone', { tone: 'casual' }) },
|
||||
], t('ai.tone_hint'));
|
||||
|
||||
// ── Translate menu ──
|
||||
const translateBtn = createDropdownBtn('Traduire', [
|
||||
const translateBtn = createDropdownBtn(t('ai.toolbar_translate'), [
|
||||
{ label: '🇬🇧 English', action: () => action('translate', { target_lang: 'English' }) },
|
||||
{ label: '🇨🇳 Chinese', action: () => action('translate', { target_lang: 'Chinese' }) },
|
||||
{ label: '🇯🇵 Japanese', action: () => action('translate', { target_lang: 'Japanese' }) },
|
||||
@@ -304,17 +541,18 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
], t('ai.hint_translate'));
|
||||
|
||||
// ── Generate menu ──
|
||||
const genBtn = createDropdownBtn(t('ai.generate'), [
|
||||
{ label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') },
|
||||
{ label: '📝 Summarize', action: () => action('summarize', {}, 'append') },
|
||||
{ label: '✏️ Continue writing', action: () => action('continue', {}, 'append') },
|
||||
const genBtn = createDropdownBtn(t('ai.toolbar_generate'), [
|
||||
{ label: 'ℹ️ ' + t('ai.explain'), action: () => action('explain', {}, 'append') },
|
||||
{ label: '📝 ' + t('ai.summarize'), action: () => action('summarize', {}, 'append') },
|
||||
{ label: '✏️ ' + t('ai.continue'), action: () => action('continue', {}, 'append') },
|
||||
], t('ai.hint_generate'));
|
||||
|
||||
// ── Custom Rewrite ──
|
||||
const rewriteBtn = document.createElement('button');
|
||||
rewriteBtn.className = 'ai-toolbar-btn';
|
||||
rewriteBtn.innerHTML = t('ai.rewrite');
|
||||
rewriteBtn.innerHTML = t('ai.toolbar_rewrite');
|
||||
rewriteBtn.title = t('ai.rewrite_title');
|
||||
rewriteBtn.setAttribute('aria-label', t('ai.rewrite_title'));
|
||||
Object.assign(rewriteBtn.style, {
|
||||
fontSize: '0.7rem',
|
||||
background: 'transparent',
|
||||
@@ -328,24 +566,31 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
const instruction = prompt(t('ai.rewrite_prompt'), '');
|
||||
if (!text.trim()) {
|
||||
showToast(t('ai.select_text'), 'warning');
|
||||
return;
|
||||
}
|
||||
const instruction = await _promptRewriteInstruction();
|
||||
if (!instruction) return;
|
||||
rewriteBtn.disabled = true;
|
||||
try {
|
||||
const result = await aiAction('rewrite', text, { instruction });
|
||||
replaceSelection(v, result);
|
||||
showToast(t('ai.text_rewritten'), 'success');
|
||||
} catch (e) {
|
||||
showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error');
|
||||
} finally {
|
||||
rewriteBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Toolbox menu ──
|
||||
const toolboxBtn = createDropdownBtn(t('ai.toolbox'), [
|
||||
{ label: '📋 Convert to list', action: () => action('to-list') },
|
||||
{ label: '📊 Convert to table', action: () => action('to-table') },
|
||||
{ label: '⚙️ Generate frontmatter', action: () => action('frontmatter', {}, 'before') },
|
||||
{ label: '🔷 Convert to canvas', action: () => action('to-canvas', {}, 'append') },
|
||||
], 'Outils de conversion');
|
||||
const toolboxBtn = createDropdownBtn(t('ai.toolbar_toolbox'), [
|
||||
{ label: '📋 ' + t('ai.to_list'), action: () => action('to-list') },
|
||||
{ label: '📊 ' + t('ai.to_table'), action: () => action('to-table') },
|
||||
{ label: '⚙️ ' + t('ai.frontmatter'), action: () => action('frontmatter', {}, 'before') },
|
||||
{ label: '🔷 ' + t('ai.to_canvas'), action: () => action('to-canvas', {}, 'append') },
|
||||
], t('ai.toolbox_hint'));
|
||||
|
||||
toolbar.appendChild(aiBtn);
|
||||
toolbar.appendChild(createSeparator());
|
||||
@@ -357,6 +602,13 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
toolbar.appendChild(rewriteBtn);
|
||||
toolbar.appendChild(toolboxBtn);
|
||||
|
||||
// Append the per-section provider/model picker on the right.
|
||||
const picker = await _buildPickerUI();
|
||||
if (picker) {
|
||||
toolbar.appendChild(createSeparator());
|
||||
toolbar.appendChild(picker);
|
||||
}
|
||||
|
||||
container.insertBefore(toolbar, container.firstChild);
|
||||
|
||||
// ── Action helper ──
|
||||
@@ -404,9 +656,10 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
el.id = 'ai-processing-indicator';
|
||||
el.className = 'ai-processing-indicator';
|
||||
el.innerHTML = '<span class="ai-spinner"></span> AI...';
|
||||
el.title = 'Traitement AI en cours';
|
||||
el.title = t('ai.processing');
|
||||
el.setAttribute('role', 'status');
|
||||
const header = document.querySelector('#editor-modal .editor-header');
|
||||
if (header) header.appendChild(el);
|
||||
(header || toolbar).appendChild(el);
|
||||
}
|
||||
el.style.display = 'inline-flex';
|
||||
} else if (el) {
|
||||
@@ -414,14 +667,8 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
}
|
||||
}
|
||||
// ── Keyboard shortcut Ctrl+J for inline completion ──
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'j') {
|
||||
const modal = document.getElementById('editor-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
e.preventDefault();
|
||||
aiBtn.click();
|
||||
}
|
||||
});
|
||||
_activeAiBtn = aiBtn;
|
||||
_bindAiShortcut();
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
@@ -431,6 +678,8 @@ function createDropdownBtn(text, items, tooltip = '') {
|
||||
btn.className = 'ai-toolbar-btn';
|
||||
btn.innerHTML = text + ' ▾';
|
||||
btn.title = tooltip || text;
|
||||
btn.setAttribute('aria-haspopup', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
Object.assign(btn.style, {
|
||||
position: 'relative',
|
||||
fontSize: '0.7rem',
|
||||
@@ -452,6 +701,17 @@ function createDropdownBtn(text, items, tooltip = '') {
|
||||
|
||||
function createSeparator() {
|
||||
const sep = document.createElement('span');
|
||||
sep.style.cssText = 'width:1px;height:16px;background:var(--border-color);margin:0 2px';
|
||||
sep.style.cssText = 'width:1px;height:16px;background:var(--border);margin:0 2px';
|
||||
return sep;
|
||||
}
|
||||
|
||||
// ── Public exports ────────────────────────────────────────────────────────
|
||||
// Other modules (e.g. BooksLM) can import the picker helpers to render the
|
||||
// same per-section provider/model picker in their own toolbar.
|
||||
export {
|
||||
_readPicker,
|
||||
_writePicker,
|
||||
PICKER_STORAGE_KEY,
|
||||
_buildPickerUI as buildAIPickerUI,
|
||||
_promptRewriteInstruction,
|
||||
};
|
||||
|
||||
+24
-14
@@ -5,6 +5,7 @@
|
||||
import * as UI from './ui.js';
|
||||
import * as Utils from './utils.js';
|
||||
import { initI18n, t } from './i18n.js';
|
||||
import { initAIFab } from './ai-fab.js';
|
||||
|
||||
// Wire up AI toolbar toast (avoids circular import in utils.js)
|
||||
window._obsigateShowToast = UI.showToast;
|
||||
@@ -19,6 +20,10 @@ import { initMobileToolbar } from './mobile-toolbar.js';
|
||||
import { initDragDrop } from './dragdrop.js';
|
||||
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
|
||||
import PaneManager from './pane-manager.js';
|
||||
import { initDesktopIntegration, isTauriEnv } from './desktop.js';
|
||||
import { initPush } from './push.js';
|
||||
import { initPlugins } from './plugins.js';
|
||||
import { populateVersions } from './config.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
@@ -29,6 +34,10 @@ async function init() {
|
||||
// LocalStorage has correct language if user set it previously
|
||||
await initI18n();
|
||||
|
||||
// 1b. Desktop bridge — crash banner + OS theme sync (before theme engine).
|
||||
// No-op on web (Docker); on desktop it also readies the vault picker.
|
||||
await initDesktopIntegration();
|
||||
|
||||
// 2. Init auth — now t() works inside showApp()/renderUserMenu()
|
||||
const authOk = await Auth.AuthManager.initAuth();
|
||||
|
||||
@@ -61,7 +70,9 @@ async function init() {
|
||||
UI.TabManager.init();
|
||||
PaneManager.init();
|
||||
initCommandPalette();
|
||||
initMobileToolbar();
|
||||
initMobileToolbar();
|
||||
initPush();
|
||||
initPlugins();
|
||||
|
||||
if (authOk) {
|
||||
Legacy.initSyncStatus();
|
||||
@@ -99,27 +110,26 @@ async function init() {
|
||||
initMermaid();
|
||||
setupFocusMode();
|
||||
Utils.safeCreateIcons();
|
||||
initAIFab();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
try {
|
||||
init();
|
||||
await init();
|
||||
Sync.init();
|
||||
} finally {
|
||||
// Signale au splash de démarrage (index.html #boot-splash) que l'app est
|
||||
// initialisée : le splash est retiré et le squelette stylé révélé.
|
||||
// Set AFTER init() resolves so every handler (search, tabs, …) is bound.
|
||||
window.__OBSIGATE_BOOTED = true;
|
||||
if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady();
|
||||
}
|
||||
// Show version in header
|
||||
fetch('/api/health')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const badge = document.getElementById('version-badge');
|
||||
if (badge && d.version) {
|
||||
badge.textContent = d.version;
|
||||
badge.style.display = 'inline-block';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// Show version in header — always visible, fallback before the async fetch
|
||||
populateVersions();
|
||||
fetch('/api/health')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d && d.version) populateVersions(d.version);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
+748
-31
@@ -139,10 +139,122 @@ const AuthManager = {
|
||||
throw new Error(err.detail || "Erreur de connexion");
|
||||
}
|
||||
const data = await response.json();
|
||||
// If MFA is required, return the MFA challenge instead of saving token
|
||||
if (data.mfa_required) {
|
||||
return data;
|
||||
}
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
async verifyMfa(username, code, rememberMe) {
|
||||
const response = await fetch("/api/auth/mfa/totp/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, code, remember_me: rememberMe || false }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || "Code invalide");
|
||||
}
|
||||
const data = await response.json();
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
async verifyRecovery(username, recoveryCode) {
|
||||
const response = await fetch("/api/auth/mfa/recovery", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, recovery_code: recoveryCode }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || "Code invalide");
|
||||
}
|
||||
const data = await response.json();
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
// ── WebAuthn (ROADMAP #64) ─────────────────────────────────────────
|
||||
|
||||
async webauthnLoginOptions(username) {
|
||||
const resp = await fetch("/api/auth/mfa/webauthn/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!resp.ok) throw new Error((await resp.json()).detail || "WebAuthn indisponible");
|
||||
return await resp.json();
|
||||
},
|
||||
|
||||
async verifyWebauthn(username, credential, rememberMe) {
|
||||
const response = await fetch("/api/auth/mfa/webauthn/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, credential, remember_me: rememberMe || false }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || "Vérification WebAuthn échouée");
|
||||
}
|
||||
const data = await response.json();
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
async webauthnRegisterOptions() {
|
||||
return await api("/api/auth/mfa/webauthn/register/options", { method: "POST" });
|
||||
},
|
||||
|
||||
async webauthnRegister(credential, label) {
|
||||
return await api("/api/auth/mfa/webauthn/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ credential, label }),
|
||||
});
|
||||
},
|
||||
|
||||
async webauthnCredentials() {
|
||||
return await api("/api/auth/mfa/webauthn/credentials");
|
||||
},
|
||||
|
||||
async webauthnRemove(credentialId, password) {
|
||||
return await api("/api/auth/mfa/webauthn/credentials/remove", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ credential_id: credentialId, password }),
|
||||
});
|
||||
},
|
||||
|
||||
// ── MFA Setup API calls ──────────────────────────────────────────
|
||||
|
||||
async getMfaStatus() {
|
||||
const resp = await api("/api/auth/mfa/status");
|
||||
return resp;
|
||||
},
|
||||
|
||||
async mfaSetup() {
|
||||
return await api("/api/auth/mfa/totp/setup", { method: "POST" });
|
||||
},
|
||||
|
||||
async mfaEnable(code) {
|
||||
return await api("/api/auth/mfa/totp/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
},
|
||||
|
||||
async mfaDisable(password, code) {
|
||||
return await api("/api/auth/mfa/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, code }),
|
||||
});
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
const token = this.getToken();
|
||||
@@ -216,7 +328,7 @@ const AuthManager = {
|
||||
// Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before
|
||||
adminRow.onclick = () => {
|
||||
closeHeaderMenu();
|
||||
AdminPanel.show();
|
||||
window.location.href = "/admin.html";
|
||||
};
|
||||
} else {
|
||||
adminRow.classList.add("hidden");
|
||||
@@ -278,6 +390,343 @@ const AuthManager = {
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-login setup (shared between normal login and MFA login)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function _onLoginSuccess() {
|
||||
AuthManager.showApp();
|
||||
|
||||
// Re-sync language from server now that we're authenticated
|
||||
try {
|
||||
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (resp.ok) {
|
||||
const user = await resp.json();
|
||||
if (user.language && user.language !== getLocale()) {
|
||||
await setLocale(user.language);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* non-bloquant */ }
|
||||
|
||||
// Load app data after successful login
|
||||
try {
|
||||
const { loadVaults, loadTags } = await import('./sidebar.js');
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
const { IndexUpdateManager } = await import('./sync.js');
|
||||
IndexUpdateManager.connect();
|
||||
const { syncFileIndexFromServer } = await import('./offline.js');
|
||||
syncFileIndexFromServer();
|
||||
showWelcome();
|
||||
} catch (err) {
|
||||
console.error("Failed to load data after login:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MFA Challenge UI (TOTP code input during login)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// base64url <-> ArrayBuffer helpers for WebAuthn (ROADMAP #64)
|
||||
function _b64urlToBuf(s) {
|
||||
const pad = "=".repeat((4 - (s.length % 4)) % 4);
|
||||
const b = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(b);
|
||||
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
function _bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let s = "";
|
||||
for (const c of bytes) s += String.fromCharCode(c);
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function runWebauthnCeremony(optionsJson) {
|
||||
const pkOptions = {
|
||||
challenge: _b64urlToBuf(optionsJson.challenge),
|
||||
timeout: optionsJson.timeout || 60000,
|
||||
rpId: optionsJson.rpId,
|
||||
userVerification: optionsJson.userVerification || "preferred",
|
||||
allowCredentials: (optionsJson.allowCredentials || []).map((c) => ({
|
||||
type: c.type || "public-key",
|
||||
id: _b64urlToBuf(c.id),
|
||||
transports: c.transports,
|
||||
})),
|
||||
};
|
||||
const cred = await navigator.credentials.get({ publicKey: pkOptions });
|
||||
if (!cred) throw new Error(t("mfa.webauthn_cancelled"));
|
||||
return {
|
||||
id: cred.id,
|
||||
rawId: _bufToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
clientDataJSON: _bufToB64url(cred.response.clientDataJSON),
|
||||
authenticatorData: _bufToB64url(cred.response.authenticatorData),
|
||||
signature: _bufToB64url(cred.response.signature),
|
||||
userHandle: cred.response.userHandle ? _bufToB64url(cred.response.userHandle) : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runWebauthnRegistration() {
|
||||
const resp = await AuthManager.webauthnRegisterOptions();
|
||||
const o = resp.options;
|
||||
const pkOptions = {
|
||||
rp: { id: o.rpId || o.rp.id, name: o.rp.name },
|
||||
challenge: _b64urlToBuf(o.challenge),
|
||||
user: {
|
||||
id: _b64urlToBuf(o.user.id),
|
||||
name: o.user.name,
|
||||
displayName: o.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: o.pubKeyCredParams,
|
||||
timeout: o.timeout || 60000,
|
||||
authenticatorSelection: o.authenticatorSelection || undefined,
|
||||
attestation: "none",
|
||||
};
|
||||
const cred = await navigator.credentials.create({ publicKey: pkOptions });
|
||||
if (!cred) throw new Error(t("mfa.webauthn_cancelled"));
|
||||
const credential = {
|
||||
id: cred.id,
|
||||
rawId: _bufToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
clientDataJSON: _bufToB64url(cred.response.clientDataJSON),
|
||||
attestationObject: _bufToB64url(cred.response.attestationObject),
|
||||
},
|
||||
};
|
||||
if (cred.response.getTransports) {
|
||||
credential.response.transports = Array.from(cred.response.getTransports());
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
function _startWebauthnLogin(mfaSection, username, rememberMe) {
|
||||
const btn = mfaSection.querySelector("#mfa-webauthn-btn");
|
||||
const errorEl = mfaSection.querySelector("#mfa-error");
|
||||
if (btn) btn.disabled = true;
|
||||
if (errorEl) errorEl.classList.add("hidden");
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await AuthManager.webauthnLoginOptions(username);
|
||||
if (resp.mfa_method !== "webauthn" || !resp.options) {
|
||||
throw new Error(t("mfa.webauthn_no_key"));
|
||||
}
|
||||
const credential = await runWebauthnCeremony(resp.options);
|
||||
await AuthManager.verifyWebauthn(username, credential, rememberMe);
|
||||
mfaSection.classList.add("hidden");
|
||||
const loginForm = document.getElementById("login-form");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
await _onLoginSuccess();
|
||||
} catch (err) {
|
||||
if (errorEl) {
|
||||
errorEl.textContent = err.message || String(err);
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) {
|
||||
const loginBox = document.querySelector(".login-box");
|
||||
if (!loginBox) return;
|
||||
|
||||
// Hide the normal login form
|
||||
const loginForm = document.getElementById("login-form");
|
||||
if (loginForm) loginForm.classList.add("hidden");
|
||||
|
||||
// Create MFA challenge UI
|
||||
let mfaSection = document.getElementById("mfa-challenge");
|
||||
if (!mfaSection) {
|
||||
mfaSection = document.createElement("div");
|
||||
mfaSection.id = "mfa-challenge";
|
||||
mfaSection.className = "mfa-challenge";
|
||||
loginBox.appendChild(mfaSection);
|
||||
}
|
||||
|
||||
// WebAuthn second factor: key prompt instead of TOTP code input
|
||||
if (mfaMethod === "webauthn") {
|
||||
mfaSection.innerHTML = `
|
||||
<div class="mfa-icon">🔑</div>
|
||||
<h3>${t('mfa.title')}</h3>
|
||||
<p class="mfa-subtitle">${t('mfa.webauthn_prompt')}</p>
|
||||
<p class="mfa-error hidden" id="mfa-error"></p>
|
||||
<button type="button" class="btn-login" id="mfa-webauthn-btn">
|
||||
<span class="btn-text">${t('mfa.webauthn_btn')}</span>
|
||||
</button>
|
||||
<div class="mfa-actions">
|
||||
<button type="button" class="mfa-link-btn" id="mfa-use-recovery">${t('mfa.use_recovery')}</button>
|
||||
<button type="button" class="mfa-link-btn" id="mfa-back-login">${t('mfa.back_to_login')}</button>
|
||||
</div>
|
||||
`;
|
||||
mfaSection.classList.remove("hidden");
|
||||
document.getElementById("mfa-webauthn-btn").addEventListener("click", () => {
|
||||
_startWebauthnLogin(mfaSection, username, rememberMe);
|
||||
});
|
||||
document.getElementById("mfa-use-recovery").addEventListener("click", () => {
|
||||
showRecoveryChallenge(username, rememberMe, loginForm, mfaSection);
|
||||
});
|
||||
document.getElementById("mfa-back-login").addEventListener("click", () => {
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
});
|
||||
// Auto-start the ceremony — the browser shows its own dialog
|
||||
_startWebauthnLogin(mfaSection, username, rememberMe);
|
||||
return;
|
||||
}
|
||||
|
||||
mfaSection.innerHTML = `
|
||||
<div class="mfa-icon">🔐</div>
|
||||
<h3>${t('mfa.title')}</h3>
|
||||
<p class="mfa-subtitle">${t('mfa.subtitle')}</p>
|
||||
<form id="mfa-form">
|
||||
<div class="mfa-code-input-group">
|
||||
<input type="text" id="mfa-code" class="mfa-code-input" maxlength="6"
|
||||
pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code"
|
||||
placeholder="000000" autofocus required>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="mfa-error"></p>
|
||||
<button type="submit" class="btn-login" id="mfa-verify-btn">
|
||||
<span class="btn-text">${t('mfa.verify')}</span>
|
||||
<span class="btn-spinner hidden">⏳</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="mfa-actions">
|
||||
<button type="button" class="mfa-link-btn" id="mfa-use-recovery">${t('mfa.use_recovery')}</button>
|
||||
<button type="button" class="mfa-link-btn" id="mfa-back-login">${t('mfa.back_to_login')}</button>
|
||||
</div>
|
||||
`;
|
||||
mfaSection.classList.remove("hidden");
|
||||
|
||||
const codeInput = document.getElementById("mfa-code");
|
||||
codeInput.focus();
|
||||
|
||||
// Auto-submit when 6 digits entered
|
||||
codeInput.addEventListener("input", () => {
|
||||
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
|
||||
if (codeInput.value.length === 6) {
|
||||
document.getElementById("mfa-verify-btn").click();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle MFA form submit
|
||||
document.getElementById("mfa-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const code = codeInput.value.trim();
|
||||
if (code.length !== 6) return;
|
||||
|
||||
const btn = document.getElementById("mfa-verify-btn");
|
||||
const errorEl = document.getElementById("mfa-error");
|
||||
btn.disabled = true;
|
||||
btn.querySelector(".btn-spinner").classList.remove("hidden");
|
||||
btn.querySelector(".btn-text").textContent = t('mfa.verifying');
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.verifyMfa(username, code, rememberMe);
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
await _onLoginSuccess();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
codeInput.value = "";
|
||||
codeInput.focus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.querySelector(".btn-spinner").classList.add("hidden");
|
||||
btn.querySelector(".btn-text").textContent = t('mfa.verify');
|
||||
}
|
||||
});
|
||||
|
||||
// Switch to recovery code input
|
||||
document.getElementById("mfa-use-recovery").addEventListener("click", () => {
|
||||
showRecoveryChallenge(username, rememberMe, loginForm, mfaSection);
|
||||
});
|
||||
|
||||
// Back to login
|
||||
document.getElementById("mfa-back-login").addEventListener("click", () => {
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function showRecoveryChallenge(username, rememberMe, loginForm, mfaSection) {
|
||||
mfaSection.innerHTML = `
|
||||
<div class="mfa-icon">🔑</div>
|
||||
<h3>${t('mfa.recovery_title')}</h3>
|
||||
<p class="mfa-subtitle">${t('mfa.recovery_subtitle')}</p>
|
||||
<form id="recovery-form">
|
||||
<div class="mfa-code-input-group">
|
||||
<input type="text" id="recovery-code" class="mfa-code-input recovery-input" maxlength="9"
|
||||
autocomplete="off" placeholder="XXXX-XXXX" autofocus required>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="recovery-error"></p>
|
||||
<button type="submit" class="btn-login" id="recovery-verify-btn">
|
||||
<span class="btn-text">${t('mfa.verify')}</span>
|
||||
<span class="btn-spinner hidden">⏳</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="mfa-actions">
|
||||
<button type="button" class="mfa-link-btn" id="recovery-use-totp">${t('mfa.use_totp')}</button>
|
||||
<button type="button" class="mfa-link-btn" id="recovery-back-login">${t('mfa.back_to_login')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const codeInput = document.getElementById("recovery-code");
|
||||
codeInput.focus();
|
||||
|
||||
// Auto-format: insert dash after 4 chars
|
||||
codeInput.addEventListener("input", () => {
|
||||
let v = codeInput.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
|
||||
if (v.length > 4) v = v.slice(0, 4) + "-" + v.slice(4, 8);
|
||||
codeInput.value = v;
|
||||
});
|
||||
|
||||
document.getElementById("recovery-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const code = codeInput.value.trim();
|
||||
const btn = document.getElementById("recovery-verify-btn");
|
||||
const errorEl = document.getElementById("recovery-error");
|
||||
btn.disabled = true;
|
||||
btn.querySelector(".btn-spinner").classList.remove("hidden");
|
||||
btn.querySelector(".btn-text").textContent = t('mfa.verifying');
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.verifyRecovery(username, code);
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
await _onLoginSuccess();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
codeInput.value = "";
|
||||
codeInput.focus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.querySelector(".btn-spinner").classList.add("hidden");
|
||||
btn.querySelector(".btn-text").textContent = t('mfa.verify');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("recovery-use-totp").addEventListener("click", () => {
|
||||
showMfaChallenge(username, rememberMe, null, null);
|
||||
});
|
||||
|
||||
document.getElementById("recovery-back-login").addEventListener("click", () => {
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login form handler
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -300,36 +749,14 @@ function initLoginForm() {
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.login(username, password, rememberMe);
|
||||
AuthManager.showApp();
|
||||
|
||||
// Re-sync language from server now that we're authenticated
|
||||
try {
|
||||
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (resp.ok) {
|
||||
const user = await resp.json();
|
||||
if (user.language && user.language !== getLocale()) {
|
||||
await setLocale(user.language);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* non-bloquant */ }
|
||||
|
||||
// Load app data after successful login
|
||||
try {
|
||||
// Dynamic imports to avoid circular dependency with sidebar.js
|
||||
const { loadVaults, loadTags } = await import('./sidebar.js');
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
// Start SSE sync now that auth cookie is set (dynamic import to avoid circular dep)
|
||||
const { IndexUpdateManager } = await import('./sync.js');
|
||||
IndexUpdateManager.connect();
|
||||
// Sync offline file index now that we're authenticated
|
||||
const { syncFileIndexFromServer } = await import('./offline.js');
|
||||
syncFileIndexFromServer();
|
||||
// Show dashboard
|
||||
showWelcome();
|
||||
} catch (err) {
|
||||
console.error("Failed to load data after login:", err);
|
||||
const result = await AuthManager.login(username, password, rememberMe);
|
||||
// Check if MFA is required
|
||||
if (result && result.mfa_required) {
|
||||
showMfaChallenge(result.username, rememberMe, btn, errorEl, result.mfa_method);
|
||||
return;
|
||||
}
|
||||
// Normal login success
|
||||
await _onLoginSuccess();
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
@@ -576,4 +1003,294 @@ const AdminPanel = {
|
||||
};
|
||||
|
||||
|
||||
export { api, AuthManager, initLoginForm, AdminPanel };
|
||||
// ---------------------------------------------------------------------------
|
||||
// MFA Settings — Setup/Disable UI in the settings panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function initMfaSettings() {
|
||||
const area = document.getElementById("mfa-setup-area");
|
||||
const badge = document.getElementById("mfa-status-badge");
|
||||
if (!area || !badge) return;
|
||||
|
||||
// Check current MFA status
|
||||
let mfaEnabled = false;
|
||||
try {
|
||||
const status = await AuthManager.getMfaStatus();
|
||||
mfaEnabled = status.mfa_enabled;
|
||||
} catch (e) {
|
||||
// Not logged in or error
|
||||
return;
|
||||
}
|
||||
|
||||
if (mfaEnabled) {
|
||||
badge.textContent = t("mfa.enabled");
|
||||
badge.className = "mfa-badge mfa-badge-on";
|
||||
area.innerHTML = `
|
||||
<p class="mfa-info-text" data-i18n="mfa.enabled_desc">${t("mfa.enabled_desc")}</p>
|
||||
<button class="config-btn-secondary" id="mfa-disable-btn">${t("mfa.disable_btn")}</button>
|
||||
<div id="mfa-disable-form-area"></div>
|
||||
`;
|
||||
document.getElementById("mfa-disable-btn").addEventListener("click", () => {
|
||||
_showDisableMfaForm();
|
||||
});
|
||||
} else {
|
||||
badge.textContent = t("mfa.disabled");
|
||||
badge.className = "mfa-badge mfa-badge-off";
|
||||
area.innerHTML = `
|
||||
<p class="mfa-info-text" data-i18n="mfa.setup_desc">${t("mfa.setup_desc")}</p>
|
||||
<button class="config-btn-primary" id="mfa-enable-btn">${t("mfa.enable_btn")}</button>
|
||||
<div id="mfa-setup-flow-area"></div>
|
||||
`;
|
||||
document.getElementById("mfa-enable-btn").addEventListener("click", () => {
|
||||
_startMfaSetup();
|
||||
});
|
||||
}
|
||||
|
||||
// WebAuthn security keys section (ROADMAP #64)
|
||||
_renderWebauthnSection(area);
|
||||
}
|
||||
|
||||
|
||||
async function _renderWebauthnSection(container) {
|
||||
if (!container || !window.PublicKeyCredential) return;
|
||||
|
||||
let keys = [];
|
||||
try {
|
||||
const resp = await AuthManager.webauthnCredentials();
|
||||
keys = resp.credentials || [];
|
||||
} catch (e) {
|
||||
return; // auth disabled or endpoint unreachable — hide section
|
||||
}
|
||||
|
||||
let section = document.getElementById("webauthn-settings");
|
||||
if (!section) {
|
||||
section = document.createElement("div");
|
||||
section.id = "webauthn-settings";
|
||||
section.className = "webauthn-settings";
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
const listHtml = keys.length
|
||||
? `<ul class="webauthn-key-list">${keys.map((k) => `
|
||||
<li class="webauthn-key-item">
|
||||
<span class="webauthn-key-label">🔑 ${k.label || "Security key"}</span>
|
||||
<span class="webauthn-key-meta">${(k.transports || []).join(", ") || "—"}</span>
|
||||
<button class="config-btn-secondary config-btn-sm webauthn-key-remove"
|
||||
data-id="${k.credential_id}">${t("mfa.webauthn_remove")}</button>
|
||||
</li>`).join("")}</ul>`
|
||||
: `<p class="mfa-info-text">${t("mfa.webauthn_none")}</p>`;
|
||||
|
||||
section.innerHTML = `
|
||||
<h4 class="webauthn-title">${t("mfa.webauthn_title")}</h4>
|
||||
<p class="mfa-info-text">${t("mfa.webauthn_desc")}</p>
|
||||
${listHtml}
|
||||
<div class="mfa-recovery-actions">
|
||||
<button class="config-btn-primary" id="webauthn-add-btn">${t("mfa.webauthn_add")}</button>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="webauthn-error"></p>
|
||||
<div id="webauthn-flow-area"></div>
|
||||
`;
|
||||
|
||||
const errEl = section.querySelector("#webauthn-error");
|
||||
document.getElementById("webauthn-add-btn").addEventListener("click", async () => {
|
||||
errEl.classList.add("hidden");
|
||||
try {
|
||||
const credential = await runWebauthnRegistration();
|
||||
const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé");
|
||||
const result = await AuthManager.webauthnRegister(credential, label || "Security key");
|
||||
if (result.recovery_codes && result.recovery_codes.length) {
|
||||
_showRecoveryCodes(result.recovery_codes);
|
||||
} else {
|
||||
showToast(t("mfa.webauthn_added"), "success");
|
||||
}
|
||||
initMfaSettings();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || String(err);
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
section.querySelectorAll(".webauthn-key-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const password = prompt(t("mfa.webauthn_remove_confirm"));
|
||||
if (password === null) return;
|
||||
try {
|
||||
await AuthManager.webauthnRemove(btn.dataset.id, password);
|
||||
showToast(t("mfa.webauthn_removed"), "success");
|
||||
initMfaSettings();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || String(err);
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function _startMfaSetup() {
|
||||
const flowArea = document.getElementById("mfa-setup-flow-area");
|
||||
if (!flowArea) return;
|
||||
|
||||
try {
|
||||
const data = await AuthManager.mfaSetup();
|
||||
flowArea.innerHTML = `
|
||||
<div class="mfa-setup-card">
|
||||
<h4>${t("mfa.scan_qr")}</h4>
|
||||
<div class="mfa-qr-container">
|
||||
<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code"
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.otpauth_uri)}">
|
||||
</div>
|
||||
<details class="mfa-secret-details">
|
||||
<summary>${t("mfa.manual_entry")}</summary>
|
||||
<code class="mfa-secret-code">${data.secret}</code>
|
||||
</details>
|
||||
<div class="mfa-verify-section">
|
||||
<label>${t("mfa.enter_code")}</label>
|
||||
<input type="text" id="mfa-enable-code" class="mfa-code-input" maxlength="6"
|
||||
pattern="[0-9]{6}" inputmode="numeric" placeholder="000000" autocomplete="one-time-code">
|
||||
<button class="config-btn-primary" id="mfa-confirm-btn">${t("mfa.confirm_enable")}</button>
|
||||
<p class="mfa-error hidden" id="mfa-enable-error"></p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const codeInput = document.getElementById("mfa-enable-code");
|
||||
codeInput.addEventListener("input", () => {
|
||||
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
|
||||
});
|
||||
|
||||
document.getElementById("mfa-confirm-btn").addEventListener("click", async () => {
|
||||
const code = codeInput.value.trim();
|
||||
if (code.length !== 6) return;
|
||||
|
||||
const btn = document.getElementById("mfa-confirm-btn");
|
||||
const errorEl = document.getElementById("mfa-enable-error");
|
||||
btn.disabled = true;
|
||||
btn.textContent = t("mfa.verifying");
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const result = await AuthManager.mfaEnable(code);
|
||||
// Show recovery codes
|
||||
_showRecoveryCodes(result.recovery_codes);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
codeInput.value = "";
|
||||
codeInput.focus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("mfa.confirm_enable");
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
flowArea.innerHTML = `<p class="mfa-error">${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function _showRecoveryCodes(codes) {
|
||||
const flowArea = document.getElementById("mfa-setup-flow-area");
|
||||
const area = document.getElementById("mfa-setup-area");
|
||||
if (!flowArea) return;
|
||||
|
||||
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${c}</code>`).join("\n");
|
||||
flowArea.innerHTML = `
|
||||
<div class="mfa-recovery-card">
|
||||
<h4>🔑 ${t("mfa.recovery_codes_title")}</h4>
|
||||
<p class="mfa-warning">${t("mfa.recovery_codes_warning")}</p>
|
||||
<div class="mfa-recovery-list" id="mfa-recovery-list">
|
||||
${codesHtml}
|
||||
</div>
|
||||
<div class="mfa-recovery-actions">
|
||||
<button class="config-btn-secondary" id="mfa-copy-codes">${t("mfa.copy_codes")}</button>
|
||||
<button class="config-btn-secondary" id="mfa-download-codes">${t("mfa.download_codes")}</button>
|
||||
<button class="config-btn-primary" id="mfa-codes-done">${t("mfa.done")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("mfa-copy-codes").addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(codes.join("\n")).then(() => {
|
||||
showToast(t("mfa.codes_copied"), "success");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("mfa-download-codes").addEventListener("click", () => {
|
||||
const blob = new Blob([codes.join("\n")], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "obsigate-recovery-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
document.getElementById("mfa-codes-done").addEventListener("click", () => {
|
||||
// Refresh MFA settings display
|
||||
initMfaSettings();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function _showDisableMfaForm() {
|
||||
const formArea = document.getElementById("mfa-disable-form-area");
|
||||
if (!formArea) return;
|
||||
|
||||
formArea.innerHTML = `
|
||||
<div class="mfa-disable-card">
|
||||
<h4>${t("mfa.disable_confirm_title")}</h4>
|
||||
<p>${t("mfa.disable_confirm_desc")}</p>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.password_label")}</label>
|
||||
<input type="password" id="mfa-disable-password" class="config-input" placeholder="${t('mfa.password_placeholder')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.totp_code_label")}</label>
|
||||
<input type="text" id="mfa-disable-code" class="mfa-code-input" maxlength="6"
|
||||
pattern="[0-9]{6}" inputmode="numeric" placeholder="000000">
|
||||
</div>
|
||||
<div class="mfa-disable-actions">
|
||||
<button class="config-btn-secondary" id="mfa-disable-cancel">${t("common.cancel")}</button>
|
||||
<button class="config-btn-danger" id="mfa-disable-confirm">${t("mfa.disable_confirm_btn")}</button>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="mfa-disable-error"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("mfa-disable-cancel").addEventListener("click", () => {
|
||||
formArea.innerHTML = "";
|
||||
});
|
||||
|
||||
document.getElementById("mfa-disable-confirm").addEventListener("click", async () => {
|
||||
const password = document.getElementById("mfa-disable-password").value;
|
||||
const code = document.getElementById("mfa-disable-code").value.trim();
|
||||
const errorEl = document.getElementById("mfa-disable-error");
|
||||
const btn = document.getElementById("mfa-disable-confirm");
|
||||
|
||||
if (!password || code.length !== 6) {
|
||||
errorEl.textContent = t("mfa.fill_all_fields");
|
||||
errorEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = t("mfa.verifying");
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
try {
|
||||
await AuthManager.mfaDisable(password, code);
|
||||
showToast(t("mfa.disabled_success"), "success");
|
||||
initMfaSettings();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("mfa.disable_confirm_btn");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export { api, AuthManager, initLoginForm, AdminPanel, initMfaSettings };
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
// ObsiGate — AI Assistant sidebar.
|
||||
//
|
||||
// The panel adapts to three contexts:
|
||||
// • directory — a vault folder (opened from the tree context menu)
|
||||
// • documents — the file(s) currently open in tabs/panes
|
||||
// • general — no document open: app help + file/directory creation actions
|
||||
import { t } from './i18n.js';
|
||||
import { buildAIPickerUI } from './ai.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
|
||||
export const MODE = Object.freeze({
|
||||
DIRECTORY: 'directory',
|
||||
DOCUMENTS: 'documents',
|
||||
GENERAL: 'general',
|
||||
});
|
||||
|
||||
/**
|
||||
* Gather the files currently open across the singleton TabManager and every
|
||||
* split-view pane (window globals avoid a circular import with ui.js).
|
||||
* The active tab of each pane is listed first.
|
||||
*/
|
||||
export function collectOpenDocuments() {
|
||||
const docs = [];
|
||||
const seen = new Set();
|
||||
const add = (vault, path) => {
|
||||
if (!vault || !path) return;
|
||||
const key = `${vault}\u0000${path}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
docs.push({ vault, path });
|
||||
};
|
||||
const scan = (tm) => {
|
||||
if (!tm || !Array.isArray(tm._tabs)) return;
|
||||
const active = tm._tabs.find((x) => x.id === tm._activeTabId);
|
||||
if (active) add(active.vault, active.path);
|
||||
for (const tab of tm._tabs) add(tab.vault, tab.path);
|
||||
};
|
||||
try {
|
||||
scan(window.TabManager);
|
||||
const pm = window.PaneManager;
|
||||
if (pm && pm.tabManagers) Object.values(pm.tabManagers).forEach(scan);
|
||||
} catch { /* not ready yet */ }
|
||||
return docs;
|
||||
}
|
||||
|
||||
export function documentsSignature(documents) {
|
||||
return (documents || []).map((d) => `${d.vault}/${d.path}`).join('\u0000');
|
||||
}
|
||||
|
||||
class BooksLM {
|
||||
constructor() {
|
||||
this._isOpen = false;
|
||||
this._mode = MODE.GENERAL;
|
||||
this._vault = null;
|
||||
this._directory = null;
|
||||
this._documents = [];
|
||||
this._effectiveScope = MODE.GENERAL;
|
||||
this._messages = [];
|
||||
this._contextFiles = [];
|
||||
this._isLoading = false;
|
||||
this._abortCtrl = null;
|
||||
this._panel = null;
|
||||
this._isFullscreen = false;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the assistant for a specific context.
|
||||
* @param {object} opts
|
||||
* @param {'directory'|'documents'|'general'} opts.mode
|
||||
* @param {string|null} [opts.vault]
|
||||
* @param {string} [opts.directory]
|
||||
* @param {Array<{vault:string,path:string}>} [opts.documents]
|
||||
*/
|
||||
async openContext(opts = {}) {
|
||||
const mode = opts.mode || MODE.GENERAL;
|
||||
const vault = opts.vault || null;
|
||||
const directory = opts.directory || '';
|
||||
const documents = Array.isArray(opts.documents) ? opts.documents : [];
|
||||
|
||||
const sameContext =
|
||||
this._isOpen &&
|
||||
this._mode === mode &&
|
||||
this._vault === vault &&
|
||||
this._directory === directory &&
|
||||
documentsSignature(this._documents) === documentsSignature(documents);
|
||||
|
||||
if (sameContext) {
|
||||
// Already open for this context: reveal a collapsed panel, else close.
|
||||
if (this._panel && this._panel.classList.contains('hidden')) {
|
||||
this._setHidden(false);
|
||||
document.dispatchEvent(new CustomEvent('bookslm:opened'));
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._mode = mode;
|
||||
this._vault = vault;
|
||||
this._directory = directory;
|
||||
this._documents = documents;
|
||||
this._effectiveScope = mode;
|
||||
this._messages = [];
|
||||
this._contextFiles = [];
|
||||
this._isLoading = true;
|
||||
this._loadHistory();
|
||||
|
||||
if (!this._panel) {
|
||||
this._panel = this._render();
|
||||
document.body.appendChild(this._panel);
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
// An explicit open always reveals the panel, even if it was collapsed
|
||||
// in a previous session (previously it stayed hidden → "ne s'ouvre plus").
|
||||
this._setHidden(false);
|
||||
this._updateHeader();
|
||||
this._updateStatus();
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this._panel.classList.add('open');
|
||||
document.dispatchEvent(new CustomEvent('bookslm:opened', {
|
||||
detail: { mode, vault, directory, documents },
|
||||
}));
|
||||
});
|
||||
this._isOpen = true;
|
||||
|
||||
await this._loadContext();
|
||||
this._isLoading = false;
|
||||
}
|
||||
|
||||
/** Backwards-compatible helper: open the assistant on a directory. */
|
||||
async open(vault, directory) {
|
||||
return this.openContext({ mode: MODE.DIRECTORY, vault, directory });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the assistant using the current application context: the open
|
||||
* document(s) if any, otherwise the General assistant.
|
||||
*/
|
||||
async openForCurrentContext() {
|
||||
const docs = collectOpenDocuments();
|
||||
if (docs.length) {
|
||||
const vault = docs[0].vault;
|
||||
const scoped = docs.filter((d) => d.vault === vault);
|
||||
return this.openContext({ mode: MODE.DOCUMENTS, vault, documents: scoped });
|
||||
}
|
||||
return this.openContext({ mode: MODE.GENERAL });
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this._panel || !this._isOpen) return;
|
||||
this._panel.classList.remove('open');
|
||||
this._isOpen = false;
|
||||
document.dispatchEvent(new CustomEvent('bookslm:closed'));
|
||||
if (this._abortCtrl) {
|
||||
this._abortCtrl.abort();
|
||||
this._abortCtrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
_setHidden(hidden) {
|
||||
if (!this._panel) return;
|
||||
this._panel.classList.toggle('hidden', hidden);
|
||||
try {
|
||||
localStorage.setItem('obsigate-bookslm-hidden', hidden ? 'true' : 'false');
|
||||
} catch { /* */ }
|
||||
this._updateToggleIcon();
|
||||
}
|
||||
|
||||
/** Collapse/expand while keeping the panel mounted (header toggle button). */
|
||||
_toggleSidebar() {
|
||||
if (!this._panel) return;
|
||||
const isHidden = !this._panel.classList.contains('hidden');
|
||||
this._setHidden(isHidden);
|
||||
document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened'));
|
||||
}
|
||||
|
||||
_updateToggleIcon() {
|
||||
if (!this._panel) return;
|
||||
const btn = this._panel.querySelector('.bookslm-btn-toggle');
|
||||
if (!btn) return;
|
||||
const isHidden = this._panel.classList.contains('hidden');
|
||||
const icon = btn.querySelector('i[data-lucide]');
|
||||
if (!icon) return;
|
||||
icon.setAttribute('data-lucide', isHidden ? 'panel-right-open' : 'panel-right-close');
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
newConversation() {
|
||||
this._messages = [];
|
||||
this._saveHistory();
|
||||
if (this._panel) {
|
||||
this._renderMessages();
|
||||
this._showSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
exportConversation() {
|
||||
if (!this._messages.length) return;
|
||||
const label = this._contextTitle() || 'assistant';
|
||||
let md = `# ${label}\n\n`;
|
||||
for (const msg of this._messages) {
|
||||
const role = msg.role === 'user' ? '**You**' : '**AI**';
|
||||
md += `### ${role}\n\n${msg.content}\n\n---\n\n`;
|
||||
}
|
||||
const blob = new Blob([md], { type: 'text/markdown' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `obsigate-assistant-${label.replace(/[^\w.-]+/g, '_')}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
openFile(path) {
|
||||
window.dispatchEvent(new CustomEvent('obsigate:open-file', {
|
||||
detail: { vault: this._vault, path },
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Context ─────────────────────────────────────────────────────────
|
||||
|
||||
async _loadContext() {
|
||||
if (this._mode === MODE.GENERAL) {
|
||||
this._effectiveScope = MODE.GENERAL;
|
||||
this._contextFiles = [];
|
||||
this._updateStatus({ files: [], total_chars: 0, file_count: 0, scope: MODE.GENERAL, max_total_chars: 200000 });
|
||||
this._showSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
mode: this._mode,
|
||||
vault: this._vault,
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||||
};
|
||||
try {
|
||||
const data = await api('/api/ai/bookslm/context', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
this._contextFiles = data.files || [];
|
||||
this._effectiveScope = data.scope || this._mode;
|
||||
this._updateStatus(data);
|
||||
this._showSuggestions();
|
||||
} catch (e) {
|
||||
console.warn('AI assistant: context load failed', e);
|
||||
this._contextFiles = [];
|
||||
this._updateStatus({ error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
_contextMeta() {
|
||||
if (this._mode === MODE.DOCUMENTS) {
|
||||
const names = this._documents.map((d) => (d.path || '').split('/').pop()).filter(Boolean);
|
||||
return {
|
||||
icon: 'file-text',
|
||||
title: t('ai.context_documents', { count: this._documents.length }),
|
||||
subtitle: names.slice(0, 3).join(', ') + (names.length > 3 ? '…' : ''),
|
||||
};
|
||||
}
|
||||
if (this._mode === MODE.DIRECTORY) {
|
||||
const name = this._directory ? this._directory.split('/').filter(Boolean).pop() : this._vault;
|
||||
return {
|
||||
icon: 'folder',
|
||||
title: name || this._vault || t('bookslm.title'),
|
||||
subtitle: `${this._vault}${this._directory ? '/' + this._directory : ''}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: 'sparkles',
|
||||
title: t('ai.context_general'),
|
||||
subtitle: t('ai.context_general_desc'),
|
||||
};
|
||||
}
|
||||
|
||||
_contextTitle() {
|
||||
return this._contextMeta().title;
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
_render() {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'bookslm-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="bookslm-header">
|
||||
<button class="bookslm-btn-toggle" title="${t('bookslm.toggle_sidebar')}" aria-label="${t('bookslm.toggle_sidebar')}">
|
||||
<i data-lucide="panel-right-close" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
<span class="bookslm-context-icon"><i data-lucide="sparkles" style="width:16px;height:16px"></i></span>
|
||||
<div class="bookslm-header-titles">
|
||||
<span class="bookslm-title"></span>
|
||||
<span class="bookslm-subtitle"></span>
|
||||
</div>
|
||||
<div class="bookslm-header-actions">
|
||||
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-fullscreen" title="${t('ai.fullscreen')}" aria-label="${t('ai.fullscreen')}"><i data-lucide="maximize-2" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-close" title="${t('ai.close')}" aria-label="${t('ai.close')}"><i data-lucide="x" style="width:16px;height:16px"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bookslm-toolbar">
|
||||
<span class="bookslm-toolbar-label">${t('ai.model_settings')}</span>
|
||||
<span class="bookslm-picker-host"></span>
|
||||
</div>
|
||||
<div class="bookslm-status"></div>
|
||||
<div class="bookslm-suggestions"></div>
|
||||
<div class="bookslm-messages"></div>
|
||||
<div class="bookslm-input-area">
|
||||
<textarea placeholder="${t('bookslm.placeholder')}" rows="1" aria-label="${t('bookslm.placeholder')}"></textarea>
|
||||
<button class="bookslm-btn-send" title="${t('bookslm.send')}">${t('bookslm.send')}</button>
|
||||
</div>
|
||||
<div class="bookslm-input-hint">${t('ai.input_hint')}</div>
|
||||
`;
|
||||
|
||||
// Provider/model picker lives on its own toolbar row so the header
|
||||
// actions stay visible regardless of the provider/model text width.
|
||||
const pickerHost = panel.querySelector('.bookslm-picker-host');
|
||||
buildAIPickerUI().then((picker) => {
|
||||
if (!picker || !pickerHost) return;
|
||||
pickerHost.replaceWith(picker);
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}).catch(() => { /* ignore */ });
|
||||
|
||||
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
|
||||
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
|
||||
panel.querySelector('.bookslm-btn-export').addEventListener('click', () => this.exportConversation());
|
||||
panel.querySelector('.bookslm-btn-toggle').addEventListener('click', () => this._toggleSidebar());
|
||||
panel.querySelector('.bookslm-btn-fullscreen').addEventListener('click', () => {
|
||||
this._isFullscreen = !this._isFullscreen;
|
||||
panel.classList.toggle('fullscreen', this._isFullscreen);
|
||||
const icon = panel.querySelector('.bookslm-btn-fullscreen i');
|
||||
if (icon) {
|
||||
icon.setAttribute('data-lucide', this._isFullscreen ? 'minimize-2' : 'maximize-2');
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
});
|
||||
|
||||
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
|
||||
|
||||
const textarea = panel.querySelector('textarea');
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
|
||||
});
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Ctrl/Cmd+Enter → insert a newline at the caret.
|
||||
e.preventDefault();
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
textarea.value = textarea.value.slice(0, start) + '\n' + textarea.value.slice(end);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 1;
|
||||
textarea.dispatchEvent(new Event('input'));
|
||||
return;
|
||||
}
|
||||
// Enter (with or without Shift) → submit. Shift+Enter also submits so
|
||||
// the documented shortcut (Ctrl+Enter) is the only way to add a line.
|
||||
e.preventDefault();
|
||||
this._sendMessage();
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
_updateHeader() {
|
||||
if (!this._panel) return;
|
||||
const meta = this._contextMeta();
|
||||
const title = this._panel.querySelector('.bookslm-title');
|
||||
const subtitle = this._panel.querySelector('.bookslm-subtitle');
|
||||
const icon = this._panel.querySelector('.bookslm-context-icon i');
|
||||
if (title) title.textContent = meta.title;
|
||||
if (subtitle) subtitle.textContent = meta.subtitle || '';
|
||||
if (icon) icon.setAttribute('data-lucide', meta.icon);
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
_updateStatus(data) {
|
||||
if (!this._panel) return;
|
||||
const status = this._panel.querySelector('.bookslm-status');
|
||||
if (!status) return;
|
||||
|
||||
if (data && data.error) {
|
||||
status.innerHTML = `<span class="bookslm-status-error">⚠ ${t('bookslm.no_context')}</span>`;
|
||||
return;
|
||||
}
|
||||
if (data && Array.isArray(data.files)) {
|
||||
const count = data.files.length;
|
||||
const chars = data.total_chars || 0;
|
||||
const maxChars = data.max_total_chars || 200000;
|
||||
const pct = Math.min(100, Math.round((chars / maxChars) * 100));
|
||||
const label = data.scope === MODE.GENERAL
|
||||
? t('ai.context_general_desc')
|
||||
: `${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}`;
|
||||
status.innerHTML = `
|
||||
<span class="bookslm-status-text">${label}</span>
|
||||
<div class="bookslm-context-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
|
||||
`;
|
||||
} else if (this._isLoading) {
|
||||
status.textContent = '⏳ …';
|
||||
}
|
||||
}
|
||||
|
||||
_suggestionsForContext() {
|
||||
if (this._mode === MODE.GENERAL) {
|
||||
return [
|
||||
t('ai.suggestion_capabilities'),
|
||||
t('ai.suggestion_search'),
|
||||
t('ai.suggestion_create_file'),
|
||||
];
|
||||
}
|
||||
if (this._mode === MODE.DOCUMENTS) {
|
||||
return [
|
||||
t('ai.suggestion_docs_summary'),
|
||||
t('ai.suggestion_docs_keypoints'),
|
||||
t('ai.suggestion_docs_contradictions'),
|
||||
];
|
||||
}
|
||||
return [
|
||||
t('bookslm.suggestion_summary'),
|
||||
t('bookslm.suggestion_themes'),
|
||||
t('bookslm.suggestion_contradictions'),
|
||||
];
|
||||
}
|
||||
|
||||
_showSuggestions() {
|
||||
if (!this._panel) return;
|
||||
const sugEl = this._panel.querySelector('.bookslm-suggestions');
|
||||
if (!sugEl) return;
|
||||
sugEl.innerHTML = '';
|
||||
if (this._messages.length) return;
|
||||
|
||||
for (const s of this._suggestionsForContext()) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'bookslm-suggestion';
|
||||
btn.textContent = s;
|
||||
btn.addEventListener('click', () => {
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
if (textarea) {
|
||||
textarea.value = s;
|
||||
this._sendMessage();
|
||||
}
|
||||
});
|
||||
sugEl.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
_renderMessages() {
|
||||
if (!this._panel) return;
|
||||
const container = this._panel.querySelector('.bookslm-messages');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const msg of this._messages) {
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = `bookslm-bubble ${msg.role}`;
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
const { text, actions } = this._extractActions(msg.content || '');
|
||||
bubble.innerHTML = this._renderMarkdown(text);
|
||||
if (actions.length) {
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'bookslm-actions';
|
||||
actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
|
||||
bubble.appendChild(actionsDiv);
|
||||
}
|
||||
if (msg.sources && msg.sources.length) {
|
||||
const sourcesDiv = document.createElement('div');
|
||||
sourcesDiv.className = 'bookslm-sources';
|
||||
for (const src of msg.sources) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'bookslm-source-badge';
|
||||
badge.textContent = `📄 ${src.split('/').pop()}`;
|
||||
badge.title = src;
|
||||
badge.addEventListener('click', () => this.openFile(src));
|
||||
sourcesDiv.appendChild(badge);
|
||||
}
|
||||
bubble.appendChild(sourcesDiv);
|
||||
}
|
||||
} else {
|
||||
bubble.textContent = msg.content;
|
||||
}
|
||||
|
||||
container.appendChild(bubble);
|
||||
}
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Actions (general assistant) ─────────────────────────────────────
|
||||
|
||||
_extractActions(text) {
|
||||
const actions = [];
|
||||
const re = /```obsigate-action\s*\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
try {
|
||||
const obj = JSON.parse(match[1].trim());
|
||||
if (obj && typeof obj.action === 'string') actions.push(obj);
|
||||
} catch { /* malformed block — ignore */ }
|
||||
}
|
||||
return { text: text.replace(re, '').trim(), actions };
|
||||
}
|
||||
|
||||
_renderActionCard(action) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bookslm-action';
|
||||
const isDir = action.action === 'create_directory';
|
||||
const label = isDir
|
||||
? t('ai.action_create_dir', { path: action.path || '' })
|
||||
: t('ai.action_create_file', { path: action.path || '' });
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'bookslm-action-meta';
|
||||
meta.innerHTML = `<span class="bookslm-action-icon">${isDir ? '📁' : '📄'}</span>`;
|
||||
const textEl = document.createElement('span');
|
||||
textEl.textContent = label;
|
||||
meta.appendChild(textEl);
|
||||
card.appendChild(meta);
|
||||
|
||||
const apply = document.createElement('button');
|
||||
apply.className = 'bookslm-action-apply';
|
||||
apply.textContent = t('ai.action_apply');
|
||||
apply.addEventListener('click', async () => {
|
||||
apply.disabled = true;
|
||||
apply.textContent = t('ai.action_applying');
|
||||
try {
|
||||
await this._applyAction(action);
|
||||
card.classList.add('done');
|
||||
apply.textContent = t('ai.action_applied');
|
||||
} catch (e) {
|
||||
apply.disabled = false;
|
||||
apply.textContent = t('ai.action_apply');
|
||||
showToast(t('ai.action_failed', { error: e.message }), 'error');
|
||||
}
|
||||
});
|
||||
card.appendChild(apply);
|
||||
return card;
|
||||
}
|
||||
|
||||
async _applyAction(action) {
|
||||
if (!action.vault || !action.path) throw new Error('missing vault/path');
|
||||
if (action.action === 'create_file') {
|
||||
await api(`/api/file/${encodeURIComponent(action.vault)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: action.path, content: action.content || '' }),
|
||||
});
|
||||
showToast(t('ai.action_created_file', { path: action.path }), 'success');
|
||||
} else if (action.action === 'create_directory') {
|
||||
await api(`/api/directory/${encodeURIComponent(action.vault)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: action.path }),
|
||||
});
|
||||
showToast(t('ai.action_created_dir', { path: action.path }), 'success');
|
||||
} else {
|
||||
throw new Error(`unknown action: ${action.action}`);
|
||||
}
|
||||
try {
|
||||
const m = await import('./sidebar.js');
|
||||
if (m && typeof m.refreshSidebarTreePreservingState === 'function') {
|
||||
await m.refreshSidebarTreePreservingState();
|
||||
}
|
||||
} catch { /* tree refresh is best-effort */ }
|
||||
}
|
||||
|
||||
// ── Messaging ───────────────────────────────────────────────────────
|
||||
|
||||
async _sendMessage() {
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
const text = (textarea.value || '').trim();
|
||||
if (!text || this._isLoading) return;
|
||||
|
||||
textarea.value = '';
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const sugEl = this._panel.querySelector('.bookslm-suggestions');
|
||||
if (sugEl) sugEl.innerHTML = '';
|
||||
|
||||
// Snapshot prior turns BEFORE appending the new user message.
|
||||
const history = this._messages
|
||||
.filter((m) => m.content && m.content.trim())
|
||||
.map((m) => ({ role: m.role, content: m.content }));
|
||||
|
||||
this._messages.push({ role: 'user', content: text });
|
||||
this._renderMessages();
|
||||
|
||||
const assistantMsg = { role: 'assistant', content: '', sources: [] };
|
||||
this._messages.push(assistantMsg);
|
||||
this._renderMessages();
|
||||
this._isLoading = true;
|
||||
|
||||
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
this._abortCtrl = new AbortController();
|
||||
|
||||
try {
|
||||
let provider = null;
|
||||
let model = null;
|
||||
try {
|
||||
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
|
||||
provider = picker.provider || null;
|
||||
model = picker.model || null;
|
||||
} catch { /* */ }
|
||||
|
||||
const payload = {
|
||||
mode: this._mode,
|
||||
vault: this._vault,
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||||
message: text,
|
||||
conversation_history: history,
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
|
||||
const doFetch = () => {
|
||||
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
||||
return fetch('/api/ai/bookslm/chat', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
signal: this._abortCtrl.signal,
|
||||
});
|
||||
};
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401 && AuthManager._authEnabled) {
|
||||
await AuthManager.refreshAccessToken();
|
||||
resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let detail = `HTTP ${resp.status}`;
|
||||
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentEvent = 'message';
|
||||
|
||||
const handleLine = (line) => {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
return;
|
||||
}
|
||||
if (!line.startsWith('data:')) return;
|
||||
const raw = line.slice(5).trim();
|
||||
if (!raw || raw === '[DONE]') return;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
assistantMsg.content += raw;
|
||||
return;
|
||||
}
|
||||
if (currentEvent === 'error' || data.error) {
|
||||
const msg = data.error || t('bookslm.error');
|
||||
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
|
||||
return;
|
||||
}
|
||||
const chunk = data.token || data.content || '';
|
||||
if (chunk) assistantMsg.content += chunk;
|
||||
if (data.sources) assistantMsg.sources = data.sources;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) handleLine(line);
|
||||
this._renderMessages();
|
||||
}
|
||||
if (buffer) handleLine(buffer);
|
||||
|
||||
if (!assistantMsg.sources.length && this._contextFiles.length) {
|
||||
const contentLower = assistantMsg.content.toLowerCase();
|
||||
assistantMsg.sources = this._contextFiles
|
||||
.filter((f) => {
|
||||
const name = (f.path || f || '').split('/').pop().toLowerCase();
|
||||
return name && contentLower.includes(name);
|
||||
})
|
||||
.map((f) => f.path || f);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
assistantMsg.content = `⚠ Error: ${e.message}`;
|
||||
console.warn('AI assistant chat error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this._isLoading = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
this._abortCtrl = null;
|
||||
this._renderMessages();
|
||||
this._saveHistory();
|
||||
}
|
||||
|
||||
// ── Markdown (lightweight) ──────────────────────────────────────────
|
||||
|
||||
_renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
// Escape HTML first so model output cannot inject markup.
|
||||
let html = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
.replace(/^[*\-+] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `<p>${html}</p>`;
|
||||
}
|
||||
|
||||
// ── History persistence ─────────────────────────────────────────────
|
||||
|
||||
_historyKey() {
|
||||
const ctx = this._mode === MODE.DOCUMENTS
|
||||
? documentsSignature(this._documents)
|
||||
: (this._directory || this._vault || 'general');
|
||||
return `bookslm-history-${this._mode}-${ctx.replace(/[^\w.-]+/g, '_')}`;
|
||||
}
|
||||
|
||||
_saveHistory() {
|
||||
try {
|
||||
localStorage.setItem(this._historyKey(), JSON.stringify(this._messages));
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
_loadHistory() {
|
||||
try {
|
||||
const data = localStorage.getItem(this._historyKey());
|
||||
this._messages = data ? JSON.parse(data) : [];
|
||||
} catch {
|
||||
this._messages = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const booksLM = new BooksLM();
|
||||
export default booksLM;
|
||||
export { BooksLM };
|
||||
+233
-48
@@ -1,5 +1,5 @@
|
||||
// config.js — extracted from app.js (3872-4865)
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { api, AuthManager, initMfaSettings } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { el, icon, openFile } from './viewer.js';
|
||||
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
@@ -663,6 +663,9 @@ function initConfigModal() {
|
||||
|
||||
// Init profile
|
||||
initProfile();
|
||||
|
||||
// Init MFA settings (security tab)
|
||||
initMfaSettings();
|
||||
}
|
||||
|
||||
function closeConfigModal() {
|
||||
@@ -920,8 +923,7 @@ function loadAbout() {
|
||||
title: t("about.section_app"),
|
||||
rows: [
|
||||
[t("about.name"), "ObsiGate"],
|
||||
[t("about.version"), state.APP_VERSION],
|
||||
[t("about.api_version"), health.version || "—"],
|
||||
[t("about.version"), health.version || "—"],
|
||||
[t("about.status"), health.status || "—"],
|
||||
],
|
||||
},
|
||||
@@ -1337,21 +1339,77 @@ function updateRegexPreview() {
|
||||
|
||||
|
||||
// ── AI Keys management ──
|
||||
const AI_KEY_MAP = {
|
||||
"cfg-deepseek-key": "DEEPSEEK_API_KEY",
|
||||
"cfg-openrouter-key": "OPENROUTER_API_KEY",
|
||||
"cfg-gemini-key": "GEMINI_API_KEY",
|
||||
"cfg-nvidia-key": "NVIDIA_API_KEY",
|
||||
"cfg-qwencloud-key": "QWENCLOUD_API_KEY",
|
||||
"cfg-xiaomi-key": "XIAOMI_API_KEY",
|
||||
"cfg-mistral-key": "MISTRAL_API_KEY",
|
||||
};
|
||||
const AI_PROVIDER_NAMES = ["deepseek","openrouter","gemini","nvidia","qwencloud","xiaomi","mistral"];
|
||||
|
||||
function _ensureAIKeyUI() {
|
||||
for (const [inputId] of Object.entries(AI_KEY_MAP)) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) continue;
|
||||
const row = input.closest(".config-row");
|
||||
if (!row || row.dataset.enhanced) continue;
|
||||
row.dataset.enhanced = "1";
|
||||
row.style.cssText += "display:flex;align-items:center;gap:8px;flex-wrap:wrap;";
|
||||
const badge = document.createElement("span");
|
||||
badge.id = inputId.replace("-key", "-badge");
|
||||
badge.style.cssText = "font-size:11px;padding:2px 8px;border-radius:10px;white-space:nowrap;";
|
||||
row.appendChild(badge);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.type = "button";
|
||||
delBtn.id = inputId.replace("-key", "-delete");
|
||||
delBtn.className = "config-btn-secondary";
|
||||
delBtn.style.cssText = "font-size:11px;padding:4px 10px;color:var(--danger,#e74c3c);border-color:var(--danger,#e74c3c);cursor:pointer;display:none;";
|
||||
delBtn.textContent = "\u00d7 Supprimer";
|
||||
delBtn.addEventListener("click", () => deleteAIKey(inputId));
|
||||
row.appendChild(delBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function _setAIKeyBadge(inputId, hasKey) {
|
||||
const badge = document.getElementById(inputId.replace("-key", "-badge"));
|
||||
const delBtn = document.getElementById(inputId.replace("-key", "-delete"));
|
||||
if (badge) {
|
||||
if (hasKey) {
|
||||
badge.textContent = "\u2713 Configur\u00e9";
|
||||
badge.style.background = "var(--success-bg, #27ae6022)";
|
||||
badge.style.color = "var(--success, #27ae60)";
|
||||
badge.style.border = "1px solid var(--success, #27ae60)";
|
||||
} else {
|
||||
badge.textContent = "Non configur\u00e9";
|
||||
badge.style.background = "var(--muted-bg, #ffffff10)";
|
||||
badge.style.color = "var(--text-muted, #888)";
|
||||
badge.style.border = "1px solid var(--border, #444)";
|
||||
}
|
||||
}
|
||||
if (delBtn) delBtn.style.display = hasKey ? "inline-block" : "none";
|
||||
}
|
||||
|
||||
async function loadAIKeys() {
|
||||
_ensureAIKeyUI();
|
||||
try {
|
||||
const data = await api("/api/config/ai-keys");
|
||||
["DEEPSEEK_API_KEY","OPENROUTER_API_KEY","GEMINI_API_KEY"].forEach(k => {
|
||||
const id = "cfg-" + k.toLowerCase().replace(/_api_key/g, "") + "-key";
|
||||
const input = document.getElementById(id);
|
||||
if (input && data[k]) input.placeholder = data[k];
|
||||
});
|
||||
for (const [inputId, envName] of Object.entries(AI_KEY_MAP)) {
|
||||
const input = document.getElementById(inputId);
|
||||
const val = data[envName] || "";
|
||||
if (input) {
|
||||
input.placeholder = val || (inputId.includes("gemini") ? "AIza..." : inputId.includes("openrouter") ? "sk-or-..." : "sk-...");
|
||||
}
|
||||
_setAIKeyBadge(inputId, !!val);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function saveAIKeys() {
|
||||
const keys = {};
|
||||
const map = { "cfg-deepseek-key": "DEEPSEEK_API_KEY", "cfg-openrouter-key": "OPENROUTER_API_KEY", "cfg-gemini-key": "GEMINI_API_KEY" };
|
||||
for (const [id, name] of Object.entries(map)) {
|
||||
for (const [id, name] of Object.entries(AI_KEY_MAP)) {
|
||||
const input = document.getElementById(id);
|
||||
if (input && input.value.trim()) keys[name] = input.value.trim();
|
||||
}
|
||||
@@ -1359,8 +1417,19 @@ async function saveAIKeys() {
|
||||
try {
|
||||
await api("/api/config/ai-keys", { method: "POST", body: JSON.stringify(keys) });
|
||||
showToast(t("config.api_keys_saved"), "success");
|
||||
// Clear inputs
|
||||
Object.keys(map).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
|
||||
Object.keys(AI_KEY_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
|
||||
loadAIKeys();
|
||||
} catch(e) { showToast("Erreur: " + e.message, "error"); }
|
||||
}
|
||||
|
||||
async function deleteAIKey(inputId) {
|
||||
const envName = AI_KEY_MAP[inputId];
|
||||
if (!envName) return;
|
||||
const provider = envName.replace("_API_KEY", "").replace(/_/g, " ");
|
||||
if (!confirm("Supprimer la cl\u00e9 API " + provider + " ?\nLe fournisseur ne sera plus disponible.")) return;
|
||||
try {
|
||||
await api("/api/config/ai-keys/" + envName, { method: "DELETE" });
|
||||
showToast("Cl\u00e9 " + provider + " supprim\u00e9e", "success");
|
||||
loadAIKeys();
|
||||
} catch(e) { showToast("Erreur: " + e.message, "error"); }
|
||||
}
|
||||
@@ -1369,12 +1438,18 @@ async function testAIKeys() {
|
||||
const status = document.getElementById("cfg-ai-status");
|
||||
if (status) status.textContent = "Test en cours...";
|
||||
try {
|
||||
// Test connectivity
|
||||
const results = await api("/api/config/ai-keys/test", { method: "POST" });
|
||||
var msg = Object.entries(results).map(([k,v]) => k + ": " + v).join(" | ");
|
||||
if (status) status.textContent = msg;
|
||||
// Fetch models for configured providers
|
||||
for (const p of ["deepseek","openrouter","gemini"]) {
|
||||
for (const p of AI_PROVIDER_NAMES) {
|
||||
const inputId = "cfg-" + p + "-key";
|
||||
if (results[p] === "ok") {
|
||||
_setAIKeyBadge(inputId, true);
|
||||
} else if (results[p] && results[p].includes("non")) {
|
||||
_setAIKeyBadge(inputId, false);
|
||||
}
|
||||
}
|
||||
for (const p of AI_PROVIDER_NAMES) {
|
||||
if (results[p] === "ok") {
|
||||
try {
|
||||
const m = await api("/api/config/ai-models?provider=" + p);
|
||||
@@ -1389,9 +1464,9 @@ async function testAIKeys() {
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
if (status) status.textContent = "Erreur: " + e.message;
|
||||
if (status) status.textContent = "Erreur: " + e.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
@@ -1408,31 +1483,62 @@ export {
|
||||
initAboutModal,
|
||||
initProfile,
|
||||
switchSidebarTab,
|
||||
populateVersions,
|
||||
};
|
||||
|
||||
// Populate every version display (header badge, About modal, help guide footer)
|
||||
// from a single source of truth so they always match.
|
||||
function populateVersions(version) {
|
||||
var v = version || window.__OBSIGATE_VERSION || '—';
|
||||
var els = [
|
||||
document.getElementById('version-badge'),
|
||||
document.getElementById('about-version'),
|
||||
document.getElementById('help-version'),
|
||||
];
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
if (els[i]) els[i].textContent = v;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Theme Picker ─────────────────────────────────────────────────────
|
||||
function initThemePicker() {
|
||||
import('./themes.js').then(function(mod) {
|
||||
var THEMES = mod.THEMES;
|
||||
var AVAILABLE_MODES = mod.AVAILABLE_MODES;
|
||||
var applyTheme = mod.applyTheme;
|
||||
var getCurrentTheme = mod.getCurrentTheme;
|
||||
var getCurrentMode = mod.getCurrentMode;
|
||||
var exportAllThemes = mod.exportAllThemes;
|
||||
var importTheme = mod.importTheme;
|
||||
var deleteCustomTheme = mod.deleteCustomTheme;
|
||||
var loadCustomThemes = mod.loadCustomThemes;
|
||||
|
||||
var grid = document.getElementById('theme-grid');
|
||||
if (!grid) return;
|
||||
|
||||
var themeKeys = Object.keys(THEMES);
|
||||
// Mode label map (uses i18n t() when available, fallback to English)
|
||||
var modeLabels = {
|
||||
'dark': function() { return t('theme.dark'); },
|
||||
'light': function() { return t('theme.light'); },
|
||||
'high-contrast': function() { return t('theme.high_contrast'); },
|
||||
'sepia': function() { return t('theme.sepia'); }
|
||||
};
|
||||
|
||||
function renderCards() {
|
||||
var themeKeys = Object.keys(THEMES);
|
||||
var current = getCurrentTheme();
|
||||
var mode = getCurrentMode();
|
||||
grid.innerHTML = '';
|
||||
|
||||
themeKeys.forEach(function(key) {
|
||||
var t = THEMES[key];
|
||||
var v = t.modes[mode];
|
||||
var th = THEMES[key];
|
||||
// Use dark mode for preview if mode not resolved yet
|
||||
var v = th.modes[mode] || th.modes.dark || th.modes.light;
|
||||
if (!v) return;
|
||||
var card = document.createElement('div');
|
||||
card.className = 'theme-card' + (key === current ? ' active' : '');
|
||||
var isCustom = th.desc === 'Custom theme';
|
||||
card.innerHTML =
|
||||
'<div class="theme-card-preview">' +
|
||||
'<div class="theme-card-preview-bar" style="background:' + (v['--bg-sidebar'] || v['--bg-primary']) + '"></div>' +
|
||||
@@ -1440,33 +1546,101 @@ function initThemePicker() {
|
||||
'<div class="theme-card-preview-btn" style="background:' + (v['--accent'] || '#58a6ff') + '"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="theme-card-name">' + t.name + '</div>' +
|
||||
'<div class="theme-card-desc">' + t.desc + '</div>';
|
||||
'<div class="theme-card-name">' + th.name + (isCustom ? ' <span class="theme-custom-badge">✦</span>' : '') + '</div>' +
|
||||
'<div class="theme-card-desc">' + th.desc + '</div>';
|
||||
card.addEventListener('click', function() {
|
||||
applyTheme(key, mode);
|
||||
renderCards();
|
||||
});
|
||||
// Right-click to delete custom themes
|
||||
if (isCustom) {
|
||||
card.addEventListener('contextmenu', function(e) {
|
||||
e.preventDefault();
|
||||
if (confirm(t('theme.delete_confirm'))) {
|
||||
deleteCustomTheme(key);
|
||||
renderCards();
|
||||
}
|
||||
});
|
||||
}
|
||||
grid.appendChild(card);
|
||||
});
|
||||
// Mode toggle
|
||||
|
||||
// Mode toggle — 4 modes
|
||||
var toggle = document.createElement('div');
|
||||
toggle.className = 'theme-mode-toggle';
|
||||
var dBtn = elBtn(t('theme.dark'), 'dark', mode);
|
||||
var lBtn = elBtn(t('theme.light'), 'light', mode);
|
||||
toggle.appendChild(dBtn);
|
||||
toggle.appendChild(lBtn);
|
||||
grid.appendChild(toggle);
|
||||
}
|
||||
|
||||
function elBtn(label, m, currentMode) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'theme-mode-btn' + (m === currentMode ? ' active' : '');
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', function() {
|
||||
applyTheme(getCurrentTheme(), m);
|
||||
renderCards();
|
||||
AVAILABLE_MODES.forEach(function(m) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'theme-mode-btn' + (m === mode ? ' active' : '');
|
||||
btn.textContent = (modeLabels[m] || function() { return m; })();
|
||||
btn.addEventListener('click', function() {
|
||||
applyTheme(getCurrentTheme(), m);
|
||||
renderCards();
|
||||
});
|
||||
toggle.appendChild(btn);
|
||||
});
|
||||
return btn;
|
||||
grid.appendChild(toggle);
|
||||
|
||||
// Import/Export buttons
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'theme-actions';
|
||||
|
||||
var exportBtn = document.createElement('button');
|
||||
exportBtn.className = 'theme-action-btn';
|
||||
exportBtn.textContent = t('theme.export_all');
|
||||
exportBtn.addEventListener('click', function() {
|
||||
var json = exportAllThemes();
|
||||
var blob = new Blob([json], { type: 'application/json' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url; a.download = 'obsigate-themes.json'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
var importBtn = document.createElement('button');
|
||||
importBtn.className = 'theme-action-btn';
|
||||
importBtn.textContent = t('theme.import');
|
||||
importBtn.addEventListener('click', function() {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.addEventListener('change', function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(ev) {
|
||||
var content = ev.target.result;
|
||||
// Support single theme or all-themes format
|
||||
try {
|
||||
var data = JSON.parse(content);
|
||||
if (data.themes) {
|
||||
// Multi-theme export: import each
|
||||
var count = 0;
|
||||
Object.keys(data.themes).forEach(function(k) {
|
||||
var res = importTheme(JSON.stringify(data.themes[k]));
|
||||
if (res.ok) count++;
|
||||
});
|
||||
alert(t('theme.import_success', { count: count }));
|
||||
} else {
|
||||
var res = importTheme(content);
|
||||
if (res.ok) {
|
||||
alert(t('theme.import_success', { count: 1 }));
|
||||
} else {
|
||||
alert(t('theme.import_error') + ': ' + res.error);
|
||||
}
|
||||
}
|
||||
} catch(ex) {
|
||||
alert(t('theme.import_error') + ': ' + ex.message);
|
||||
}
|
||||
renderCards();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
input.click();
|
||||
});
|
||||
|
||||
actions.appendChild(exportBtn);
|
||||
actions.appendChild(importBtn);
|
||||
grid.appendChild(actions);
|
||||
}
|
||||
|
||||
renderCards();
|
||||
@@ -1494,16 +1668,27 @@ function initAboutModal() {
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.classList.contains('active')) overlay.classList.remove('active'); });
|
||||
|
||||
function populateAbout() {
|
||||
// Live stats from health endpoint
|
||||
fetch('/api/health')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var fEl = document.getElementById('about-stat-files');
|
||||
var vEl = document.getElementById('about-stat-vaults');
|
||||
if (fEl) fEl.textContent = d.total_files || '—';
|
||||
if (vEl) vEl.textContent = d.vaults || '—';
|
||||
})
|
||||
.catch(function() {});
|
||||
// Live stats + version from health endpoint (single source of truth).
|
||||
// Fallback silencieux : si le fetch échoue, on garde la version pré-chargée
|
||||
// (window.__OBSIGATE_VERSION) au lieu d'un "—" trompeur.
|
||||
fetch('/api/health')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var fEl = document.getElementById('about-stat-files');
|
||||
var vEl = document.getElementById('about-stat-vaults');
|
||||
if (fEl && d.total_files) fEl.textContent = d.total_files;
|
||||
if (vEl && d.vaults) vEl.textContent = d.vaults;
|
||||
// Single source of truth: populate the header badge, About and
|
||||
// help-guide footer with the same version value.
|
||||
if (d && (d.version || d.git_describe)) {
|
||||
populateVersions((d && d.version) || window.__OBSIGATE_VERSION);
|
||||
}
|
||||
var commitEl = document.getElementById('about-commit');
|
||||
if (commitEl) commitEl.textContent = (d && d.git_describe) || (d && d.git_commit) || '—';
|
||||
})
|
||||
.catch(function() {
|
||||
populateVersions();
|
||||
});
|
||||
|
||||
// Build date (today)
|
||||
var now = new Date();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -58,6 +58,9 @@ function getCMDS() {
|
||||
{ id:'focus-next', label:'→ Panneau suivant', desc:'Active le panneau suivant (Ctrl+Alt+→)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId+1)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
|
||||
{ id:'focus-prev', label:'← Panneau précédent', desc:'Active le panneau précédent (Ctrl+Alt+←)', cat:'Panneaux', act:()=>{close();if(window.PaneManager&&window.PaneManager.isSplit()){const n=(window.PaneManager.activePaneId-1+window.PaneManager.panes.length)%window.PaneManager.panes.length;window.PaneManager.setActivePane(n);}} },
|
||||
{ id:'reset-panes', label:'🔄 Réinitialiser les panneaux', desc:'Ferme tous les panneaux et revient au mode single-pane', cat:'Panneaux', act:()=>{close();if(window.PaneManager){while(window.PaneManager.isSplit()){window.PaneManager.closePane(window.PaneManager.panes.length-1);}localStorage.removeItem('obsigate-panes');}} },
|
||||
// AI Assistant commands
|
||||
{ id:'bookslm-open', label:t('palette.bookslm_open'), desc:t('palette.bookslm_open_desc'), cat:'AI', act:async()=>{close();const m=await import('./bookslm.js');m.default.openForCurrentContext();} },
|
||||
{ id:'bookslm-new', label:t('palette.bookslm_new'), desc:t('palette.bookslm_new_desc'), cat:'AI', act:async()=>{close();const m=await import('./bookslm.js');m.default.newConversation();m.default.openForCurrentContext();} },
|
||||
];
|
||||
return _cmdsCache;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
Use state.xxx to read/write any value. ES module imports are read-only,
|
||||
so we export a single mutable object instead of individual let bindings. */
|
||||
export const state = {
|
||||
APP_VERSION: "1.5.0",
|
||||
|
||||
// Core navigation
|
||||
currentVault: null,
|
||||
currentPath: null,
|
||||
|
||||
+180
-4
@@ -526,26 +526,85 @@ var THEMES = {
|
||||
var _currentTheme = 'defaut-obsigate';
|
||||
var _currentMode = 'dark';
|
||||
|
||||
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
|
||||
|
||||
// ── Mode generators ─────────────────────────────────────────────────
|
||||
// Generate a high-contrast variant from dark-mode CSS vars.
|
||||
function _genHighContrast(darkVars) {
|
||||
return {
|
||||
'--bg-primary': '#000000', '--bg-secondary': '#0a0a0a', '--bg-sidebar': '#000000',
|
||||
'--bg-hover': '#141414', '--border': '#444444', '--text-primary': '#ffffff',
|
||||
'--text-secondary': '#cccccc', '--text-muted': '#888888', '--accent': darkVars['--accent'] || '#58a6ff',
|
||||
'--accent-green': '#4cff6e', '--tag-bg': '#1a2a44', '--tag-text': '#7dc4ff',
|
||||
'--code-bg': '#0a0a0a', '--search-bg': '#111111', '--scrollbar': '#444444',
|
||||
'--resize-handle': '#444444', '--overlay-bg': 'rgba(0,0,0,0.85)',
|
||||
'--danger': '#ff6b6b', '--danger-bg': '#4a0f0f', '--success': '#4cff6e', '--success-bg': '#0a3a14',
|
||||
'--accent-card': darkVars['--accent'] || '#58a6ff', '--accent-bg': '#1a2a44', '--accent-text': '#7dc4ff',
|
||||
'--green': '#4cff6e', '--green-bg': '#0a3a14', '--purple': '#c084fc', '--purple-bg': '#2a1a4a',
|
||||
'--surface': '#0a0a0a', '--surface2': '#000000', '--surface3': '#000000',
|
||||
'--border-md': '#444444', '--text': '#ffffff', '--text-2': '#cccccc', '--text-3': '#888888',
|
||||
'--ok': '#4cff6e', '--warn': '#ffcc00', '--ai': '#c084fc', '--err': '#ff6b6b'
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a sepia variant from light-mode CSS vars.
|
||||
function _genSepia(lightVars) {
|
||||
return {
|
||||
'--bg-primary': '#f5ecd5', '--bg-secondary': '#eee3c8', '--bg-sidebar': '#e8dcc0',
|
||||
'--bg-hover': '#e0d4b4', '--border': '#c8b890', '--text-primary': '#3d2e1a',
|
||||
'--text-secondary': '#6b5a3e', '--text-muted': '#9a8a6a', '--accent': '#b8860b',
|
||||
'--accent-green': '#5a8a32', '--tag-bg': '#e8dcc0', '--tag-text': '#8b6914',
|
||||
'--code-bg': '#eee3c8', '--search-bg': '#f5ecd5', '--scrollbar': '#c8b890',
|
||||
'--resize-handle': '#c8b890', '--overlay-bg': 'rgba(60,40,10,0.25)',
|
||||
'--danger': '#b33a2a', '--danger-bg': '#f5e0dc', '--success': '#5a8a32', '--success-bg': '#e0edd0',
|
||||
'--accent-card': '#b8860b', '--accent-bg': '#f0e4c4', '--accent-text': '#8b6914',
|
||||
'--green': '#5a8a32', '--green-bg': '#e0edd0', '--purple': '#7a5a9a', '--purple-bg': '#e8ddf0',
|
||||
'--surface': '#eee3c8', '--surface2': '#f5ecd5', '--surface3': '#e8dcc0',
|
||||
'--border-md': '#c8b890', '--text': '#3d2e1a', '--text-2': '#6b5a3e', '--text-3': '#9a8a6a',
|
||||
'--ok': '#5a8a32', '--warn': '#b8860b', '--ai': '#7a5a9a', '--err': '#b33a2a'
|
||||
};
|
||||
}
|
||||
|
||||
// Get CSS vars for a theme + mode (generating HC/sepia on demand).
|
||||
function _getVars(themeKey, mode) {
|
||||
var theme = THEMES[themeKey];
|
||||
if (!theme) return null;
|
||||
if (theme.modes[mode]) return theme.modes[mode];
|
||||
// Generate high-contrast / sepia from dark / light
|
||||
if (mode === 'high-contrast') return _genHighContrast(theme.modes.dark || {});
|
||||
if (mode === 'sepia') return _genSepia(theme.modes.light || {});
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyTheme(themeKey, mode) {
|
||||
var theme = THEMES[themeKey];
|
||||
if (!theme) return;
|
||||
mode = mode || _currentMode || 'dark';
|
||||
var vars = theme.modes[mode];
|
||||
var vars = _getVars(themeKey, mode);
|
||||
if (!vars) return;
|
||||
|
||||
var root = document.documentElement;
|
||||
Object.keys(vars).forEach(function(key) {
|
||||
root.style.setProperty(key, vars[key]);
|
||||
});
|
||||
// Store resolved vars on the theme for export/preview
|
||||
if (!theme.modes[mode]) theme.modes[mode] = vars;
|
||||
|
||||
_currentTheme = themeKey;
|
||||
_currentMode = mode;
|
||||
try { localStorage.setItem('obsigate-theme', themeKey); } catch(e) {}
|
||||
try { localStorage.setItem('obsigate-theme-mode', mode); } catch(e) {}
|
||||
|
||||
// Notify other components of theme change
|
||||
try {
|
||||
root.setAttribute('data-theme', mode);
|
||||
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeKey, mode: mode } }));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function toggleThemeMode() {
|
||||
var newMode = _currentMode === 'dark' ? 'light' : 'dark';
|
||||
var idx = AVAILABLE_MODES.indexOf(_currentMode);
|
||||
var newMode = AVAILABLE_MODES[(idx + 1) % AVAILABLE_MODES.length];
|
||||
applyTheme(_currentTheme, newMode);
|
||||
return newMode;
|
||||
}
|
||||
@@ -553,14 +612,131 @@ function toggleThemeMode() {
|
||||
function getCurrentTheme() { return _currentTheme; }
|
||||
function getCurrentMode() { return _currentMode; }
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
function setTheme(name) {
|
||||
if (THEMES[name]) {
|
||||
applyTheme(name, _currentMode);
|
||||
}
|
||||
}
|
||||
|
||||
function getTheme() {
|
||||
return _currentTheme;
|
||||
}
|
||||
|
||||
function listThemes() {
|
||||
return Object.keys(THEMES).map(function(key) {
|
||||
return { id: key, name: THEMES[key].name, desc: THEMES[key].desc };
|
||||
});
|
||||
}
|
||||
|
||||
function listModes() {
|
||||
return AVAILABLE_MODES.slice();
|
||||
}
|
||||
|
||||
// ── Import / Export ─────────────────────────────────────────────────
|
||||
function exportTheme(themeKey) {
|
||||
themeKey = themeKey || _currentTheme;
|
||||
var theme = THEMES[themeKey];
|
||||
if (!theme) return null;
|
||||
// Ensure HC/sepia are resolved for export
|
||||
var modes = {};
|
||||
AVAILABLE_MODES.forEach(function(m) {
|
||||
modes[m] = _getVars(themeKey, m);
|
||||
});
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
id: themeKey,
|
||||
name: theme.name,
|
||||
desc: theme.desc,
|
||||
modes: modes
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function exportAllThemes() {
|
||||
var out = {};
|
||||
Object.keys(THEMES).forEach(function(key) {
|
||||
out[key] = {
|
||||
name: THEMES[key].name,
|
||||
desc: THEMES[key].desc,
|
||||
modes: {}
|
||||
};
|
||||
AVAILABLE_MODES.forEach(function(m) {
|
||||
out[key].modes[m] = _getVars(key, m);
|
||||
});
|
||||
});
|
||||
return JSON.stringify({ version: 1, themes: out }, null, 2);
|
||||
}
|
||||
|
||||
function importTheme(jsonStr) {
|
||||
try {
|
||||
var data = JSON.parse(jsonStr);
|
||||
if (!data || !data.modes) return { ok: false, error: 'Invalid theme JSON: missing modes' };
|
||||
|
||||
var name = data.name || data.id || ('custom-' + Date.now());
|
||||
var key = (data.id || name).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
|
||||
var modes = {};
|
||||
if (data.modes.dark && typeof data.modes.dark === 'object') modes.dark = data.modes.dark;
|
||||
if (data.modes.light && typeof data.modes.light === 'object') modes.light = data.modes.light;
|
||||
if (Object.keys(modes).length === 0) return { ok: false, error: 'Theme must define at least dark or light mode' };
|
||||
|
||||
THEMES[key] = { name: name, desc: data.desc || 'Custom theme', modes: modes };
|
||||
|
||||
try { localStorage.setItem('obsigate-custom-' + key, JSON.stringify(THEMES[key])); } catch(e) {}
|
||||
return { ok: true, key: key };
|
||||
} catch(e) {
|
||||
return { ok: false, error: 'JSON parse error: ' + e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function loadCustomThemes() {
|
||||
var keys = [];
|
||||
try {
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var k = localStorage.key(i);
|
||||
if (k && k.indexOf('obsigate-custom-') === 0) {
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
keys.forEach(function(lsKey) {
|
||||
try {
|
||||
var data = JSON.parse(localStorage.getItem(lsKey));
|
||||
if (data && data.modes) {
|
||||
var key = lsKey.replace('obsigate-custom-', '');
|
||||
THEMES[key] = data;
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteCustomTheme(key) {
|
||||
if (THEMES[key] && THEMES[key].desc === 'Custom theme') {
|
||||
delete THEMES[key];
|
||||
try { localStorage.removeItem('obsigate-custom-' + key); } catch(e) {}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function initThemes() {
|
||||
// Load any custom themes from localStorage first
|
||||
loadCustomThemes();
|
||||
var saved = null;
|
||||
var savedMode = null;
|
||||
try { saved = localStorage.getItem('obsigate-theme'); } catch(e) {}
|
||||
try { savedMode = localStorage.getItem('obsigate-theme-mode'); } catch(e) {}
|
||||
if (saved && THEMES[saved]) _currentTheme = saved;
|
||||
if (savedMode) _currentMode = savedMode;
|
||||
if (savedMode && AVAILABLE_MODES.indexOf(savedMode) !== -1) _currentMode = savedMode;
|
||||
applyTheme(_currentTheme, _currentMode);
|
||||
}
|
||||
|
||||
export { THEMES, applyTheme, toggleThemeMode, getCurrentTheme, getCurrentMode, initThemes };
|
||||
export {
|
||||
THEMES, AVAILABLE_MODES,
|
||||
applyTheme, toggleThemeMode,
|
||||
getCurrentTheme, getCurrentMode,
|
||||
getTheme,
|
||||
setTheme, listThemes, listModes,
|
||||
exportTheme, exportAllThemes, importTheme, deleteCustomTheme, loadCustomThemes,
|
||||
initThemes
|
||||
};
|
||||
|
||||
+28
-6
@@ -177,14 +177,16 @@ export function toggleTheme() {
|
||||
// Update the theme toggle label
|
||||
const themeLabel = document.getElementById("theme-label");
|
||||
if (themeLabel) {
|
||||
themeLabel.textContent = newMode === "dark" ? t('theme.dark') : t('theme.light');
|
||||
const modeLabelMap = { dark: 'theme.dark', light: 'theme.light', 'high-contrast': 'theme.high_contrast', sepia: 'theme.sepia' };
|
||||
themeLabel.textContent = t(modeLabelMap[newMode] || 'theme.dark');
|
||||
}
|
||||
// Update the theme toggle icon
|
||||
const themeBtn = document.getElementById("theme-toggle");
|
||||
if (themeBtn) {
|
||||
const icon = themeBtn.querySelector("i");
|
||||
if (icon) {
|
||||
icon.setAttribute("data-lucide", newMode === "dark" ? "moon" : "sun");
|
||||
const isLight = newMode === "light" || newMode === "sepia";
|
||||
icon.setAttribute("data-lucide", isLight ? "sun" : "moon");
|
||||
}
|
||||
}
|
||||
safeCreateIcons();
|
||||
@@ -214,6 +216,15 @@ export function initHeaderMenu() {
|
||||
menuDropdown.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// API documentation (#72) — opens the OpenAPI/Swagger page in a new tab.
|
||||
const apiRow = document.getElementById("api-docs-menu-row");
|
||||
if (apiRow) {
|
||||
apiRow.addEventListener("click", () => {
|
||||
closeHeaderMenu();
|
||||
window.open("/docs", "_blank", "noopener");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function closeHeaderMenu() {
|
||||
@@ -753,10 +764,10 @@ const FileOperations = {
|
||||
});
|
||||
},
|
||||
|
||||
showCreateFileModal(vault, parentPath) {
|
||||
const overlay = this._createModalOverlay();
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'obsigate-modal';
|
||||
showCreateFileModal(vault, parentPath, preselectExt = null) {
|
||||
const overlay = this._createModalOverlay();
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'obsigate-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="obsigate-modal-header">
|
||||
<h3 class="obsigate-modal-title">${t('modal.create_file')}</h3>
|
||||
@@ -802,6 +813,10 @@ const FileOperations = {
|
||||
|
||||
input.focus();
|
||||
|
||||
if (preselectExt && [...extSelect.options].some(o => o.value === preselectExt)) {
|
||||
extSelect.value = preselectExt;
|
||||
}
|
||||
|
||||
const validateName = (name) => {
|
||||
if (!name.trim()) return 'Le nom ne peut pas être vide';
|
||||
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
|
||||
@@ -1693,9 +1708,12 @@ export const ContextMenuManager = {
|
||||
} else if (type === 'directory') {
|
||||
this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
|
||||
this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
|
||||
this._addItem('pen-tool', 'Nouveau diagramme Excalidraw', () => this._createExcalidraw(), isReadonly);
|
||||
this._addSeparator();
|
||||
this._addItem('bookmark-plus', 'Ajouter aux recherches sauvegardees', () => this._saveDirectorySearch(), false);
|
||||
this._addSeparator();
|
||||
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false);
|
||||
this._addSeparator();
|
||||
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
|
||||
this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
|
||||
} else if (type === 'file') {
|
||||
@@ -1771,6 +1789,10 @@ export const ContextMenuManager = {
|
||||
FileOperations.showCreateFileModal(this._targetVault, this._targetPath);
|
||||
},
|
||||
|
||||
_createExcalidraw() {
|
||||
FileOperations.showCreateFileModal(this._targetVault, this._targetPath, '.excalidraw');
|
||||
},
|
||||
|
||||
_renameItem() {
|
||||
FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType);
|
||||
},
|
||||
|
||||
+91
-2
@@ -1,5 +1,5 @@
|
||||
/* ObsiGate — Viewer module */
|
||||
import { api } from './auth.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard } from './utils.js';
|
||||
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
|
||||
@@ -12,6 +12,42 @@ import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, D
|
||||
import { openShareDialog } from './config.js';
|
||||
import { cacheViewedFile, getCachedFile } from './offline.js';
|
||||
import { t } from './i18n.js';
|
||||
import { onFileRender } from './plugins.js';
|
||||
|
||||
// ── Multi-format export ────────────────────────────────────────────────────
|
||||
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
|
||||
// export endpoints, showing a loading toast while the request is in flight.
|
||||
async function downloadExport(vault, path, format, fallbackName) {
|
||||
showToast(t("viewer.export_start"), "info");
|
||||
const url = `/api/export/${format}?vault=${encodeURIComponent(vault)}&path=${encodeURIComponent(path)}`;
|
||||
try {
|
||||
const headers = AuthManager.getAuthHeaders() || {};
|
||||
const res = await fetch(url, { credentials: "include", headers });
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try { detail = (await res.json()).detail || ""; } catch (_) { /* ignore */ }
|
||||
throw new Error(detail || "HTTP " + res.status);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fallbackName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
showToast(t("viewer.export_done"), "success");
|
||||
} catch (err) {
|
||||
console.error("Export error:", err);
|
||||
showToast(t("viewer.export_error") + " " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open export dropdown when clicking elsewhere.
|
||||
document.addEventListener("click", function (e) {
|
||||
if (e.target && e.target.closest && e.target.closest(".export-dropdown")) return;
|
||||
document.querySelectorAll(".export-menu").forEach(function (m) { m.style.display = "none"; });
|
||||
});
|
||||
|
||||
// Pane-aware content area helper — delegates to PaneManager when split
|
||||
function getContentArea() {
|
||||
@@ -635,6 +671,36 @@ export function renderFile(data) {
|
||||
window.open(pdfUrl, "_blank");
|
||||
});
|
||||
|
||||
// Export dropdown button (HTML / MD bundle / ePub)
|
||||
const exportWrap = el("div", { class: "export-dropdown", style: "position:relative;display:inline-block" });
|
||||
const exportBtn = el("button", { class: "btn-action", title: t("viewer.export_title") }, [icon("download", 14), document.createTextNode(t("viewer.export"))]);
|
||||
const exportMenu = el("div", { class: "export-menu", style: "display:none;position:absolute;right:0;top:100%;z-index:1000;min-width:200px;padding:4px;background:var(--bg,#fff);border:1px solid #ccc;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.18)" });
|
||||
const exportOptions = [
|
||||
{ format: "html", icon: "file-code", label: t("viewer.export_html"), ext: ".html" },
|
||||
{ format: "md-bundle", icon: "archive", label: t("viewer.export_md_bundle"), ext: ".zip" },
|
||||
{ format: "epub", icon: "book-open", label: t("viewer.export_epub"), ext: ".epub" },
|
||||
];
|
||||
exportOptions.forEach((opt) => {
|
||||
const item = el("button", { class: "export-menu-item", style: "display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:none;background:none;cursor:pointer;border-radius:4px;text-align:left;font-size:13px" }, [
|
||||
icon(opt.icon, 14),
|
||||
document.createTextNode(opt.label),
|
||||
]);
|
||||
item.addEventListener("mouseenter", () => { item.style.background = "rgba(128,128,128,.15)"; });
|
||||
item.addEventListener("mouseleave", () => { item.style.background = "none"; });
|
||||
item.addEventListener("click", () => {
|
||||
exportMenu.style.display = "none";
|
||||
const baseName = data.path.split("/").pop().replace(/\.[^.]+$/, "") || "document";
|
||||
downloadExport(data.vault, data.path, opt.format, baseName + opt.ext);
|
||||
});
|
||||
exportMenu.appendChild(item);
|
||||
});
|
||||
exportBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
|
||||
});
|
||||
exportWrap.appendChild(exportBtn);
|
||||
exportWrap.appendChild(exportMenu);
|
||||
|
||||
const editBtn = el("button", { class: "btn-action", title: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]);
|
||||
editBtn.addEventListener("click", () => {
|
||||
openEditor(data.vault, data.path);
|
||||
@@ -754,7 +820,7 @@ export function renderFile(data) {
|
||||
// Assemble
|
||||
area.innerHTML = "";
|
||||
area.appendChild(breadcrumb);
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, editBtn, forgeBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, exportWrap, editBtn, forgeBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
|
||||
if (fmSection) area.appendChild(fmSection);
|
||||
area.appendChild(mdDiv);
|
||||
area.appendChild(rawDiv);
|
||||
@@ -812,6 +878,29 @@ export function renderFile(data) {
|
||||
|
||||
// Initialize outline/TOC for this document
|
||||
OutlineManager.init();
|
||||
|
||||
// Plugin hooks: onFileRender — allow plugins to transform the rendered
|
||||
// content or append custom widgets beneath it. Async + non-blocking so a
|
||||
// slow/failed plugin never delays the normal file render.
|
||||
if (data.content && data.is_markdown) {
|
||||
onFileRender(data.vault, data.content, data.path).then((fr) => {
|
||||
if (!fr) return;
|
||||
if (typeof fr.replace === 'string' && fr.replace !== data.content) {
|
||||
const rendered = document.getElementById('file-rendered-content');
|
||||
if (rendered) rendered.innerHTML = fr.replace;
|
||||
}
|
||||
if (Array.isArray(fr.append) && fr.append.length > 0) {
|
||||
const out = el('div', { class: 'plugin-render-output' });
|
||||
fr.append.forEach((html) => {
|
||||
const w = el('div', { class: 'plugin-widget' });
|
||||
w.innerHTML = html;
|
||||
out.appendChild(w);
|
||||
});
|
||||
area.appendChild(out);
|
||||
safeCreateIcons();
|
||||
}
|
||||
}).catch(() => { /* plugins must never break rendering */ });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+218
-2
@@ -42,6 +42,19 @@
|
||||
"about.tests": "Tests",
|
||||
"about.vaults_configured": "Configured vaults",
|
||||
"about.version": "Version",
|
||||
"admin.audit_all_actions": "All actions",
|
||||
"admin.audit_empty": "No log available",
|
||||
"admin.audit_filter_action": "Action",
|
||||
"admin.audit_filter_user": "Filter by user",
|
||||
"admin.audit_refresh": "Refresh",
|
||||
"admin.audit_title": "Audit logs",
|
||||
"admin.back_btn": "← Back",
|
||||
"admin.backups_newest": "Newest: {days} days",
|
||||
"admin.backups_oldest": "Oldest: {days} days",
|
||||
"admin.backups_per_vault": "Per vault",
|
||||
"admin.backups_title": "Backup statistics",
|
||||
"admin.backups_total": "Total: {count} backups ({size})",
|
||||
"admin.backups_vault_empty": "No backup",
|
||||
"admin.close": "Close",
|
||||
"admin.col_actions": "Actions",
|
||||
"admin.col_last_login": "Last login",
|
||||
@@ -49,10 +62,36 @@
|
||||
"admin.col_status": "Status",
|
||||
"admin.col_user": "User",
|
||||
"admin.col_vaults": "Vaults",
|
||||
"admin.forbidden_msg": "You must be an administrator to access this page.",
|
||||
"admin.forbidden_title": "Access denied",
|
||||
"admin.header_subtitle": "Administrator dashboard",
|
||||
"admin.new_user": "+ New user",
|
||||
"admin.page_title": "ObsiGate — Administration",
|
||||
"admin.stats_cpu": "CPU",
|
||||
"admin.stats_disk": "Disk",
|
||||
"admin.stats_memory": "Memory",
|
||||
"admin.stats_refresh": "Last update: {time}",
|
||||
"admin.stats_sessions": "Active sessions",
|
||||
"admin.stats_title": "Real-time statistics",
|
||||
"admin.stats_uptime": "Uptime",
|
||||
"admin.title": "Administration — Users",
|
||||
"admin.users_manage_btn": "Manage users",
|
||||
"admin.users_manage_hint": "Open the user management modal (full CRUD)",
|
||||
"admin.users_title": "User management",
|
||||
"ai.action_applied": "Applied ✓",
|
||||
"ai.action_apply": "Apply",
|
||||
"ai.action_applying": "Applying…",
|
||||
"ai.action_create_dir": "Create folder {path}",
|
||||
"ai.action_create_file": "Create file {path}",
|
||||
"ai.action_created_dir": "Folder created: {path}",
|
||||
"ai.action_created_file": "File created: {path}",
|
||||
"ai.action_failed": "Action failed: {error}",
|
||||
"ai.casual": "Casual tone",
|
||||
"ai.close": "Close assistant",
|
||||
"ai.completion_added": "AI: completion added",
|
||||
"ai.context_documents": "{count} open document(s)",
|
||||
"ai.context_general": "General context",
|
||||
"ai.context_general_desc": "Questions about the app and file creation",
|
||||
"ai.continue": "Continue",
|
||||
"ai.custom_rewrite": "Custom rewrite",
|
||||
"ai.done": "AI: text processed",
|
||||
@@ -60,7 +99,9 @@
|
||||
"ai.error_invalid_key": "Invalid API key",
|
||||
"ai.explain": "Explain",
|
||||
"ai.fix_spelling": "Fix spelling",
|
||||
"ai.fix_spelling_hint": "Fixes spelling and grammar",
|
||||
"ai.frontmatter": "Generate frontmatter",
|
||||
"ai.fullscreen": "Fullscreen",
|
||||
"ai.generate": "Generate",
|
||||
"ai.hint_add_details": "Adds details",
|
||||
"ai.hint_edit_selection": "Modifies the selected text",
|
||||
@@ -68,6 +109,7 @@
|
||||
"ai.hint_improve": "Improves text quality",
|
||||
"ai.hint_translate": "Translates the selected text",
|
||||
"ai.improve": "Improve",
|
||||
"ai.input_hint": "Enter to send · Ctrl+Enter for a line break",
|
||||
"ai.inline_completion_title": "AI Inline Completion (Ctrl+J) — Auto-complete text",
|
||||
"ai.invalid_api_key": "AI: invalid API key. Check DEEPSEEK_API_KEY in .env",
|
||||
"ai.longer": "Longer",
|
||||
@@ -75,20 +117,38 @@
|
||||
"ai.not_configured": "⚠️ AI not configured — add DEEPSEEK_API_KEY, OPENROUTER_API_KEY or GEMINI_API_KEY in .env",
|
||||
"ai.processing": "⏳ AI: processing...",
|
||||
"ai.professional": "Professional tone",
|
||||
"ai.provider": "Provider",
|
||||
"ai.provider_default": "— default —",
|
||||
"ai.model": "Model",
|
||||
"ai.model_default": "— default —",
|
||||
"ai.model_loading": "Loading…",
|
||||
"ai.model_offline": "offline",
|
||||
"ai.model_load_error": "Load error",
|
||||
"ai.model_settings": "Provider & model",
|
||||
"ai.quota_exceeded": "AI: quota exceeded or payment required",
|
||||
"ai.rewrite": "💬 Rewrite",
|
||||
"ai.rewrite_done": "AI: text rewritten",
|
||||
"ai.rewrite_prompt": "Rewrite instruction:",
|
||||
"ai.rewrite_placeholder": "e.g. make the tone more formal and add a conclusion",
|
||||
"ai.rewrite_title": "Rewrite text according to your instructions",
|
||||
"ai.select_text": "AI: select text to process",
|
||||
"ai.shorter": "Shorter",
|
||||
"ai.shorter_hint": "Makes the text more concise",
|
||||
"ai.simplify": "Simplify",
|
||||
"ai.simplify_hint": "Simplifies the language",
|
||||
"ai.suggestion_capabilities": "What can you do?",
|
||||
"ai.suggestion_create_file": "Create a meeting notes file",
|
||||
"ai.suggestion_docs_contradictions": "Are there contradictions between these documents?",
|
||||
"ai.suggestion_docs_keypoints": "What are the key points of these documents?",
|
||||
"ai.suggestion_docs_summary": "Summarize the open documents",
|
||||
"ai.suggestion_search": "How do I search my notes effectively?",
|
||||
"ai.summarize": "Summarize",
|
||||
"ai.text_processed": "AI: text processed ✓",
|
||||
"ai.text_rewritten": "AI: text rewritten",
|
||||
"ai.to_canvas": "Convert to Canvas",
|
||||
"ai.to_list": "Convert to list",
|
||||
"ai.to_table": "Convert to table",
|
||||
"ai.tone_hint": "Changes the text tone",
|
||||
"ai.toolbar_edit": "Edit",
|
||||
"ai.toolbar_generate": "Generate",
|
||||
"ai.toolbar_rewrite": "Rewrite",
|
||||
@@ -96,6 +156,7 @@
|
||||
"ai.toolbar_toolbox": "Toolbox",
|
||||
"ai.toolbar_translate": "Translate",
|
||||
"ai.toolbox": "🧰 Toolbox",
|
||||
"ai.toolbox_hint": "Conversion tools",
|
||||
"ai.translate_to": "Translate to {lang}",
|
||||
"auth.cannot_delete_self": "Cannot delete your own account",
|
||||
"auth.logged_out": "Signed out",
|
||||
@@ -425,6 +486,8 @@
|
||||
"config.section_vue-graphe": "🗺️ Vue Graphe",
|
||||
"config.section_watcher": "Auto sync",
|
||||
"config.section_webhooks": "🔗 Webhooks",
|
||||
"config.section_push": "🔔 Push notifications",
|
||||
"config.section_plugins": "🧩 Plugins",
|
||||
"config.section_workflow-recommande": "Recommended workflow",
|
||||
"config.section_zone-de-contenu": "Zone de contenu",
|
||||
"config.server_config": "Server config",
|
||||
@@ -451,6 +514,8 @@
|
||||
"config.webhook_url_placeholder": "URL",
|
||||
"config.webhooks": "Webhooks",
|
||||
"config.webhooks_desc": "HTTP notifications to external services on file changes.",
|
||||
"config.plugins": "Plugins",
|
||||
"config.plugins_desc": "Manage installed plugins for this vault.",
|
||||
"config.workers_label": "Search workers",
|
||||
"confirm.delete_dir": "Delete this folder and all its contents?",
|
||||
"confirm.delete_dir_named": "Delete \"{path}\" and ALL its contents?",
|
||||
@@ -586,6 +651,8 @@
|
||||
"header.menu_about_desc": "Version, credits & info",
|
||||
"header.menu_admin": "Administration",
|
||||
"header.menu_admin_desc": "Manage users",
|
||||
"header.menu_api": "API",
|
||||
"header.menu_api_desc": "OpenAPI documentation",
|
||||
"header.menu_config": "Settings",
|
||||
"header.menu_config_desc": "Application settings",
|
||||
"header.menu_help": "User guide",
|
||||
@@ -1037,6 +1104,8 @@
|
||||
"help.nav_tags": "🏷️ Tags",
|
||||
"help.nav_tips": "💡 Tips",
|
||||
"help.nav_webhooks": "🪝 Webhooks",
|
||||
"help.nav_plugins": "🧩 Plugins",
|
||||
"help.nav_excalidraw": "🎨 Excalidraw",
|
||||
"help.new_file": "New file",
|
||||
"help.new_folder": "New folder",
|
||||
"help.nights": "sleepless nights 😅",
|
||||
@@ -1303,6 +1372,7 @@
|
||||
"settings.search_placeholder": "Search...",
|
||||
"settings.sync": "🔄 Sync",
|
||||
"settings.themes": "🎨 Themes",
|
||||
"settings.plugins": "🧩 Plugins",
|
||||
"share.copied": "Link copied!",
|
||||
"share.copy_link": "Copy link",
|
||||
"share.create": "Create share link",
|
||||
@@ -1377,7 +1447,14 @@
|
||||
"theme.change": "Change theme",
|
||||
"theme.dark": "Dark",
|
||||
"theme.light": "Light",
|
||||
"theme.high_contrast": "High Contrast",
|
||||
"theme.sepia": "Sepia",
|
||||
"theme.title": "Theme",
|
||||
"theme.export_all": "Export Themes",
|
||||
"theme.import": "Import Theme",
|
||||
"theme.import_success": "{count} theme(s) imported",
|
||||
"theme.import_error": "Import error",
|
||||
"theme.delete_confirm": "Delete this custom theme?",
|
||||
"themes.name_gradients": "Subtle gradients",
|
||||
"themes.name_terminal": "Pure black & green",
|
||||
"toast.ai_keys_saved": "API keys saved",
|
||||
@@ -1465,6 +1542,14 @@
|
||||
"viewer.download_pdf": "Download as PDF",
|
||||
"viewer.edit": "Edit",
|
||||
"viewer.error": "Loading error",
|
||||
"viewer.export": "Export",
|
||||
"viewer.export_done": "Export complete",
|
||||
"viewer.export_epub": "Export as ePub",
|
||||
"viewer.export_error": "Export error:",
|
||||
"viewer.export_html": "Export as HTML",
|
||||
"viewer.export_md_bundle": "Export as Markdown bundle (.zip)",
|
||||
"viewer.export_start": "Exporting...",
|
||||
"viewer.export_title": "Export document",
|
||||
"viewer.forge_brand": "Forge",
|
||||
"viewer.forge_title": "Forge (new editor)",
|
||||
"viewer.index_start": "Starting index...",
|
||||
@@ -1495,5 +1580,136 @@
|
||||
"webhook.trigger_dir_create": "Directory creation via API",
|
||||
"webhook.trigger_dir_delete": "Directory deletion via API",
|
||||
"webhook.trigger_dir_rename": "Directory rename via API",
|
||||
"webhook.trigger_rename": "Rename via API"
|
||||
}
|
||||
"webhook.trigger_rename": "Rename via API",
|
||||
"settings.security": "🔒 Account Security",
|
||||
"settings.security_desc": "Enable two-factor authentication (2FA) to strengthen your account security.",
|
||||
"mfa.title": "Two-Factor Authentication",
|
||||
"mfa.subtitle": "Enter the 6-digit code from your authenticator app.",
|
||||
"mfa.verify": "Verify",
|
||||
"mfa.verifying": "Verifying...",
|
||||
"mfa.use_recovery": "Use a recovery code",
|
||||
"mfa.back_to_login": "Back to login",
|
||||
"mfa.recovery_title": "Recovery Code",
|
||||
"mfa.recovery_subtitle": "Enter one of your recovery codes (format: XXXX-XXXX).",
|
||||
"mfa.use_totp": "Use authenticator code",
|
||||
"mfa.status_label": "Two-Factor Authentication",
|
||||
"mfa.enabled": "Enabled",
|
||||
"mfa.disabled": "Disabled",
|
||||
"mfa.enabled_desc": "Your account is protected by two-factor authentication.",
|
||||
"mfa.setup_desc": "Add an extra layer of security to your account with TOTP.",
|
||||
"mfa.enable_btn": "Enable 2FA",
|
||||
"mfa.disable_btn": "Disable 2FA",
|
||||
"mfa.scan_qr": "Scan this QR code with your authenticator app",
|
||||
"mfa.manual_entry": "Manual entry (if you can't scan)",
|
||||
"mfa.enter_code": "Enter the 6-digit code to confirm",
|
||||
"mfa.confirm_enable": "Confirm & Enable",
|
||||
"mfa.recovery_codes_title": "Recovery Codes",
|
||||
"mfa.recovery_codes_warning": "Save these codes in a secure place. Each code can only be used once.",
|
||||
"mfa.copy_codes": "Copy",
|
||||
"mfa.download_codes": "Download",
|
||||
"mfa.done": "Done",
|
||||
"mfa.codes_copied": "Recovery codes copied!",
|
||||
"mfa.disable_confirm_title": "Disable 2FA",
|
||||
"mfa.disable_confirm_desc": "Enter your password and a valid TOTP code to disable two-factor authentication.",
|
||||
"mfa.password_label": "Password",
|
||||
"mfa.password_placeholder": "Your current password",
|
||||
"mfa.totp_code_label": "TOTP Code",
|
||||
"mfa.disable_confirm_btn": "Disable 2FA",
|
||||
"mfa.disabled_success": "2FA has been disabled.",
|
||||
"mfa.fill_all_fields": "Please fill in all fields.",
|
||||
"bookslm.title": "BooksLM",
|
||||
"bookslm.files_indexed": "{count} files indexed",
|
||||
"bookslm.chars_loaded": "{chars} chars loaded",
|
||||
"bookslm.placeholder": "Ask a question about these documents...",
|
||||
"bookslm.send": "Send",
|
||||
"bookslm.new_conversation": "New conversation",
|
||||
"bookslm.export": "Export conversation",
|
||||
"bookslm.toggle_sidebar": "Hide/Show AI sidebar",
|
||||
"bookslm.copy": "Copy",
|
||||
"bookslm.error": "AI service error",
|
||||
"bookslm.regenerate": "Regenerate",
|
||||
"bookslm.suggestion_summary": "Summarize this directory",
|
||||
"bookslm.suggestion_themes": "What are the main themes?",
|
||||
"bookslm.suggestion_contradictions": "Are there contradictions between these documents?",
|
||||
"bookslm.no_context": "No files found in this directory",
|
||||
"bookslm.context_too_large": "Directory too large — some files were truncated",
|
||||
"bookslm.source": "Source",
|
||||
"palette.bookslm_open": "BooksLM: Open for current directory",
|
||||
"palette.bookslm_open_desc": "Opens the AI assistant for the current context (open documents or general)",
|
||||
"palette.bookslm_new": "BooksLM: New conversation",
|
||||
"palette.bookslm_new_desc": "Resets the AI assistant conversation",
|
||||
"fab.open": "Open AI assistant",
|
||||
"fab.close": "Close AI assistant",
|
||||
"mfa.webauthn_title": "Security keys (WebAuthn)",
|
||||
"mfa.webauthn_desc": "Authenticate with a physical key (YubiKey) or your device biometrics (Windows Hello, Touch ID).",
|
||||
"mfa.webauthn_none": "No registered keys.",
|
||||
"mfa.webauthn_add": "Add a security key",
|
||||
"mfa.webauthn_label_prompt": "Key name (e.g. Pocket YubiKey)",
|
||||
"mfa.webauthn_added": "Security key registered.",
|
||||
"mfa.webauthn_removed": "Security key removed.",
|
||||
"mfa.webauthn_remove": "Remove",
|
||||
"mfa.webauthn_remove_confirm": "Enter your password to remove this key:",
|
||||
"mfa.webauthn_prompt": "Present your security key or confirm with Windows Hello.",
|
||||
"mfa.webauthn_btn": "Verify with my key",
|
||||
"mfa.webauthn_cancelled": "WebAuthn ceremony cancelled.",
|
||||
"mfa.webauthn_no_key": "No security key registered for this account.",
|
||||
"plugins.title": "🧩 Plugins",
|
||||
"plugins.description": "Extend ObsiGate with custom renderers, search filters, and editor actions.",
|
||||
"plugins.install": "Install Plugin",
|
||||
"plugins.template": "View Template",
|
||||
"plugins.none": "No plugins installed.",
|
||||
"plugins.manifest_json": "Manifest (plugin.json)",
|
||||
"plugins.code": "Plugin Code (main file)",
|
||||
"plugins.view_code": "View Code",
|
||||
"plugins.uninstall": "Uninstall",
|
||||
"plugins.enable": "Enable",
|
||||
"plugins.disable": "Disable",
|
||||
"plugins.enabled": "Plugin '{name}' enabled.",
|
||||
"plugins.disabled": "Plugin '{name}' disabled.",
|
||||
"plugins.installed": "Plugin '{name}' installed successfully.",
|
||||
"plugins.uninstalled": "Plugin '{name}' uninstalled.",
|
||||
"plugins.confirm_uninstall": "Uninstall plugin '{name}'? This cannot be undone.",
|
||||
"help.plugins_title": "🧩 Plugins",
|
||||
"help.plugins_intro": "Plugins extend ObsiGate: custom file rendering, search filters, editor actions, widgets and integrations. They run in sandboxed Web Workers — isolated from the page for security.",
|
||||
"help.plugins_install": "Installation",
|
||||
"help.plugins_install_1": "Open Settings → 🧩 Plugins.",
|
||||
"help.plugins_install_2": "Click 'Install a plugin'.",
|
||||
"help.plugins_install_3": "Select a .zip file containing the plugin.json manifest and the plugin code.",
|
||||
"help.plugins_install_4": "The manifest is validated (name, semver version, hooks, permissions) before installation.",
|
||||
"help.plugins_manage": "Management",
|
||||
"help.plugins_manage_1": "Enable / Disable: buttons on each plugin — hooks only run if the plugin is enabled.",
|
||||
"help.plugins_manage_2": "Uninstall: permanently removes the plugin from the vault.",
|
||||
"help.plugins_manage_3": "Code: view the source code of the installed plugin.",
|
||||
"help.plugins_dev": "Develop a plugin",
|
||||
"help.plugins_dev_intro": "Download the template from Settings → Plugins → 'Get template'. A plugin consists of a plugin.json manifest and a main file (e.g. index.js).",
|
||||
"help.plugins_hooks": "Available hooks",
|
||||
"help.plugins_hook_render": "onFileRender",
|
||||
"help.plugins_hook_render_desc": "transforms the displayed content of a file.",
|
||||
"help.plugins_hook_search": "onSearchFilter",
|
||||
"help.plugins_hook_search_desc": "filters search results.",
|
||||
"help.plugins_hook_editor": "onEditorAction",
|
||||
"help.plugins_hook_editor_desc": "intercepts editor actions.",
|
||||
"help.plugins_hook_sidebar": "onSidebarItem",
|
||||
"help.plugins_hook_sidebar_desc": "adds items to the sidebar.",
|
||||
"help.plugins_hook_file": "onFileCreate / onFileDelete",
|
||||
"help.plugins_hook_file_desc": "reacts to file creation / deletion.",
|
||||
"help.plugins_hook_vault": "onVaultMount",
|
||||
"help.plugins_hook_vault_desc": "runs when a vault is loaded.",
|
||||
"help.plugins_security": "Security",
|
||||
"help.plugins_security_1": "Web Worker sandbox: plugin code runs isolated, with no direct DOM access.",
|
||||
"help.plugins_security_2": "Permissions: each declared hook must be satisfied by the manifest permissions.",
|
||||
"help.plugins_security_3": "Secure packaging: the .zip is checked against path traversal on install.",
|
||||
"help.plugins_security_4": "Admin only: installation and removal require the admin role.",
|
||||
"help.plugins_template": "Use the <strong>Template</strong> button in Settings > Plugins to generate a valid starter plugin.",
|
||||
"help.excalidraw_title": "🎨 Excalidraw diagrams",
|
||||
"help.excalidraw_intro": "<code>.excalidraw</code> and <code>.excalidraw.md</code> files open in a full visual Excalidraw editor embedded in a sandboxed iframe.",
|
||||
"help.excalidraw_open_title": "Opening",
|
||||
"help.excalidraw_open": "Click a <code>.excalidraw</code> file in the tree: the editor opens instead of the markdown viewer.",
|
||||
"help.excalidraw_edit": "Draw, add shapes, text, arrows and images — the complete Excalidraw experience.",
|
||||
"help.excalidraw_save_title": "Saving",
|
||||
"help.excalidraw_save": "Changes are saved automatically (2s debounce) or with <strong>Ctrl+S</strong>. The editor follows the light/dark theme.",
|
||||
"help.excalidraw_search_title": "Search",
|
||||
"help.excalidraw_search": "Text inside diagram elements is extracted on indexing, so it is searchable via the full-text search.",
|
||||
"help.excalidraw_compat": "Files created with the Obsidian Excalidraw plugin (including the compressed <code>.excalidraw.md</code> format) are compatible.",
|
||||
"help.footer_tagline": "- Web gateway for your Obsidian vaults"
|
||||
}
|
||||
+218
-2
@@ -42,6 +42,19 @@
|
||||
"about.tests": "Tests",
|
||||
"about.vaults_configured": "Vaults configurés",
|
||||
"about.version": "Version",
|
||||
"admin.audit_all_actions": "Toutes les actions",
|
||||
"admin.audit_empty": "Aucun log disponible",
|
||||
"admin.audit_filter_action": "Action",
|
||||
"admin.audit_filter_user": "Filtrer par utilisateur",
|
||||
"admin.audit_refresh": "Rafraîchir",
|
||||
"admin.audit_title": "Logs d'audit",
|
||||
"admin.back_btn": "← Retour",
|
||||
"admin.backups_newest": "Plus récent : {days} jours",
|
||||
"admin.backups_oldest": "Plus ancien : {days} jours",
|
||||
"admin.backups_per_vault": "Par vault",
|
||||
"admin.backups_title": "Statistiques backups",
|
||||
"admin.backups_total": "Total : {count} backups ({size})",
|
||||
"admin.backups_vault_empty": "Aucun backup",
|
||||
"admin.close": "Fermer",
|
||||
"admin.col_actions": "Actions",
|
||||
"admin.col_last_login": "Dernière connexion",
|
||||
@@ -49,10 +62,36 @@
|
||||
"admin.col_status": "Statut",
|
||||
"admin.col_user": "Utilisateur",
|
||||
"admin.col_vaults": "Vaults",
|
||||
"admin.forbidden_msg": "Vous devez être administrateur pour accéder à cette page.",
|
||||
"admin.forbidden_title": "Accès refusé",
|
||||
"admin.header_subtitle": "Tableau de bord administrateur",
|
||||
"admin.new_user": "+ Nouvel utilisateur",
|
||||
"admin.page_title": "ObsiGate — Administration",
|
||||
"admin.stats_cpu": "CPU",
|
||||
"admin.stats_disk": "Disque",
|
||||
"admin.stats_memory": "Mémoire",
|
||||
"admin.stats_refresh": "Dernière mise à jour : {time}",
|
||||
"admin.stats_sessions": "Sessions actives",
|
||||
"admin.stats_title": "Statistiques temps réel",
|
||||
"admin.stats_uptime": "Uptime",
|
||||
"admin.title": "Administration — Utilisateurs",
|
||||
"admin.users_manage_btn": "Gérer les utilisateurs",
|
||||
"admin.users_manage_hint": "Ouvrir la modale de gestion des utilisateurs (CRUD complet)",
|
||||
"admin.users_title": "Gestion utilisateurs",
|
||||
"ai.action_applied": "Appliqué ✓",
|
||||
"ai.action_apply": "Appliquer",
|
||||
"ai.action_applying": "Application…",
|
||||
"ai.action_create_dir": "Créer le dossier {path}",
|
||||
"ai.action_create_file": "Créer le fichier {path}",
|
||||
"ai.action_created_dir": "Dossier créé : {path}",
|
||||
"ai.action_created_file": "Fichier créé : {path}",
|
||||
"ai.action_failed": "Échec de l'action : {error}",
|
||||
"ai.casual": "Ton décontracté",
|
||||
"ai.close": "Fermer l'assistant",
|
||||
"ai.completion_added": "AI: complétion ajoutée",
|
||||
"ai.context_documents": "{count} document(s) ouvert(s)",
|
||||
"ai.context_general": "Contexte général",
|
||||
"ai.context_general_desc": "Questions sur l'application et création de fichiers",
|
||||
"ai.continue": "Continuer",
|
||||
"ai.custom_rewrite": "Réécriture personnalisée",
|
||||
"ai.done": "AI: texte traité",
|
||||
@@ -60,7 +99,9 @@
|
||||
"ai.error_invalid_key": "clé API invalide",
|
||||
"ai.explain": "Expliquer",
|
||||
"ai.fix_spelling": "Corriger l'orthographe",
|
||||
"ai.fix_spelling_hint": "Corrige les fautes",
|
||||
"ai.frontmatter": "Générer frontmatter",
|
||||
"ai.fullscreen": "Plein écran",
|
||||
"ai.generate": "Générer",
|
||||
"ai.hint_add_details": "Ajoute des détails",
|
||||
"ai.hint_edit_selection": "Modifie le texte sélectionné",
|
||||
@@ -68,6 +109,7 @@
|
||||
"ai.hint_improve": "Améliore la qualité du texte",
|
||||
"ai.hint_translate": "Traduit le texte sélectionné",
|
||||
"ai.improve": "Améliorer",
|
||||
"ai.input_hint": "Entrée pour envoyer · Ctrl+Entrée pour un saut de ligne",
|
||||
"ai.inline_completion_title": "AI Inline Completion (Ctrl+J) — Complète le texte automatiquement",
|
||||
"ai.invalid_api_key": "AI: clé API invalide. Vérifiez DEEPSEEK_API_KEY dans .env",
|
||||
"ai.longer": "Allonger",
|
||||
@@ -75,20 +117,38 @@
|
||||
"ai.not_configured": "⚠️ AI non configuré — ajouter DEEPSEEK_API_KEY, OPENROUTER_API_KEY ou GEMINI_API_KEY dans .env",
|
||||
"ai.processing": "⏳ AI: traitement en cours...",
|
||||
"ai.professional": "Ton professionnel",
|
||||
"ai.provider": "Fournisseur",
|
||||
"ai.provider_default": "— défaut —",
|
||||
"ai.model": "Modèle",
|
||||
"ai.model_default": "— défaut —",
|
||||
"ai.model_loading": "Chargement…",
|
||||
"ai.model_offline": "hors ligne",
|
||||
"ai.model_load_error": "Erreur de chargement",
|
||||
"ai.model_settings": "Fournisseur & modèle",
|
||||
"ai.quota_exceeded": "AI: quota dépassé ou paiement requis",
|
||||
"ai.rewrite": "💬 Réécrire",
|
||||
"ai.rewrite_done": "AI: texte réécrit",
|
||||
"ai.rewrite_prompt": "Instruction de réécriture :",
|
||||
"ai.rewrite_placeholder": "Ex. : rends le ton plus formel et ajoute une conclusion",
|
||||
"ai.rewrite_title": "Réécrit le texte selon vos instructions",
|
||||
"ai.select_text": "AI: sélectionnez du texte à traiter",
|
||||
"ai.shorter": "Raccourcir",
|
||||
"ai.shorter_hint": "Rend le texte plus concis",
|
||||
"ai.simplify": "Simplifier",
|
||||
"ai.simplify_hint": "Simplifie le langage",
|
||||
"ai.suggestion_capabilities": "Que sais-tu faire ?",
|
||||
"ai.suggestion_create_file": "Crée un fichier de notes de réunion",
|
||||
"ai.suggestion_docs_contradictions": "Y a-t-il des contradictions entre ces documents ?",
|
||||
"ai.suggestion_docs_keypoints": "Quels sont les points clés de ces documents ?",
|
||||
"ai.suggestion_docs_summary": "Résume les documents ouverts",
|
||||
"ai.suggestion_search": "Comment rechercher efficacement dans mes notes ?",
|
||||
"ai.summarize": "Résumer",
|
||||
"ai.text_processed": "AI: texte traité ✓",
|
||||
"ai.text_rewritten": "AI: texte réécrit",
|
||||
"ai.to_canvas": "Convertir en Canvas",
|
||||
"ai.to_list": "Convertir en liste",
|
||||
"ai.to_table": "Convertir en tableau",
|
||||
"ai.tone_hint": "Change le ton du texte",
|
||||
"ai.toolbar_edit": "Éditer",
|
||||
"ai.toolbar_generate": "Générer",
|
||||
"ai.toolbar_rewrite": "Réécrire",
|
||||
@@ -96,6 +156,7 @@
|
||||
"ai.toolbar_toolbox": "Toolbox",
|
||||
"ai.toolbar_translate": "Traduire",
|
||||
"ai.toolbox": "🧰 Boîte",
|
||||
"ai.toolbox_hint": "Outils de conversion",
|
||||
"ai.translate_to": "Traduire en {lang}",
|
||||
"auth.cannot_delete_self": "Impossible de supprimer son propre compte",
|
||||
"auth.logged_out": "Déconnecté",
|
||||
@@ -425,6 +486,8 @@
|
||||
"config.section_vue-graphe": "🗺️ Vue Graphe",
|
||||
"config.section_watcher": "Synchronisation automatique",
|
||||
"config.section_webhooks": "🪝 Webhooks",
|
||||
"config.section_push": "🔔 Notifications push",
|
||||
"config.section_plugins": "🧩 Plugins",
|
||||
"config.section_workflow-recommande": "Workflow recommandé",
|
||||
"config.section_zone-de-contenu": "Zone de contenu",
|
||||
"config.server_config": "Configuration serveur",
|
||||
@@ -451,6 +514,8 @@
|
||||
"config.webhook_url_placeholder": "URL",
|
||||
"config.webhooks": "Webhooks",
|
||||
"config.webhooks_desc": "Notifications HTTP vers des services externes lors des changements de fichiers.",
|
||||
"config.plugins": "Plugins",
|
||||
"config.plugins_desc": "Gérer les plugins installés pour ce vault.",
|
||||
"config.workers_label": "Workers de recherche",
|
||||
"confirm.delete_dir": "Supprimer ce dossier et tout son contenu ?",
|
||||
"confirm.delete_dir_named": "Supprimer \"{path}\" et TOUT son contenu ?",
|
||||
@@ -586,6 +651,8 @@
|
||||
"header.menu_about_desc": "Version, crédits & info",
|
||||
"header.menu_admin": "Administration",
|
||||
"header.menu_admin_desc": "Gérer les utilisateurs",
|
||||
"header.menu_api": "API",
|
||||
"header.menu_api_desc": "Documentation OpenAPI",
|
||||
"header.menu_config": "Configurations",
|
||||
"header.menu_config_desc": "Paramètres de l'application",
|
||||
"header.menu_help": "Guide d'utilisation",
|
||||
@@ -1037,6 +1104,8 @@
|
||||
"help.nav_tags": "🏷️ Tags",
|
||||
"help.nav_tips": "💡 Astuces",
|
||||
"help.nav_webhooks": "🪝 Webhooks",
|
||||
"help.nav_plugins": "🧩 Plugins",
|
||||
"help.nav_excalidraw": "🎨 Excalidraw",
|
||||
"help.new_file": "Nouveau fichier",
|
||||
"help.new_folder": "Nouveau dossier",
|
||||
"help.nights": "nuits blanches 😅",
|
||||
@@ -1303,6 +1372,7 @@
|
||||
"settings.search_placeholder": "Rechercher...",
|
||||
"settings.sync": "🔄 Synchronisation",
|
||||
"settings.themes": "🎨 Thèmes",
|
||||
"settings.plugins": "🧩 Plugins",
|
||||
"share.copied": "Lien copié !",
|
||||
"share.copy_link": "Copier le lien",
|
||||
"share.create": "Créer un lien de partage",
|
||||
@@ -1377,7 +1447,14 @@
|
||||
"theme.change": "Changer le thème",
|
||||
"theme.dark": "Sombre",
|
||||
"theme.light": "Clair",
|
||||
"theme.high_contrast": "Contraste élevé",
|
||||
"theme.sepia": "Sépia",
|
||||
"theme.title": "Thème",
|
||||
"theme.export_all": "Exporter les thèmes",
|
||||
"theme.import": "Importer un thème",
|
||||
"theme.import_success": "{count} thème(s) importé(s)",
|
||||
"theme.import_error": "Erreur d'importation",
|
||||
"theme.delete_confirm": "Supprimer ce thème personnalisé ?",
|
||||
"themes.name_gradients": "Degrades subtils",
|
||||
"themes.name_terminal": "Noir pur & vert",
|
||||
"toast.ai_keys_saved": "Clés API sauvegardées",
|
||||
@@ -1465,6 +1542,14 @@
|
||||
"viewer.download_pdf": "Télécharger en PDF",
|
||||
"viewer.edit": "Éditer",
|
||||
"viewer.error": "Erreur de chargement",
|
||||
"viewer.export": "Exporter",
|
||||
"viewer.export_done": "Export terminé",
|
||||
"viewer.export_epub": "Exporter en ePub",
|
||||
"viewer.export_error": "Erreur d'export :",
|
||||
"viewer.export_html": "Exporter en HTML",
|
||||
"viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)",
|
||||
"viewer.export_start": "Export en cours...",
|
||||
"viewer.export_title": "Exporter le document",
|
||||
"viewer.forge_brand": "Forge",
|
||||
"viewer.forge_title": "Forge (nouvel éditeur)",
|
||||
"viewer.index_start": "Démarrage index.",
|
||||
@@ -1495,5 +1580,136 @@
|
||||
"webhook.trigger_dir_create": "Création de dossier via API",
|
||||
"webhook.trigger_dir_delete": "Suppression de dossier via l'API",
|
||||
"webhook.trigger_dir_rename": "Renommage de dossier via l'API",
|
||||
"webhook.trigger_rename": "Renommage via l'API"
|
||||
}
|
||||
"webhook.trigger_rename": "Renommage via l'API",
|
||||
"settings.security": "🔒 Sécurité du compte",
|
||||
"settings.security_desc": "Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.",
|
||||
"mfa.title": "Authentification à deux facteurs",
|
||||
"mfa.subtitle": "Entrez le code à 6 chiffres de votre application d'authentification.",
|
||||
"mfa.verify": "Vérifier",
|
||||
"mfa.verifying": "Vérification...",
|
||||
"mfa.use_recovery": "Utiliser un code de récupération",
|
||||
"mfa.back_to_login": "Retour à la connexion",
|
||||
"mfa.recovery_title": "Code de récupération",
|
||||
"mfa.recovery_subtitle": "Entrez l'un de vos codes de récupération (format : XXXX-XXXX).",
|
||||
"mfa.use_totp": "Utiliser le code authenticator",
|
||||
"mfa.status_label": "Authentification à deux facteurs",
|
||||
"mfa.enabled": "Activée",
|
||||
"mfa.disabled": "Désactivée",
|
||||
"mfa.enabled_desc": "Votre compte est protégé par l'authentification à deux facteurs.",
|
||||
"mfa.setup_desc": "Ajoutez une couche de sécurité supplémentaire à votre compte avec le TOTP.",
|
||||
"mfa.enable_btn": "Activer la 2FA",
|
||||
"mfa.disable_btn": "Désactiver la 2FA",
|
||||
"mfa.scan_qr": "Scannez ce QR code avec votre application d'authentification",
|
||||
"mfa.manual_entry": "Saisie manuelle (si vous ne pouvez pas scanner)",
|
||||
"mfa.enter_code": "Entrez le code à 6 chiffres pour confirmer",
|
||||
"mfa.confirm_enable": "Confirmer et activer",
|
||||
"mfa.recovery_codes_title": "Codes de récupération",
|
||||
"mfa.recovery_codes_warning": "Enregistrez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois.",
|
||||
"mfa.copy_codes": "Copier",
|
||||
"mfa.download_codes": "Télécharger",
|
||||
"mfa.done": "Terminé",
|
||||
"mfa.codes_copied": "Codes de récupération copiés !",
|
||||
"mfa.disable_confirm_title": "Désactiver la 2FA",
|
||||
"mfa.disable_confirm_desc": "Entrez votre mot de passe et un code TOTP valide pour désactiver l'authentification à deux facteurs.",
|
||||
"mfa.password_label": "Mot de passe",
|
||||
"mfa.password_placeholder": "Votre mot de passe actuel",
|
||||
"mfa.totp_code_label": "Code TOTP",
|
||||
"mfa.disable_confirm_btn": "Désactiver la 2FA",
|
||||
"mfa.disabled_success": "La 2FA a été désactivée.",
|
||||
"mfa.fill_all_fields": "Veuillez remplir tous les champs.",
|
||||
"bookslm.title": "BooksLM",
|
||||
"bookslm.files_indexed": "{count} fichiers indexés",
|
||||
"bookslm.chars_loaded": "{chars} caractères chargés",
|
||||
"bookslm.placeholder": "Posez une question sur ces documents...",
|
||||
"bookslm.send": "Envoyer",
|
||||
"bookslm.new_conversation": "Nouvelle conversation",
|
||||
"bookslm.export": "Exporter la conversation",
|
||||
"bookslm.toggle_sidebar": "Masquer/Afficher la sidebar AI",
|
||||
"bookslm.copy": "Copier",
|
||||
"bookslm.error": "Erreur du service AI",
|
||||
"bookslm.regenerate": "Régénérer",
|
||||
"bookslm.suggestion_summary": "Résume ce répertoire",
|
||||
"bookslm.suggestion_themes": "Quels sont les thèmes principaux ?",
|
||||
"bookslm.suggestion_contradictions": "Y a-t-il des contradictions entre ces documents ?",
|
||||
"bookslm.no_context": "Aucun fichier trouvé dans ce répertoire",
|
||||
"bookslm.context_too_large": "Répertoire trop volumineux — certains fichiers ont été tronqués",
|
||||
"bookslm.source": "Source",
|
||||
"palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant",
|
||||
"palette.bookslm_open_desc": "Ouvre l'assistant AI pour le contexte courant (documents ouverts ou général)",
|
||||
"palette.bookslm_new": "BooksLM: Nouvelle conversation",
|
||||
"palette.bookslm_new_desc": "Réinitialise la conversation de l'assistant AI",
|
||||
"fab.open": "Ouvrir l'assistant AI",
|
||||
"fab.close": "Fermer l'assistant AI",
|
||||
"mfa.webauthn_title": "Clés de sécurité (WebAuthn)",
|
||||
"mfa.webauthn_desc": "Authentifiez-vous avec une clé physique (YubiKey) ou la biométrie de votre appareil (Windows Hello, Touch ID).",
|
||||
"mfa.webauthn_none": "Aucune clé enregistrée.",
|
||||
"mfa.webauthn_add": "Ajouter une clé de sécurité",
|
||||
"mfa.webauthn_label_prompt": "Nom de la clé (ex : YubiKey de poche)",
|
||||
"mfa.webauthn_added": "Clé de sécurité enregistrée.",
|
||||
"mfa.webauthn_removed": "Clé de sécurité supprimée.",
|
||||
"mfa.webauthn_remove": "Retirer",
|
||||
"mfa.webauthn_remove_confirm": "Entrez votre mot de passe pour retirer cette clé :",
|
||||
"mfa.webauthn_prompt": "Présentez votre clé de sécurité ou confirmez avec Windows Hello.",
|
||||
"mfa.webauthn_btn": "Valider avec ma clé",
|
||||
"mfa.webauthn_cancelled": "Cérémonie WebAuthn annulée.",
|
||||
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte.",
|
||||
"plugins.title": "🧩 Plugins",
|
||||
"plugins.description": "Étendez ObsiGate avec des renderers personnalisés, filtres de recherche et actions d'éditeur.",
|
||||
"plugins.install": "Installer le plugin",
|
||||
"plugins.template": "Voir le modèle",
|
||||
"plugins.none": "Aucun plugin installé.",
|
||||
"plugins.manifest_json": "Manifeste (plugin.json)",
|
||||
"plugins.code": "Code du plugin (fichier principal)",
|
||||
"plugins.view_code": "Voir le code",
|
||||
"plugins.uninstall": "Désinstaller",
|
||||
"plugins.enable": "Activer",
|
||||
"plugins.disable": "Désactiver",
|
||||
"plugins.enabled": "Plugin '{name}' activé.",
|
||||
"plugins.disabled": "Plugin '{name}' désactivé.",
|
||||
"plugins.installed": "Plugin '{name}' installé avec succès.",
|
||||
"plugins.uninstalled": "Plugin '{name}' désinstallé.",
|
||||
"plugins.confirm_uninstall": "Désinstaller le plugin '{name}' ? Cette action est irréversible.",
|
||||
"help.plugins_title": "🧩 Plugins",
|
||||
"help.plugins_intro": "Les plugins étendent ObsiGate : affichage personnalisé des fichiers, filtres de recherche, actions d'édition, widgets et intégrations. Ils s'exécutent dans des Web Workers sandboxés — isolés de la page pour la sécurité.",
|
||||
"help.plugins_install": "Installation",
|
||||
"help.plugins_install_1": "Ouvrez Réglages → 🧩 Plugins.",
|
||||
"help.plugins_install_2": "Cliquez « Installer un plugin ».",
|
||||
"help.plugins_install_3": "Sélectionnez un fichier .zip contenant le manifest plugin.json et le code du plugin.",
|
||||
"help.plugins_install_4": "Le manifest est validé (nom, version semver, hooks, permissions) avant installation.",
|
||||
"help.plugins_manage": "Gestion",
|
||||
"help.plugins_manage_1": "Activer / Désactiver : boutons sur chaque plugin — les hooks ne tournent que si le plugin est activé.",
|
||||
"help.plugins_manage_2": "Désinstaller : supprime définitivement le plugin du vault.",
|
||||
"help.plugins_manage_3": "Code : visualisez le code source du plugin installé.",
|
||||
"help.plugins_dev": "Développer un plugin",
|
||||
"help.plugins_dev_intro": "Téléchargez le modèle depuis Réglages → Plugins → « Obtenir le modèle ». Un plugin se compose d'un manifest plugin.json et d'un fichier principal (ex. index.js).",
|
||||
"help.plugins_hooks": "Hooks disponibles",
|
||||
"help.plugins_hook_render": "onFileRender",
|
||||
"help.plugins_hook_render_desc": "transforme le contenu affiché d'un fichier.",
|
||||
"help.plugins_hook_search": "onSearchFilter",
|
||||
"help.plugins_hook_search_desc": "filtre les résultats de recherche.",
|
||||
"help.plugins_hook_editor": "onEditorAction",
|
||||
"help.plugins_hook_editor_desc": "intercepte les actions de l'éditeur.",
|
||||
"help.plugins_hook_sidebar": "onSidebarItem",
|
||||
"help.plugins_hook_sidebar_desc": "ajoute des éléments à la barre latérale.",
|
||||
"help.plugins_hook_file": "onFileCreate / onFileDelete",
|
||||
"help.plugins_hook_file_desc": "réagit à la création / suppression de fichiers.",
|
||||
"help.plugins_hook_vault": "onVaultMount",
|
||||
"help.plugins_hook_vault_desc": "exécuté au chargement d'un vault.",
|
||||
"help.plugins_security": "Sécurité",
|
||||
"help.plugins_security_1": "Sandbox Web Worker : le code plugin tourne isolé, sans accès direct au DOM.",
|
||||
"help.plugins_security_2": "Permissions : chaque hook déclaré doit être satisfait par les permissions du manifest.",
|
||||
"help.plugins_security_3": "Empaquetage sécurisé : le .zip est vérifié contre le path traversal à l'installation.",
|
||||
"help.plugins_security_4": "Admin uniquement : l'installation et la suppression requièrent le rôle admin.",
|
||||
"help.plugins_template": "Utilisez le bouton <strong>Template</strong> dans Paramètres > Plugins pour générer un plugin starter valide.",
|
||||
"help.excalidraw_title": "🎨 Diagrammes Excalidraw",
|
||||
"help.excalidraw_intro": "Les fichiers <code>.excalidraw</code> et <code>.excalidraw.md</code> s'ouvrent dans un éditeur visuel Excalidraw complet, intégré dans une iframe sandboxée.",
|
||||
"help.excalidraw_open_title": "Ouverture",
|
||||
"help.excalidraw_open": "Cliquez sur un fichier <code>.excalidraw</code> dans l'arborescence : l'éditeur s'ouvre à la place du viewer markdown.",
|
||||
"help.excalidraw_edit": "Dessinez, ajoutez des formes, du texte, des flèches et des images — l'expérience Excalidraw complète.",
|
||||
"help.excalidraw_save_title": "Sauvegarde",
|
||||
"help.excalidraw_save": "Les modifications sont sauvegardées automatiquement (2 s) ou avec <strong>Ctrl+S</strong>. L'éditeur suit le thème clair/sombre.",
|
||||
"help.excalidraw_search_title": "Recherche",
|
||||
"help.excalidraw_search": "Le texte des éléments du diagramme est extrait à l'indexation : il est donc recherchable via la recherche full-text.",
|
||||
"help.excalidraw_compat": "Les fichiers créés avec le plugin Obsidian Excalidraw (y compris le format <code>.excalidraw.md</code> compressé) sont compatibles.",
|
||||
"help.footer_tagline": "- Porte d'entrée web pour vos vaults Obsidian"
|
||||
}
|
||||
+357
-7
@@ -522,15 +522,16 @@ a:hover {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Version badge in header */
|
||||
/* Version badge in header — always visible */
|
||||
.version-badge {
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--text-muted) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--text-secondary) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--text-secondary) 20%, transparent);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
@@ -2398,6 +2399,34 @@ select {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
/* Theme import/export actions */
|
||||
.theme-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.theme-action-btn {
|
||||
padding: 5px 14px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.theme-action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.theme-custom-badge {
|
||||
font-size: 0.6rem;
|
||||
color: var(--accent);
|
||||
margin-left: 2px;
|
||||
}
|
||||
/* Profile section */
|
||||
.profile-form { max-width: 420px; }
|
||||
.profile-field { margin-bottom: 14px; }
|
||||
@@ -2646,6 +2675,88 @@ select {
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* AI custom-rewrite modal — replaces the browser prompt() for a consistent,
|
||||
theme-aware and keyboard-accessible experience. */
|
||||
.ai-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.ai-modal {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 18px 14px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ai-modal-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-modal-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-modal-input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 72px;
|
||||
padding: 8px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-modal-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
.ai-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ai-modal-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-modal-btn:hover { background: var(--bg-hover); }
|
||||
|
||||
.ai-modal-btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ai-modal-btn.primary:hover { filter: brightness(1.08); }
|
||||
|
||||
.editor-title {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.9rem;
|
||||
@@ -3805,6 +3916,12 @@ body.resizing-v {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-select option {
|
||||
background: var(--bg-input, #1a1a2e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.config-btn-add {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--accent);
|
||||
@@ -8878,3 +8995,236 @@ body.popup-mode .content-area {
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── MFA (Multi-Factor Authentication) ────────────────────────────────── */
|
||||
|
||||
.mfa-challenge {
|
||||
margin-top: 20px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface, #1a1a2e);
|
||||
border: 1px solid var(--border, #333);
|
||||
text-align: center;
|
||||
}
|
||||
.mfa-challenge .mfa-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.mfa-challenge h3 { margin: 0 0 8px; color: var(--text, #fff); }
|
||||
.mfa-challenge p { color: var(--muted, #999); margin: 0 0 16px; }
|
||||
.mfa-code-input {
|
||||
width: 180px;
|
||||
font-size: 28px;
|
||||
text-align: center;
|
||||
letter-spacing: 12px;
|
||||
padding: 12px;
|
||||
border: 2px solid var(--border, #444);
|
||||
border-radius: 8px;
|
||||
background: var(--bg, #0d0d1a);
|
||||
color: var(--text, #fff);
|
||||
font-family: monospace;
|
||||
outline: none;
|
||||
}
|
||||
.mfa-code-input:focus { border-color: var(--accent, #7C3AED); }
|
||||
.mfa-code-input-group { display: flex; gap: 8px; justify-content: center; align-items: center; margin-bottom: 12px; }
|
||||
.mfa-code-input-group span { color: var(--muted, #999); font-size: 24px; }
|
||||
.mfa-link-btn {
|
||||
background: none; border: none; color: var(--accent, #7C3AED);
|
||||
cursor: pointer; font-size: 13px; text-decoration: underline;
|
||||
}
|
||||
.mfa-link-btn:hover { opacity: 0.8; }
|
||||
.mfa-error { color: #e74c3c; font-size: 13px; margin-top: 8px; }
|
||||
.mfa-recovery-input { width: 200px; font-size: 20px; letter-spacing: 4px; }
|
||||
|
||||
/* MFA Settings (Security tab) */
|
||||
.mfa-status-section { padding: 16px 0; }
|
||||
.mfa-status-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.mfa-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.mfa-badge-on { background: #1a3a2a; color: #4ade80; }
|
||||
.mfa-badge-off { background: #3a2a1a; color: #f59e0b; }
|
||||
.mfa-status-label { color: var(--text, #fff); font-weight: 500; }
|
||||
|
||||
/* MFA Setup card */
|
||||
.mfa-setup-card {
|
||||
padding: 20px; border-radius: 10px;
|
||||
background: var(--surface2, #1e1e3a); border: 1px solid var(--border, #333);
|
||||
}
|
||||
.mfa-setup-card h4 { margin: 0 0 12px; color: var(--text, #fff); }
|
||||
.mfa-qr-container { text-align: center; margin: 16px 0; }
|
||||
.mfa-qr-code img, .mfa-qr-code canvas { max-width: 200px; border-radius: 8px; }
|
||||
.mfa-secret-details { margin-top: 12px; }
|
||||
.mfa-secret-code {
|
||||
font-family: monospace; font-size: 14px; padding: 8px 12px;
|
||||
background: var(--bg, #0d0d1a); border-radius: 6px;
|
||||
color: var(--accent, #7C3AED); cursor: pointer; user-select: all;
|
||||
display: inline-block;
|
||||
}
|
||||
.mfa-info-text { color: var(--muted, #999); font-size: 13px; margin: 8px 0; }
|
||||
.mfa-subtitle { color: var(--muted, #999); font-size: 13px; margin: 0 0 16px; }
|
||||
.mfa-verify-section { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
|
||||
.mfa-actions { margin-top: 16px; }
|
||||
|
||||
/* MFA Recovery codes display */
|
||||
.mfa-recovery-card {
|
||||
padding: 20px; border-radius: 10px;
|
||||
background: var(--surface2, #1e1e3a); border: 1px solid var(--border, #333);
|
||||
margin-top: 16px;
|
||||
}
|
||||
.mfa-recovery-card h4 { margin: 0 0 8px; color: var(--text, #fff); }
|
||||
.mfa-recovery-list {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 24px;
|
||||
margin: 12px 0; list-style: none; padding: 0;
|
||||
}
|
||||
.mfa-recovery-code {
|
||||
font-family: monospace; font-size: 14px; padding: 4px 8px;
|
||||
background: var(--bg, #0d0d1a); border-radius: 4px;
|
||||
color: var(--text, #fff); letter-spacing: 1px;
|
||||
}
|
||||
.mfa-recovery-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
|
||||
/* WebAuthn security keys (ROADMAP #64) */
|
||||
.webauthn-settings { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border, #333); }
|
||||
.webauthn-title { margin: 0 0 6px; color: var(--text, #fff); font-size: 0.95rem; }
|
||||
.webauthn-key-list { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.webauthn-key-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
|
||||
background: var(--surface2, #1a1a2e); border-radius: 6px;
|
||||
}
|
||||
.webauthn-key-label { flex: 1; font-size: 0.9rem; color: var(--text, #fff); }
|
||||
.webauthn-key-meta { font-size: 0.75rem; color: var(--text-muted, #888); }
|
||||
.config-btn-sm { padding: 4px 10px; font-size: 0.78rem; }
|
||||
|
||||
/* MFA Disable card */
|
||||
.mfa-disable-card {
|
||||
padding: 16px; border-radius: 10px;
|
||||
background: var(--surface2, #1e1e3a); border: 1px solid #e74c3c33;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.mfa-disable-card h4 { margin: 0 0 8px; color: #e74c3c; }
|
||||
.mfa-disable-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
|
||||
.mfa-warning {
|
||||
color: #f59e0b; font-size: 13px; padding: 8px 12px;
|
||||
background: #3a2a1a; border-radius: 6px; margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── BooksLM Panel ──────────────────────────────────── */
|
||||
.bookslm-panel { position: fixed; right: 0; top: 0; bottom: 0; width: 450px;
|
||||
background: var(--bg-primary); border-left: 1px solid var(--border);
|
||||
z-index: 100; display: flex; flex-direction: column;
|
||||
transform: translateX(100%); transition: transform 300ms ease-out;
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,0.3);
|
||||
color: var(--text-primary); }
|
||||
.bookslm-panel.open { transform: translateX(0); }
|
||||
/* The `.hidden` class is applied by the header toggle button to collapse the
|
||||
panel while keeping it mounted (so the toggle icon stays usable). When
|
||||
hidden, the panel is slid fully off-screen and the open animation no
|
||||
longer overrides the translateX. */
|
||||
.bookslm-panel.open.hidden { transform: translateX(100%); }
|
||||
.bookslm-btn-toggle {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--text-secondary); padding: 4px;
|
||||
border-radius: 4px; flex-shrink: 0;
|
||||
transition: color 200ms ease, background 200ms ease;
|
||||
}
|
||||
.bookslm-btn-toggle:hover { color: var(--accent); background: var(--surface2); }
|
||||
.bookslm-header { display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.bookslm-header .bookslm-context-icon { display: flex; color: var(--accent); flex-shrink: 0; }
|
||||
.bookslm-header-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
|
||||
.bookslm-header-titles .bookslm-title { font-size: 13px; font-weight: 600;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookslm-header-titles .bookslm-subtitle { font-size: 11px; color: var(--text-secondary);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookslm-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
||||
.bookslm-header button { background: none; border: none; cursor: pointer;
|
||||
color: var(--text-secondary); padding: 5px; border-radius: 6px; display: flex; align-items: center; }
|
||||
.bookslm-header button:hover { background: var(--surface2); color: var(--text-primary); }
|
||||
/* Provider/model picker on its own row so the header actions never get hidden. */
|
||||
.bookslm-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); }
|
||||
.bookslm-toolbar-label { font-size: 11px; color: var(--text-secondary);
|
||||
text-transform: uppercase; letter-spacing: .04em; flex-shrink: 0; }
|
||||
.bookslm-toolbar .ai-picker { margin-left: 0; padding-left: 0; border-left: none;
|
||||
flex-wrap: wrap; row-gap: 4px; }
|
||||
.bookslm-toolbar .ai-picker select { max-width: 200px; }
|
||||
.bookslm-panel.fullscreen { width: 100vw; }
|
||||
.bookslm-status { padding: 6px 16px; font-size: 12px; color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border); display: flex; gap: 12px; align-items: center; }
|
||||
.bookslm-status .bookslm-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookslm-status .bookslm-status-error { color: #f87171; }
|
||||
.bookslm-status .bookslm-context-bar { flex: 1; height: 4px; border-radius: 2px;
|
||||
background: var(--surface2); overflow: hidden; }
|
||||
.bookslm-status .bookslm-context-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; }
|
||||
.bookslm-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.bookslm-bubble { max-width: 85%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.5; word-wrap: break-word; }
|
||||
.bookslm-bubble.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
|
||||
.bookslm-bubble.assistant { align-self: flex-start; background: var(--surface2); color: var(--text-primary); border-bottom-left-radius: 4px; }
|
||||
.bookslm-bubble.assistant code { background: rgba(0,0,0,0.2); padding: 1px 4px; border-radius: 3px; font-size: 0.9em; }
|
||||
.bookslm-bubble.assistant pre { background: rgba(0,0,0,0.3); padding: 10px; border-radius: 6px; overflow-x: auto; margin: 8px 0; }
|
||||
.bookslm-sources { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
|
||||
.bookslm-source-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
font-size: 11px; color: var(--accent); cursor: pointer; }
|
||||
.bookslm-source-badge:hover { background: var(--surface2); }
|
||||
.bookslm-input-area { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); align-items: flex-end; }
|
||||
.bookslm-input-area textarea { flex: 1; resize: none; min-height: 36px; max-height: 120px;
|
||||
padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--bg-primary); color: var(--text-primary); font-size: 14px; font-family: inherit; }
|
||||
.bookslm-input-area textarea:focus { border-color: var(--accent); outline: none; }
|
||||
.bookslm-input-area button { padding: 8px 16px; border-radius: 8px; border: none;
|
||||
background: var(--accent); color: #fff; cursor: pointer; font-size: 14px; }
|
||||
.bookslm-input-area button:hover { opacity: 0.9; }
|
||||
.bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.bookslm-input-hint { padding: 0 16px 10px; font-size: 10px; color: var(--text-secondary);
|
||||
text-align: right; border-top: none; }
|
||||
/* Action cards proposed by the General assistant (file/dir creation). */
|
||||
.bookslm-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||||
.bookslm-action { display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||
border: 1px solid var(--border); border-radius: 8px; background: var(--bg-primary); }
|
||||
.bookslm-action.done { opacity: 0.65; }
|
||||
.bookslm-action-meta { flex: 1; display: flex; align-items: center; gap: 6px; font-size: 12px;
|
||||
color: var(--text-primary); word-break: break-word; }
|
||||
.bookslm-action-icon { flex-shrink: 0; }
|
||||
.bookslm-action-apply { padding: 5px 12px; border-radius: 6px; border: none;
|
||||
background: var(--accent); color: #fff; cursor: pointer; font-size: 12px; flex-shrink: 0; }
|
||||
.bookslm-action-apply:disabled { opacity: 0.6; cursor: default; }
|
||||
.bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
|
||||
.bookslm-suggestions { display: flex; flex-direction: column; gap: 6px; padding: 8px 16px 0; }
|
||||
.bookslm-suggestion { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; }
|
||||
.bookslm-suggestion:hover { background: var(--surface2); color: var(--text-primary); border-color: var(--accent); }
|
||||
@media (max-width: 768px) {
|
||||
.bookslm-panel { width: 100%; }
|
||||
}
|
||||
|
||||
/* ── AI Floating Action Button ───────────────────────── */
|
||||
.ai-fab {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 99;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 200ms ease, opacity 200ms ease;
|
||||
padding: 0;
|
||||
}
|
||||
.ai-fab:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.ai-fab:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.ai-fab[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+172
-149
@@ -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('/'));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# ObsiGate — version bump helper (SemVer MAJOR.MINOR.PATCH)
|
||||
#
|
||||
# The canonical version is the latest release tag. This tool inspects the
|
||||
# commits since that tag and decides the next version from their type
|
||||
# (Conventional Commits), then creates and pushes the new tag:
|
||||
#
|
||||
# breaking change / `!:` / "BREAKING CHANGE" -> MAJOR bump (x.0.0)
|
||||
# feat / feature -> MINOR bump (x.y.0)
|
||||
# anything else (fix, perf, refactor, ...) -> PATCH bump (x.y.z)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/bump_version.sh [--major|--minor|--patch] [--dry-run] [--push]
|
||||
#
|
||||
# --dry-run : only print the computed next version, do not tag.
|
||||
# --push : also `git push origin` the new tag (default: local only).
|
||||
# --major|--minor|--patch : force the increment, overriding commit detection.
|
||||
#
|
||||
# Examples:
|
||||
# scripts/bump_version.sh --dry-run # preview next version
|
||||
# scripts/bump_version.sh # tag locally per commits
|
||||
# scripts/bump_version.sh --patch --push # force patch + push
|
||||
# -----------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)" # run from repo root regardless of cwd
|
||||
BRANCH="$(git branch --show-current)"
|
||||
|
||||
# ── Parse args ────────────────────────────────────────────────────────────
|
||||
FORCE=""; DRY_RUN=0; DO_PUSH=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--major) FORCE=major ;;
|
||||
--minor) FORCE=minor ;;
|
||||
--patch) FORCE=patch ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--push) DO_PUSH=1 ;;
|
||||
*) echo "✗ Argument inconnu: $a"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Current version = latest tag base ─────────────────────────────────────
|
||||
CURRENT="$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")"
|
||||
CURRENT="${CURRENT#v}"
|
||||
IFS='.' read -r MAJ MIN PAT <<< "$CURRENT"
|
||||
MAJ="${MAJ:-0}"; MIN="${MIN:-0}"; PAT="${PAT:-0}"
|
||||
|
||||
echo "Version actuelle : $CURRENT (branche: $BRANCH)"
|
||||
|
||||
# ── Detect bump type from commits since last tag ──────────────────────────
|
||||
if [ -z "$FORCE" ]; then
|
||||
# Use HEAD~1..HEAD when no tag exists yet (all commits "since" nothing).
|
||||
RANGE="${CURRENT#0.0.0}" # if 0.0.0 fallback there may be no tag at all
|
||||
if git rev-parse "v${CURRENT}" >/dev/null 2>&1; then
|
||||
RANGE="v${CURRENT}..HEAD"
|
||||
else
|
||||
RANGE="HEAD~10..HEAD"
|
||||
echo "⚠ Aucun tag v${CURRENT} trouvé — analyse des 10 derniers commits."
|
||||
fi
|
||||
LOG="$(git log --format='%s%n%b' "$RANGE" 2>/dev/null || true)"
|
||||
|
||||
BUMP="patch" # default
|
||||
if echo "$LOG" | grep -qE '^[a-zA-Z]+\([^)]*\)!:|\!:\s|BREAKING CHANGE:'; then
|
||||
BUMP="major"
|
||||
elif echo "$LOG" | grep -qiE '^(feat|feature)(\(|:|\s)'; then
|
||||
BUMP="minor"
|
||||
fi
|
||||
# If nothing meaningful in range, keep patch
|
||||
echo "Détection (${RANGE}) : changement ${BUMP}"
|
||||
else
|
||||
BUMP="$FORCE"
|
||||
echo "Incrément forcé : ${BUMP}"
|
||||
fi
|
||||
|
||||
# ── Compute next version ──────────────────────────────────────────────────
|
||||
case "$BUMP" in
|
||||
major) MAJ=$((MAJ + 1)); MIN=0; PAT=0 ;;
|
||||
minor) MIN=$((MIN + 1)); PAT=0 ;;
|
||||
*) PAT=$((PAT + 1)) ;;
|
||||
esac
|
||||
NEXT="${MAJ}.${MIN}.${PAT}"
|
||||
|
||||
# ── Guard: tag already exists? ────────────────────────────────────────────
|
||||
if git rev-parse "v${NEXT}" >/dev/null 2>&1; then
|
||||
echo "✗ Le tag v${NEXT} existe déjà. Corrigez l'incrément ou retirez le tag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "── DRY RUN ─────────────────────────────"
|
||||
echo " Prochain tag : v${NEXT}"
|
||||
echo " (aucun tag créé)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Create tag ────────────────────────────────────────────────────────────
|
||||
git tag -a "v${NEXT}" -m "Release v${NEXT} (${BUMP} bump depuis v${CURRENT})"
|
||||
echo "✓ Tag créé localement : v${NEXT}"
|
||||
|
||||
if [ "$DO_PUSH" = "1" ]; then
|
||||
git push origin "v${NEXT}"
|
||||
echo "✓ Tag poussé : v${NEXT}"
|
||||
else
|
||||
echo "ℹ Tag local uniquement. Poussez avec: git push origin v${NEXT}"
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
@@ -128,6 +128,13 @@ def make_gitea_request(
|
||||
if content_type:
|
||||
req.add_header("Content-Type", content_type)
|
||||
req.add_header("Accept", "application/json")
|
||||
# Cloudflare sur git.dracodev.net bloque les User-Agents "Python-urllib/*"
|
||||
# (HTTP 403 Error 1010) pour les écritures ; on présente un UA navigateur.
|
||||
req.add_header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, data=data) as resp:
|
||||
|
||||
@@ -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);
|
||||
Generated
+555
@@ -0,0 +1,555 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"jsdom": "^30.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
|
||||
"integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.3.0",
|
||||
"@csstools/css-color-parser": "^4.1.10",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
|
||||
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
|
||||
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz",
|
||||
"integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.1",
|
||||
"@csstools/css-calc": "^3.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz",
|
||||
"integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^6.0.5",
|
||||
"@asamuzakjp/dom-selector": "^8.3.0",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.9.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.11",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz",
|
||||
"integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.11"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.11",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz",
|
||||
"integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
|
||||
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
|
||||
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"jsdom": "^30.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM integration tests for PaneManager (ROADMAP #75 I2).
|
||||
*
|
||||
* Loads the actual pane-manager.js ES module in a JSDOM environment and
|
||||
* verifies DOM-level behavior: tab creation via open(), activation,
|
||||
* removal via close(), pane isolation, drag/drop smoke.
|
||||
*
|
||||
* Usage: node tests/frontend/pane-manager.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="content-wrapper">
|
||||
<div id="dashboard-home"></div>
|
||||
<div id="tab-bar" class="tab-bar">
|
||||
<div id="tab-list" class="tab-list"></div>
|
||||
</div>
|
||||
<div id="content-area"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true }
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.DragEvent = w.DragEvent || w.Event;
|
||||
globalThis.MouseEvent = w.MouseEvent || w.Event;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function $$(sel) {
|
||||
return Array.from(document.querySelectorAll(sel));
|
||||
}
|
||||
|
||||
function resetBody(html) {
|
||||
document.body.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Dynamic import (after globals are set) ──────────────────────────────────
|
||||
|
||||
const pm = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "pane-manager.js")).href
|
||||
);
|
||||
|
||||
const { createPaneTabManager, PaneManager, getActiveTabManager, getActiveContentArea } = pm;
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passCount++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test suite ──────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("\n── pane-manager.js JSDOM integration tests (#75 I2) ──\n");
|
||||
|
||||
await test("createPaneTabManager returns expected shape", () => {
|
||||
resetBody(`
|
||||
<div id="pane-t1"><div class="tab-bar"></div><div id="tl1" class="tab-list"></div><div class="content-area"></div></div>
|
||||
`);
|
||||
const tm = createPaneTabManager("t1");
|
||||
assert.equal(tm.paneId, "t1");
|
||||
assert.equal(typeof tm.open, "function");
|
||||
assert.equal(typeof tm.activate, "function");
|
||||
assert.equal(typeof tm.close, "function");
|
||||
assert.equal(typeof tm.init, "function");
|
||||
});
|
||||
|
||||
await test("init() binds tab bar / list / content area to provided DOM", () => {
|
||||
resetBody(`
|
||||
<div id="pane-t2">
|
||||
<div class="tab-bar"></div>
|
||||
<div id="tb2" class="tab-list"></div>
|
||||
<div id="ca2" class="content-area"></div>
|
||||
</div>
|
||||
`);
|
||||
const tm = createPaneTabManager("t2");
|
||||
const bar = document.querySelector("#pane-t2 .tab-bar");
|
||||
const list = document.querySelector("#tb2");
|
||||
const area = document.querySelector("#ca2");
|
||||
tm.init(bar, list, area);
|
||||
assert.equal(tm.getTabBar(), bar);
|
||||
assert.equal(tm.getTabList(), list);
|
||||
assert.equal(tm.getContentArea(), area);
|
||||
});
|
||||
|
||||
await test("open() appends a tab-item to the tab-list", async () => {
|
||||
resetBody(`
|
||||
<div id="pane-t3">
|
||||
<div class="tab-bar"></div>
|
||||
<div id="tl3" class="tab-list"></div>
|
||||
<div class="content-area"></div>
|
||||
</div>
|
||||
`);
|
||||
const tm = createPaneTabManager("t3");
|
||||
tm.init(
|
||||
document.querySelector("#pane-t3 .tab-bar"),
|
||||
document.querySelector("#tl3"),
|
||||
document.querySelector("#pane-t3 .content-area")
|
||||
);
|
||||
await tm.open("vault1", "/notes/foo.md");
|
||||
const items = $$("#tl3 .tab-item");
|
||||
assert.equal(items.length, 1, `expected 1 tab-item, got ${items.length}`);
|
||||
assert.match(items[0].textContent, /foo/);
|
||||
});
|
||||
|
||||
await test("activate() switches .active class between tabs", async () => {
|
||||
resetBody(`
|
||||
<div id="pane-t4">
|
||||
<div class="tab-bar"></div>
|
||||
<div id="tl4" class="tab-list"></div>
|
||||
<div class="content-area"></div>
|
||||
</div>
|
||||
`);
|
||||
const tm = createPaneTabManager("t4");
|
||||
tm.init(
|
||||
document.querySelector("#pane-t4 .tab-bar"),
|
||||
document.querySelector("#tl4"),
|
||||
document.querySelector("#pane-t4 .content-area")
|
||||
);
|
||||
await tm.open("vault1", "/notes/foo.md");
|
||||
await tm.open("vault1", "/notes/bar.md");
|
||||
// PaneTabManager.activate(tabId) is called inside open(), so the most
|
||||
// recently opened tab is active by default.
|
||||
let [t1, t2] = $$("#tl4 .tab-item");
|
||||
assert.ok(!t1.classList.contains("active"), `t1 should NOT be active (it was opened first): ${t1.className}`);
|
||||
assert.ok(t2.classList.contains("active"), `t2 should be active (most recent): ${t2.className}`);
|
||||
|
||||
// Now explicitly activate the first tab.
|
||||
tm.activate("vault1::/notes/foo.md");
|
||||
tm._renderTabs();
|
||||
[t1, t2] = $$("#tl4 .tab-item");
|
||||
assert.ok(t1.classList.contains("active"), "after explicit activate, t1 should be active");
|
||||
assert.ok(!t2.classList.contains("active"), "t2 should no longer be active");
|
||||
});
|
||||
|
||||
await test("close() removes the tab-item from DOM", async () => {
|
||||
resetBody(`
|
||||
<div id="pane-t5">
|
||||
<div class="tab-bar"></div>
|
||||
<div id="tl5" class="tab-list"></div>
|
||||
<div class="content-area"></div>
|
||||
</div>
|
||||
`);
|
||||
const tm = createPaneTabManager("t5");
|
||||
tm.init(
|
||||
document.querySelector("#pane-t5 .tab-bar"),
|
||||
document.querySelector("#tl5"),
|
||||
document.querySelector("#pane-t5 .content-area")
|
||||
);
|
||||
await tm.open("vault1", "/notes/foo.md");
|
||||
await tm.open("vault1", "/notes/bar.md");
|
||||
assert.equal($$("#tl5 .tab-item").length, 2);
|
||||
await tm.close("vault1::/notes/foo.md");
|
||||
assert.equal($$("#tl5 .tab-item").length, 1);
|
||||
assert.match($$("#tl5 .tab-item")[0].textContent, /bar/);
|
||||
});
|
||||
|
||||
await test("isolation: tabs in pane-A do not appear in pane-B", async () => {
|
||||
resetBody(`
|
||||
<div id="pane-A"><div class="tab-bar"></div><div id="tlA" class="tab-list"></div><div class="content-area"></div></div>
|
||||
<div id="pane-B"><div class="tab-bar"></div><div id="tlB" class="tab-list"></div><div class="content-area"></div></div>
|
||||
`);
|
||||
const tmA = createPaneTabManager("A");
|
||||
tmA.init(
|
||||
document.querySelector("#pane-A .tab-bar"),
|
||||
document.querySelector("#tlA"),
|
||||
document.querySelector("#pane-A .content-area")
|
||||
);
|
||||
const tmB = createPaneTabManager("B");
|
||||
tmB.init(
|
||||
document.querySelector("#pane-B .tab-bar"),
|
||||
document.querySelector("#tlB"),
|
||||
document.querySelector("#pane-B .content-area")
|
||||
);
|
||||
|
||||
await tmA.open("v1", "/only-in-a.md");
|
||||
assert.equal($$("#tlA .tab-item").length, 1);
|
||||
assert.equal($$("#tlB .tab-item").length, 0, "pane-B must be empty");
|
||||
|
||||
await tmB.open("v1", "/only-in-b.md");
|
||||
assert.equal($$("#tlA .tab-item").length, 1, "pane-A unchanged");
|
||||
assert.equal($$("#tlB .tab-item").length, 1);
|
||||
});
|
||||
|
||||
await test("PaneManager singleton exposes expected methods", () => {
|
||||
assert.equal(typeof PaneManager, "object");
|
||||
assert.equal(PaneManager.isSplit(), false);
|
||||
assert.deepEqual(PaneManager.panes, []);
|
||||
assert.equal(typeof PaneManager.splitRight, "function");
|
||||
assert.equal(typeof PaneManager.splitDown, "function");
|
||||
assert.equal(typeof PaneManager.closePane, "function");
|
||||
});
|
||||
|
||||
await test("DragEvent dispatch on tab-item does not crash", async () => {
|
||||
resetBody(`
|
||||
<div id="pane-d">
|
||||
<div class="tab-bar"></div>
|
||||
<div id="tld" class="tab-list"></div>
|
||||
<div class="content-area"></div>
|
||||
</div>
|
||||
`);
|
||||
const tm = createPaneTabManager("d");
|
||||
tm.init(
|
||||
document.querySelector("#pane-d .tab-bar"),
|
||||
document.querySelector("#tld"),
|
||||
document.querySelector("#pane-d .content-area")
|
||||
);
|
||||
await tm.open("v1", "/drag.md");
|
||||
const tab = document.querySelector("#tld .tab-item");
|
||||
assert.ok(tab, "tab should exist");
|
||||
const dt = {
|
||||
setData: () => {},
|
||||
getData: () => "",
|
||||
types: ["text/plain"],
|
||||
};
|
||||
const ds = new Event("dragstart", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ds, "dataTransfer", { value: dt });
|
||||
tab.dispatchEvent(ds); // should not throw
|
||||
const de = new Event("dragend", { bubbles: true });
|
||||
Object.defineProperty(de, "dataTransfer", { value: dt });
|
||||
tab.dispatchEvent(de); // should not throw
|
||||
});
|
||||
|
||||
await test("getActiveTabManager / getActiveContentArea helpers exist", () => {
|
||||
assert.equal(typeof getActiveTabManager, "function");
|
||||
assert.equal(typeof getActiveContentArea, "function");
|
||||
});
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\n${passCount}/${testCount} pane-manager tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
@@ -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);
|
||||
@@ -28,7 +28,7 @@ function testEscapeHtml() {
|
||||
// ── Test state object structure ────────────────────────────────────────────
|
||||
// Replicate state.js structure for testing
|
||||
const expectedStateKeys = [
|
||||
'APP_VERSION', 'currentVault', 'currentPath', 'allVaults', 'selectedContextVault',
|
||||
'currentVault', 'currentPath', 'allVaults', 'selectedContextVault',
|
||||
'searchTimeout', 'searchAbortController', 'advancedSearchOffset', 'advancedSearchTotal',
|
||||
'advancedSearchSort', 'advancedSearchLastQuery', 'suggestAbortController',
|
||||
'dropdownActiveIndex', 'dropdownItems', 'currentSearchId', 'selectedTags',
|
||||
@@ -43,7 +43,7 @@ const expectedStateKeys = [
|
||||
|
||||
function testStateKeys() {
|
||||
const state = {
|
||||
APP_VERSION: "1.5.0", currentVault: null, currentPath: null, allVaults: [],
|
||||
currentVault: null, currentPath: null, allVaults: [],
|
||||
selectedContextVault: "all", searchTimeout: null, searchAbortController: null,
|
||||
advancedSearchOffset: 0, advancedSearchTotal: 0, advancedSearchSort: "relevance",
|
||||
advancedSearchLastQuery: "", suggestAbortController: null, dropdownActiveIndex: -1,
|
||||
@@ -108,8 +108,9 @@ function testModulesHaveImports() {
|
||||
{ file: 'offline-db.js', reason: 'standalone — IndexedDB' },
|
||||
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
|
||||
{ file: 'themes.js', reason: 'standalone — no deps' },
|
||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||
];
|
||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
||||
];
|
||||
const expectNoImport = new Set(expected.map(e => e.file));
|
||||
|
||||
for (const file of files) {
|
||||
@@ -127,6 +128,85 @@ function testModulesHaveImports() {
|
||||
console.log(' ✓ All modules have imports and exports (except state.js)');
|
||||
}
|
||||
|
||||
// ── Test admin.js exports (ROADMAP #71) ─────────────────────────────────
|
||||
// Static analysis of frontend/js/admin.js — confirms the ES module shape
|
||||
// without depending on a DOM environment.
|
||||
const ADMIN_PATH = join(JS_DIR, 'admin.js');
|
||||
const ADMIN_REQUIRED_EXPORTS = [
|
||||
'init',
|
||||
'connectSSE',
|
||||
'loadStatsOnce',
|
||||
'loadAuditLogs',
|
||||
'loadBackupStats',
|
||||
'renderStatsWidget',
|
||||
'renderAuditTable',
|
||||
'renderBackups',
|
||||
'getAuthHeaders',
|
||||
'formatBytes',
|
||||
'formatUptime',
|
||||
'severityColor',
|
||||
];
|
||||
|
||||
function _collectExportedNames(content) {
|
||||
const names = new Set();
|
||||
// Multi-line export { ... } blocks
|
||||
for (const m of content.matchAll(/^export\s*\{([\s\S]*?)\}/gm)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const n = part.trim().split(/\s+as\s+/)[0].trim();
|
||||
if (n) names.add(n);
|
||||
}
|
||||
}
|
||||
// export function/const/let/class/async function NAME
|
||||
for (const m of content.matchAll(/^export\s+(?:async\s+)?(?:function|const|let|class)\s+(\w+)/gm)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function testAdminModuleExists() {
|
||||
const content = readFileSync(ADMIN_PATH, 'utf-8');
|
||||
assert.ok(content.length > 0, 'admin.js must be a non-empty file');
|
||||
// Sanity: it must import from auth.js, state.js, utils.js, i18n.js
|
||||
assert.ok(/from\s+['"]\.\/auth\.js['"]/.test(content), 'admin.js must import from ./auth.js');
|
||||
assert.ok(/from\s+['"]\.\/state\.js['"]/.test(content), 'admin.js must import from ./state.js');
|
||||
assert.ok(/from\s+['"]\.\/utils\.js['"]/.test(content), 'admin.js must import from ./utils.js');
|
||||
assert.ok(/from\s+['"]\.\/i18n\.js['"]/.test(content), 'admin.js must import from ./i18n.js');
|
||||
console.log(' ✓ admin.js exists and imports the expected modules');
|
||||
}
|
||||
|
||||
function testAdminModuleExports() {
|
||||
const content = readFileSync(ADMIN_PATH, 'utf-8');
|
||||
const exported = _collectExportedNames(content);
|
||||
const missing = ADMIN_REQUIRED_EXPORTS.filter((name) => !exported.has(name));
|
||||
assert.strictEqual(missing.length, 0, `admin.js missing exports: ${missing.join(', ')}`);
|
||||
console.log(` ✓ admin.js exports all ${ADMIN_REQUIRED_EXPORTS.length} expected names`);
|
||||
}
|
||||
|
||||
function testAdminModuleSyntax() {
|
||||
// node --check verifies parseability without executing side effects.
|
||||
execSync(`node --check "${ADMIN_PATH}"`, { stdio: 'pipe' });
|
||||
console.log(' ✓ admin.js parses without syntax errors');
|
||||
}
|
||||
|
||||
// ── Test ai-fab.js (FAB — Floating Action Button) ─────────────────────────
|
||||
const AIFAB_PATH = join(JS_DIR, 'ai-fab.js');
|
||||
|
||||
function testAIFabModuleExists() {
|
||||
const content = readFileSync(AIFAB_PATH, 'utf-8');
|
||||
assert.ok(content.length > 0, 'ai-fab.js must be a non-empty file');
|
||||
assert.ok(/from\s+['"]\.\/bookslm\.js['"]/.test(content), 'ai-fab.js must import from ./bookslm.js');
|
||||
assert.ok(/from\s+['"]\.\/utils\.js['"]/.test(content), 'ai-fab.js must import from ./utils.js');
|
||||
assert.ok(/from\s+['"]\.\/i18n\.js['"]/.test(content), 'ai-fab.js must import from ./i18n.js');
|
||||
console.log(' ✓ ai-fab.js exists and imports the expected modules');
|
||||
}
|
||||
|
||||
function testAIFabModuleExports() {
|
||||
const content = readFileSync(AIFAB_PATH, 'utf-8');
|
||||
const exported = _collectExportedNames(content);
|
||||
assert.ok(exported.has('initAIFab'), 'ai-fab.js must export initAIFab');
|
||||
console.log(' ✓ ai-fab.js exports initAIFab');
|
||||
}
|
||||
|
||||
// ── Run all tests ──────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
let passed = 0, failed = 0;
|
||||
@@ -136,6 +216,11 @@ async function main() {
|
||||
['state keys', testStateKeys],
|
||||
['module syntax', testAllModulesParse],
|
||||
['module structure', testModulesHaveImports],
|
||||
['admin module exists', testAdminModuleExists],
|
||||
['admin module exports', testAdminModuleExports],
|
||||
['admin module syntax', testAdminModuleSyntax],
|
||||
['ai-fab module exists', testAIFabModuleExists],
|
||||
['ai-fab module exports', testAIFabModuleExports],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
|
||||
@@ -38,8 +38,15 @@ function collectExports(filePath, modName) {
|
||||
const m = exportBlockText.match(/^export\s*\{([^}]+)\}/);
|
||||
if (m) {
|
||||
for (const name of m[1].split(',')) {
|
||||
const n = name.trim().replace(/\s+as\s+\w+.*/, '').trim();
|
||||
if (n && n !== '') exports.add(n);
|
||||
// Handle `original as exported` — export both names
|
||||
const asMatch = name.trim().match(/^(\w+)\s+as\s+(\w+)$/);
|
||||
if (asMatch) {
|
||||
exports.add(asMatch[2]); // exported name
|
||||
exports.add(asMatch[1]); // original name too (in case it's re-imported)
|
||||
} else {
|
||||
const n = name.trim().trim();
|
||||
if (n && n !== '') exports.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
exportBlockText = '';
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# tests/test_admin.py — Integration tests for the Admin Dashboard endpoints
|
||||
# (ROADMAP #71)
|
||||
#
|
||||
# These tests cover:
|
||||
# - GET /api/admin/stats — CPU/RAM/Disk/Uptime snapshot
|
||||
# - GET /api/admin/audit — recent audit log entries with filters
|
||||
# - GET /api/admin/backup-stats — backup counts/sizes per vault
|
||||
# - GET /api/admin/stream — Server-Sent Events stream
|
||||
# All endpoints must require admin auth.
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(tmp_path):
|
||||
"""TestClient with auth enabled, isolated temp data, admin user provisioned."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
import json
|
||||
from backend.auth.password import hash_password
|
||||
|
||||
pw_hash = hash_password("chab30")
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"admin": {
|
||||
"id": "admin-1",
|
||||
"username": "admin",
|
||||
"display_name": "admin",
|
||||
"password_hash": pw_hash,
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
"normaluser": {
|
||||
"id": "user-1",
|
||||
"username": "normaluser",
|
||||
"display_name": "normal",
|
||||
"password_hash": hash_password("normal123"),
|
||||
"role": "user",
|
||||
"vaults": ["TestVault"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
orig_cwd = os.getcwd()
|
||||
test_vault_path = os.path.abspath("test-vault")
|
||||
os.chdir(str(tmp_path))
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = test_vault_path
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_ADMIN_USER"] = "admin"
|
||||
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "chab30"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.main import app
|
||||
from backend.indexer import build_index, index
|
||||
import asyncio
|
||||
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
from backend.search import init_inverted_index
|
||||
init_inverted_index()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
if hasattr(client, "close"):
|
||||
client.close()
|
||||
loop.run_until_complete(asyncio.sleep(0))
|
||||
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp_path), ignore_errors=True)
|
||||
for k in [
|
||||
"VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED",
|
||||
]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
def _login(client, username="admin", password="chab30"):
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _bearer(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/admin/stats
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestAdminStats:
|
||||
EXPECTED_KEYS = {
|
||||
"cpu_pct", "mem_used_mb", "mem_total_mb",
|
||||
"disk_used_gb", "disk_total_gb",
|
||||
"uptime_seconds", "active_sessions",
|
||||
}
|
||||
|
||||
def test_stats_ok_as_admin(self, admin_client):
|
||||
token = _login(admin_client)
|
||||
resp = admin_client.get("/api/admin/stats", headers=_bearer(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
missing = self.EXPECTED_KEYS - set(data.keys())
|
||||
assert not missing, f"Missing keys: {missing}"
|
||||
# Numeric sanity (allow 0 for any metric — depending on platform)
|
||||
for key in ("cpu_pct", "mem_used_mb", "mem_total_mb",
|
||||
"disk_used_gb", "disk_total_gb", "uptime_seconds",
|
||||
"active_sessions"):
|
||||
assert isinstance(data[key], (int, float)), f"{key} not numeric"
|
||||
assert data[key] >= 0, f"{key} is negative"
|
||||
|
||||
def test_stats_requires_auth(self, admin_client):
|
||||
resp = admin_client.get("/api/admin/stats")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
def test_stats_requires_admin_role(self, admin_client):
|
||||
token = _login(admin_client, username="normaluser", password="normal123")
|
||||
resp = admin_client.get("/api/admin/stats", headers=_bearer(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/admin/audit
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestAdminAudit:
|
||||
def _seed_audit(self, tmp_path_factory=None):
|
||||
"""Append a few entries to the audit log so filtering has data."""
|
||||
from backend.audit import _write_entry
|
||||
from datetime import datetime, timezone
|
||||
entries = [
|
||||
{"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"action": "file_save", "username": "alice", "vault": "TestVault",
|
||||
"size": 100, "ip": "127.0.0.1"},
|
||||
{"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"action": "file_delete", "username": "bob", "vault": "TestVault",
|
||||
"ip": "127.0.0.1"},
|
||||
{"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"action": "file_save", "username": "bob", "vault": "TestVault",
|
||||
"size": 50, "ip": "127.0.0.1"},
|
||||
]
|
||||
for e in entries:
|
||||
_write_entry(e)
|
||||
|
||||
def test_audit_ok(self, admin_client):
|
||||
self._seed_audit()
|
||||
token = _login(admin_client)
|
||||
resp = admin_client.get("/api/admin/audit", headers=_bearer(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "entries" in data
|
||||
assert "total" in data
|
||||
# We just seeded at least 3 entries
|
||||
assert data["total"] >= 3
|
||||
|
||||
def test_audit_filter_by_user(self, admin_client):
|
||||
self._seed_audit()
|
||||
token = _login(admin_client)
|
||||
resp = admin_client.get("/api/admin/audit?user=bob", headers=_bearer(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert all("bob" in str(e.get("username", "")).lower() for e in data["entries"])
|
||||
|
||||
def test_audit_filter_by_action(self, admin_client):
|
||||
self._seed_audit()
|
||||
token = _login(admin_client)
|
||||
resp = admin_client.get("/api/admin/audit?action=file_delete", headers=_bearer(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert all(e.get("action") == "file_delete" for e in data["entries"])
|
||||
|
||||
def test_audit_limit_param(self, admin_client):
|
||||
self._seed_audit()
|
||||
token = _login(admin_client)
|
||||
resp = admin_client.get("/api/admin/audit?limit=2", headers=_bearer(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["entries"]) <= 2
|
||||
|
||||
def test_audit_requires_admin(self, admin_client):
|
||||
resp = admin_client.get("/api/admin/audit")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/admin/backup-stats
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestAdminBackupStats:
|
||||
def _create_backup_files(self, tmp_path_factory=None):
|
||||
"""Create a couple of fake .bak files in the default backup dir."""
|
||||
from backend.indexer import vault_config
|
||||
import time as _time
|
||||
for vault_name, cfg in list(vault_config.items()):
|
||||
vault_root = Path(cfg["path"])
|
||||
backup_root = vault_root / ".obsigate-backup" / vault_name / "subdir"
|
||||
backup_root.mkdir(parents=True, exist_ok=True)
|
||||
ts1 = int(_time.time()) - 86400
|
||||
ts2 = int(_time.time())
|
||||
(backup_root / f"note.md.{ts1}.bak").write_text("old version")
|
||||
(backup_root / f"note.md.{ts2}.bak").write_text("newer version with more content")
|
||||
|
||||
def test_backup_stats_ok(self, admin_client):
|
||||
self._create_backup_files()
|
||||
token = _login(admin_client)
|
||||
resp = admin_client.get("/api/admin/backup-stats", headers=_bearer(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
for k in ("total_backups", "total_size_mb", "oldest_age_days",
|
||||
"newest_age_days", "by_vault"):
|
||||
assert k in data, f"missing key {k}"
|
||||
assert data["total_backups"] >= 2
|
||||
assert data["total_size_mb"] >= 0
|
||||
# We created one set per vault — at least one vault entry
|
||||
assert isinstance(data["by_vault"], dict)
|
||||
|
||||
def test_backup_stats_empty(self, admin_client):
|
||||
"""Even with no backups, endpoint returns 200 with zero counts."""
|
||||
token = _login(admin_client)
|
||||
resp = admin_client.get("/api/admin/backup-stats", headers=_bearer(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_backups"] >= 0
|
||||
# If no backups exist, both age fields are 0
|
||||
if data["total_backups"] == 0:
|
||||
assert data["newest_age_days"] == 0.0
|
||||
assert data["oldest_age_days"] == 0.0
|
||||
|
||||
def test_backup_stats_requires_admin(self, admin_client):
|
||||
resp = admin_client.get("/api/admin/backup-stats")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/admin/stream
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestAdminStream:
|
||||
def test_stream_content_type(self, admin_client):
|
||||
"""Verify SSE endpoint returns text/event-stream with valid first frame.
|
||||
|
||||
The /api/admin/stream endpoint is an infinite generator (yields every 5s).
|
||||
We can't easily consume a streaming response from a sync TestClient
|
||||
(the context manager blocks on entry for infinite responses). Instead,
|
||||
we verify the endpoint contract from two angles:
|
||||
1. Without auth → 401/403 (proves the endpoint is mounted and gated)
|
||||
2. Direct invocation of the underlying generator yields a valid SSE
|
||||
frame on the first iteration (proves the format contract).
|
||||
"""
|
||||
# 1) Endpoint is gated behind admin auth.
|
||||
resp = admin_client.get("/api/admin/stream")
|
||||
assert resp.status_code in (401, 403), (
|
||||
f"unauthenticated should be rejected, got {resp.status_code}"
|
||||
)
|
||||
|
||||
# 2) Direct generator check — read the first frame, then close.
|
||||
import asyncio
|
||||
from backend.admin import _stats_event_generator
|
||||
|
||||
async def _first_frame():
|
||||
gen = _stats_event_generator()
|
||||
return await gen.__anext__()
|
||||
|
||||
first = asyncio.run(_first_frame())
|
||||
assert isinstance(first, str), f"expected str, got {type(first).__name__}"
|
||||
assert first.startswith("event: stats"), f"unexpected frame: {first[:100]!r}"
|
||||
assert "data: " in first
|
||||
# The data line should be parseable JSON.
|
||||
import json
|
||||
data_line = [ln for ln in first.splitlines() if ln.startswith("data: ")][0]
|
||||
payload = json.loads(data_line[len("data: "):])
|
||||
assert isinstance(payload, dict)
|
||||
assert "cpu_pct" in payload or "error" in payload
|
||||
|
||||
def test_stream_requires_admin(self, admin_client):
|
||||
resp = admin_client.get("/api/admin/stream")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Admin dashboard PAGE (/admin.html)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
class TestAdminPage:
|
||||
"""Regression for ROADMAP #71 — Admin menu bounced back to the main page.
|
||||
|
||||
Root cause: /admin.html had no explicit route, so the SPA catch-all
|
||||
``/{full_path:path}`` served index.html. Guard the fix.
|
||||
"""
|
||||
|
||||
def test_page_served_as_admin(self, admin_client):
|
||||
token = _login(admin_client)
|
||||
resp = admin_client.get("/admin.html", headers=_bearer(token))
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# Real admin page markers (NOT the main SPA index.html)
|
||||
assert "admin-main" in body or "ObsiGate — Admin" in body or "admin.js" in body
|
||||
# The regression: index.html markers must be absent
|
||||
assert "boot-splash" not in body
|
||||
# Asset paths must point to the actual static mount (/static), not /frontend
|
||||
assert "/static/js/admin.js" in body
|
||||
assert "/frontend/js/admin.js" not in body
|
||||
|
||||
def test_page_requires_auth(self, admin_client):
|
||||
resp = admin_client.get("/admin.html")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
def test_page_requires_admin_role(self, admin_client):
|
||||
token = _login(admin_client, username="normaluser", password="normal123")
|
||||
resp = admin_client.get("/admin.html", headers=_bearer(token))
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Tests for the AI models listing endpoint + curated fallback lists (ROADMAP #74/#71).
|
||||
|
||||
Covers:
|
||||
- GET /api/config/ai-models always returns a non-empty list for known providers,
|
||||
even when the live API call fails (network, auth, etc.).
|
||||
- The fallback list is curated with at least one model per provider.
|
||||
- Gemini parsing strips the "models/" prefix.
|
||||
- The default model is prepended if not already in the list.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import _FALLBACK_MODELS, app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(tmp_path):
|
||||
"""Minimal admin client for the /api/config/ai-models endpoint."""
|
||||
from backend.auth.password import hash_password
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"admin": {
|
||||
"id": "admin-1",
|
||||
"username": "admin",
|
||||
"display_name": "admin",
|
||||
"password_hash": hash_password("chab30"),
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
import shutil
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
orig_cwd = os.getcwd()
|
||||
os.chdir(str(tmp_path))
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = str(Path("test-vault").resolve())
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_ADMIN_USER"] = "admin"
|
||||
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "chab30"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
from backend.indexer import build_index, index
|
||||
import asyncio
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
client.close()
|
||||
os.chdir(orig_cwd)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
def _login_admin(client):
|
||||
resp = client.post("/api/auth/login",
|
||||
json={"username": "admin", "password": "chab30"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
# ── Unit tests for the fallback table itself ─────────────────────────────
|
||||
|
||||
|
||||
class TestFallbackTable:
|
||||
def test_every_known_provider_has_fallback_list(self):
|
||||
"""Every provider in the dropdown must have a non-empty fallback list."""
|
||||
expected_providers = {
|
||||
"deepseek", "openrouter", "gemini", "nvidia",
|
||||
"qwencloud", "xiaomi", "mistral",
|
||||
}
|
||||
assert set(_FALLBACK_MODELS.keys()) >= expected_providers
|
||||
for p in expected_providers:
|
||||
assert _FALLBACK_MODELS[p], f"fallback list for {p!r} is empty"
|
||||
assert all(isinstance(m, str) and m.strip() for m in _FALLBACK_MODELS[p]), \
|
||||
f"fallback list for {p!r} contains invalid entries: {_FALLBACK_MODELS[p]}"
|
||||
|
||||
def test_fallback_lists_are_short_and_focused(self):
|
||||
"""Fallbacks should be short (≤10 models) and well-known."""
|
||||
for p, models in _FALLBACK_MODELS.items():
|
||||
assert len(models) <= 10, f"too many fallbacks for {p}: {len(models)}"
|
||||
|
||||
def test_xiaomi_fallback_contains_mimo_models(self):
|
||||
"""Xiaomi's MiMo models must be in the fallback list."""
|
||||
mimos = [m for m in _FALLBACK_MODELS["xiaomi"] if "mimo" in m.lower()]
|
||||
assert mimos, "no MiMo model in xiaomi fallback"
|
||||
|
||||
|
||||
# ── Endpoint integration tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListModelsEndpoint:
|
||||
"""Verify /api/config/ai-models?provider=X always returns a usable list."""
|
||||
|
||||
def test_unknown_provider_returns_empty(self, admin_client):
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "nonexistent-provider"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["models"] == []
|
||||
assert "error" in data or "source" in data
|
||||
|
||||
def test_provider_without_key_returns_fallback(self, admin_client, monkeypatch):
|
||||
"""When the provider has no API key configured, return the curated fallback list."""
|
||||
# Force every provider key to be empty so the endpoint hits its
|
||||
# 'no key configured' branch.
|
||||
from backend import ai
|
||||
monkeypatch.setattr(ai, "get_ai_key", lambda name: None)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
for provider in ("xiaomi", "nvidia", "deepseek", "mistral"):
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": provider},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200, f"{provider}: {resp.status_code}"
|
||||
data = resp.json()
|
||||
assert data["models"], f"{provider}: fallback list is empty"
|
||||
assert data.get("source") == "fallback", (
|
||||
f"{provider}: expected source=fallback, got {data.get('source')!r}"
|
||||
)
|
||||
|
||||
def test_provider_with_unreachable_api_returns_fallback(
|
||||
self, admin_client, monkeypatch,
|
||||
):
|
||||
"""When the live API call fails (network/DNS), the fallback list is used.
|
||||
|
||||
This test patches both the key lookup AND the urlopen call so we
|
||||
deterministically hit the network-failure branch.
|
||||
"""
|
||||
from backend import ai as aimod
|
||||
|
||||
# Fake key so we don't take the 'no key' short-circuit.
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key-for-test")
|
||||
|
||||
# Patch urllib.request.urlopen to always raise — simulating network down.
|
||||
# NOTE: main.py imports urllib.request at module load, so we patch the
|
||||
# symbol it actually uses (urllib.request.urlopen).
|
||||
import urllib.request
|
||||
def _boom(*args, **kwargs):
|
||||
raise OSError("simulated network down")
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _boom)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
# Pick a non-Gemini provider so we hit the network code path.
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "nvidia"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# The response should be a usable list — either via fallback after
|
||||
# network failure, or the 'no key' shortcut. Both are acceptable as
|
||||
# long as models is non-empty.
|
||||
assert data["models"], "model list should be non-empty"
|
||||
assert data.get("source") in ("fallback",), (
|
||||
f"unexpected source: {data.get('source')!r}"
|
||||
)
|
||||
|
||||
def test_response_includes_source_field(self, admin_client, monkeypatch):
|
||||
"""All successful responses must include a 'source' field for UI hinting."""
|
||||
from backend import ai as aimod
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key")
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "gemini"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "source" in data
|
||||
assert data["source"] in ("live", "fallback")
|
||||
|
||||
def test_xiaomi_uses_api_key_header_not_bearer(self, admin_client, monkeypatch):
|
||||
"""Regression test: Xiaomi MiMo must use `api-key` header, NOT Authorization.
|
||||
|
||||
Without this, the live call always fails with 401 even with a valid key.
|
||||
"""
|
||||
# Fake key — patch BOTH the source module AND the imported reference
|
||||
# in backend.main (which does `from backend.ai import ... get_ai_key`).
|
||||
import backend.ai as aimod
|
||||
import backend.main as bmain
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-xiaomi-key")
|
||||
if hasattr(bmain, "get_ai_key"):
|
||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-xiaomi-key")
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_Request(url, *args, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["headers"] = dict(kwargs.get("headers") or {})
|
||||
|
||||
class FakeResp:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
def read(self):
|
||||
return b'{"object":"list","data":[{"id":"mimo-v2.5-pro","object":"model","owned_by":"xiaomi"}]}'
|
||||
|
||||
captured["response"] = FakeResp()
|
||||
return captured["response"]
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return req
|
||||
|
||||
# Patch urllib.request at the point where backend.main imported it.
|
||||
import urllib.request as global_urllib_mod
|
||||
monkeypatch.setattr(global_urllib_mod, "Request", fake_Request, raising=True)
|
||||
monkeypatch.setattr(global_urllib_mod, "urlopen", fake_urlopen, raising=True)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "xiaomi"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200, f"resp: {resp.text[:300]}"
|
||||
# Verify the URL + headers used.
|
||||
assert captured.get("url"), f"Request was not called (captured={captured!r})"
|
||||
assert captured["url"].startswith("https://api.xiaomimimo.com/v1/models"), (
|
||||
f"unexpected URL: {captured.get('url')!r}"
|
||||
)
|
||||
hdrs = {k.lower(): v for k, v in captured.get("headers", {}).items()}
|
||||
assert "api-key" in hdrs, f"missing api-key header in {hdrs!r}"
|
||||
assert hdrs["api-key"] == "fake-xiaomi-key"
|
||||
assert "authorization" not in hdrs, f"Authorization leaked: {hdrs!r}"
|
||||
|
||||
def test_xiaomi_test_endpoint_uses_real_url(self, admin_client, monkeypatch):
|
||||
"""The /api/config/ai-keys/test endpoint must also use api.xiaomimimo.com.
|
||||
|
||||
Regression: it previously used api.xiaomi.com which doesn't exist
|
||||
(DNS error Name or service not known).
|
||||
"""
|
||||
# Patch BOTH the source module AND the imported reference in backend.main
|
||||
import backend.ai as aimod
|
||||
import backend.main as bmain
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key")
|
||||
if hasattr(bmain, "get_ai_key"):
|
||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-key")
|
||||
|
||||
import urllib.request as global_urllib_mod
|
||||
captured_urls = []
|
||||
captured_headers = []
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured_urls.append(req.full_url)
|
||||
captured_headers.append(dict(req.headers))
|
||||
raise OSError("simulated network error")
|
||||
|
||||
monkeypatch.setattr(global_urllib_mod, "urlopen", fake_urlopen, raising=True)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
admin_client.post(
|
||||
"/api/config/ai-keys/test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
# Find the xiaomi URL among the captured calls.
|
||||
xiaomi_idx = next(
|
||||
(i for i, u in enumerate(captured_urls) if "xiaomi" in u.lower()),
|
||||
None,
|
||||
)
|
||||
assert xiaomi_idx is not None, (
|
||||
f"xiaomi URL not found in captured URLs: {captured_urls!r}"
|
||||
)
|
||||
xiaomi_url = captured_urls[xiaomi_idx]
|
||||
# Must use the real MiMo endpoint, NOT the dead api.xiaomi.com.
|
||||
assert "api.xiaomimimo.com" in xiaomi_url, (
|
||||
f"xiaomi test URL is wrong: {xiaomi_url!r}"
|
||||
)
|
||||
# Must use api-key header, not Authorization. urllib.request
|
||||
# normalizes header names to title-case ("Api-key"), but HTTP
|
||||
# headers are case-insensitive on the wire — both forms are valid.
|
||||
xiaomi_hdrs = {k.lower(): v for k, v in captured_headers[xiaomi_idx].items()}
|
||||
assert "api-key" in xiaomi_hdrs, (
|
||||
f"xiaomi api-key header missing: {xiaomi_hdrs!r}"
|
||||
)
|
||||
assert xiaomi_hdrs["api-key"] == "fake-key"
|
||||
# Bearer prefix must NOT be in the api-key value
|
||||
assert "Bearer" not in xiaomi_hdrs["api-key"]
|
||||
@@ -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):
|
||||
|
||||
@@ -221,6 +221,52 @@ class TestAdmin:
|
||||
data = resp.json()
|
||||
assert data["username"] == "testuser"
|
||||
|
||||
def test_patch_user(self, auth_client):
|
||||
"""PATCH /api/auth/admin/users/{username} updates fields."""
|
||||
token = self._login_admin(auth_client)
|
||||
# Create a user first, then patch it.
|
||||
auth_client.post("/api/auth/admin/users", headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
}, json={
|
||||
"username": "patchuser",
|
||||
"password": "origpass",
|
||||
"role": "user",
|
||||
"vaults": ["TestVault"],
|
||||
})
|
||||
resp = auth_client.patch("/api/auth/admin/users/patchuser", headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
}, json={
|
||||
"display_name": "Patched User",
|
||||
"active": False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["display_name"] == "Patched User"
|
||||
assert data["active"] is False
|
||||
|
||||
def test_delete_user(self, auth_client):
|
||||
"""DELETE /api/auth/admin/users/{username} removes the user."""
|
||||
token = self._login_admin(auth_client)
|
||||
# Create a throwaway user, then delete it.
|
||||
auth_client.post("/api/auth/admin/users", headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
}, json={
|
||||
"username": "deleteuser",
|
||||
"password": "delpass",
|
||||
"role": "user",
|
||||
"vaults": ["TestVault"],
|
||||
})
|
||||
resp = auth_client.delete("/api/auth/admin/users/deleteuser", headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
# Confirm it's gone via list.
|
||||
list_resp = auth_client.get("/api/auth/admin/users", headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
})
|
||||
usernames = [u["username"] for u in list_resp.json()]
|
||||
assert "deleteuser" not in usernames
|
||||
|
||||
def test_logout(self, auth_client):
|
||||
token = self._login_admin(auth_client)
|
||||
resp = auth_client.post("/api/auth/logout", headers={
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
"""Tests for BooksLM — directory context collection, caching, and API routes."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Unit tests: collect_directory_context ──────────────────────────────
|
||||
|
||||
|
||||
class TestCollectDirectoryContext:
|
||||
"""Tests for collect_directory_context()."""
|
||||
|
||||
def test_basic_collection(self, tmp_path):
|
||||
"""Collect .md files from a simple directory."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "note1.md").write_text("# Note 1\nHello world", encoding="utf-8")
|
||||
(vault / "note2.md").write_text("# Note 2\nGoodbye world", encoding="utf-8")
|
||||
(vault / "notemd.txt").write_text("Not markdown", encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
assert result["file_count"] == 2
|
||||
assert result["total_chars"] > 0
|
||||
paths = [f["path"] for f in result["files"]]
|
||||
assert "note1.md" in paths
|
||||
assert "note2.md" in paths
|
||||
assert "notemd.txt" not in paths
|
||||
|
||||
def test_subdirectory_collection(self, tmp_path):
|
||||
"""Collect files recursively from subdirectories."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
subdir = vault / "projects" / "code"
|
||||
subdir.mkdir(parents=True)
|
||||
(subdir / "readme.md").write_text("# Code project", encoding="utf-8")
|
||||
(vault / "root.md").write_text("# Root", encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "projects")
|
||||
|
||||
assert result["file_count"] == 1
|
||||
assert result["files"][0]["path"] == "projects/code/readme.md"
|
||||
|
||||
def test_hidden_files_skipped(self, tmp_path):
|
||||
"""Hidden files and special directories are skipped."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / ".hidden.md").write_text("Hidden", encoding="utf-8")
|
||||
(vault / ".obsidian").mkdir()
|
||||
(vault / ".obsidian" / "config.md").write_text("Config", encoding="utf-8")
|
||||
(vault / "visible.md").write_text("Visible", encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
assert result["file_count"] == 1
|
||||
assert result["files"][0]["path"] == "visible.md"
|
||||
|
||||
def test_attachments_skipped(self, tmp_path):
|
||||
"""_attachments/ directory is skipped."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
attach = vault / "_attachments"
|
||||
attach.mkdir(parents=True)
|
||||
(attach / "image.md").write_text("Image doc", encoding="utf-8")
|
||||
(vault / "real.md").write_text("Real content", encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
assert result["file_count"] == 1
|
||||
assert result["files"][0]["path"] == "real.md"
|
||||
|
||||
def test_max_files_limit(self, tmp_path):
|
||||
"""Respects BOOKSLM_MAX_FILES limit."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
import backend.bookslm as bookslm_mod
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
|
||||
old_max = bookslm_mod.BOOKSLM_MAX_FILES
|
||||
bookslm_mod.BOOKSLM_MAX_FILES = 3
|
||||
try:
|
||||
for i in range(10):
|
||||
(vault / f"note{i}.md").write_text(f"Content {i}", encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
assert result["file_count"] == 3
|
||||
finally:
|
||||
bookslm_mod.BOOKSLM_MAX_FILES = old_max
|
||||
|
||||
def test_max_file_chars_truncation(self, tmp_path):
|
||||
"""Files exceeding max chars are truncated with marker."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
import backend.bookslm as bookslm_mod
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
|
||||
old_max = bookslm_mod.BOOKSLM_MAX_FILE_CHARS
|
||||
bookslm_mod.BOOKSLM_MAX_FILE_CHARS = 50
|
||||
try:
|
||||
long_content = "x" * 200
|
||||
(vault / "long.md").write_text(long_content, encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
assert result["file_count"] == 1
|
||||
assert "[... tronqué]" in result["files"][0]["content"]
|
||||
assert len(result["files"][0]["content"]) <= 50 + 50 # truncated + marker
|
||||
finally:
|
||||
bookslm_mod.BOOKSLM_MAX_FILE_CHARS = old_max
|
||||
|
||||
def test_max_total_chars_limit(self, tmp_path):
|
||||
"""Stops collecting when total chars limit is reached."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
import backend.bookslm as bookslm_mod
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
|
||||
old_total = bookslm_mod.BOOKSLM_MAX_TOTAL_CHARS
|
||||
old_file = bookslm_mod.BOOKSLM_MAX_FILE_CHARS
|
||||
bookslm_mod.BOOKSLM_MAX_TOTAL_CHARS = 100
|
||||
bookslm_mod.BOOKSLM_MAX_FILE_CHARS = 10000
|
||||
try:
|
||||
for i in range(10):
|
||||
(vault / f"f{i}.md").write_text("a" * 50, encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
# Should not collect all 10 files (10 * 50 = 500 > 100)
|
||||
assert result["total_chars"] <= 100 + 50 # some margin for truncation marker
|
||||
finally:
|
||||
bookslm_mod.BOOKSLM_MAX_TOTAL_CHARS = old_total
|
||||
bookslm_mod.BOOKSLM_MAX_FILE_CHARS = old_file
|
||||
|
||||
def test_readme_index_priority(self, tmp_path):
|
||||
"""README and index files come first in results."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "aaa.md").write_text("# AAA", encoding="utf-8")
|
||||
(vault / "README.md").write_text("# README", encoding="utf-8")
|
||||
(vault / "index.md").write_text("# Index", encoding="utf-8")
|
||||
(vault / "zzz.md").write_text("# ZZZ", encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
paths = [f["path"] for f in result["files"]]
|
||||
# README and index should be before other files
|
||||
readme_idx = paths.index("README.md")
|
||||
index_idx = paths.index("index.md")
|
||||
aaa_idx = paths.index("aaa.md")
|
||||
assert readme_idx < aaa_idx
|
||||
assert index_idx < aaa_idx
|
||||
|
||||
def test_empty_directory(self, tmp_path):
|
||||
"""Empty directory returns empty result."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
assert result["file_count"] == 0
|
||||
assert result["total_chars"] == 0
|
||||
assert result["files"] == []
|
||||
|
||||
def test_nonexistent_directory(self, tmp_path):
|
||||
"""Nonexistent directory returns empty result."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
result = collect_directory_context(tmp_path, "nonexistent")
|
||||
|
||||
assert result["file_count"] == 0
|
||||
|
||||
def test_title_generation(self, tmp_path):
|
||||
"""File titles are derived from stem with proper casing."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "my-cool-note.md").write_text("Content", encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
assert result["files"][0]["title"] == "My Cool Note"
|
||||
|
||||
def test_directory_tree(self, tmp_path):
|
||||
"""Directory tree is included in result."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
(vault / "sub").mkdir(parents=True)
|
||||
(vault / "sub" / "file.md").write_text("Content", encoding="utf-8")
|
||||
(vault / "root.md").write_text("Root", encoding="utf-8")
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
tree = result["directory_tree"]
|
||||
assert "root.md" in tree
|
||||
assert "sub/" in tree
|
||||
assert "file.md" in tree
|
||||
|
||||
|
||||
# ── Unit tests: build_system_prompt ────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSystemPrompt:
|
||||
"""Tests for build_system_prompt()."""
|
||||
|
||||
def test_basic_prompt(self):
|
||||
"""Prompt contains expected sections."""
|
||||
from backend.bookslm import build_system_prompt
|
||||
|
||||
context = {
|
||||
"files": [
|
||||
{"path": "note.md", "title": "My Note", "content": "# Hello", "type": "markdown"},
|
||||
],
|
||||
"total_chars": 7,
|
||||
"file_count": 1,
|
||||
"directory_tree": "note.md",
|
||||
}
|
||||
|
||||
prompt = build_system_prompt(context)
|
||||
|
||||
assert "assistant de recherche" in prompt
|
||||
assert "note.md" in prompt
|
||||
assert "My Note" in prompt
|
||||
assert "# Hello" in prompt
|
||||
assert "Cite tes sources" in prompt
|
||||
|
||||
def test_empty_context(self):
|
||||
"""Empty context still produces valid prompt."""
|
||||
from backend.bookslm import build_system_prompt
|
||||
|
||||
context = {"files": [], "total_chars": 0, "file_count": 0, "directory_tree": ""}
|
||||
prompt = build_system_prompt(context)
|
||||
|
||||
assert "0 fichier" in prompt
|
||||
|
||||
def test_token_warning(self):
|
||||
"""Large context triggers token warning."""
|
||||
from backend.bookslm import build_system_prompt
|
||||
|
||||
context = {
|
||||
"files": [
|
||||
{"path": "big.md", "title": "Big", "content": "x" * 500000, "type": "markdown"},
|
||||
],
|
||||
"total_chars": 500000,
|
||||
"file_count": 1,
|
||||
"directory_tree": "big.md",
|
||||
}
|
||||
|
||||
prompt = build_system_prompt(context)
|
||||
|
||||
assert "⚠️" in prompt or "volumineux" in prompt
|
||||
|
||||
|
||||
# ── Unit tests: caching ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCaching:
|
||||
"""Tests for cache behavior."""
|
||||
|
||||
def test_cache_hit(self, tmp_path):
|
||||
"""Second call with same data returns cached result."""
|
||||
from backend.bookslm import collect_directory_context, _cache
|
||||
|
||||
_cache.clear()
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "note.md").write_text("Content", encoding="utf-8")
|
||||
|
||||
result1 = collect_directory_context(vault, "")
|
||||
result2 = collect_directory_context(vault, "")
|
||||
|
||||
assert result1["file_count"] == result2["file_count"]
|
||||
assert result1["total_chars"] == result2["total_chars"]
|
||||
|
||||
def test_cache_invalidation_on_change(self, tmp_path):
|
||||
"""Cache is invalidated when file content changes."""
|
||||
from backend.bookslm import collect_directory_context, _cache
|
||||
|
||||
_cache.clear()
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "note.md").write_text("Original", encoding="utf-8")
|
||||
|
||||
result1 = collect_directory_context(vault, "")
|
||||
assert result1["file_count"] == 1
|
||||
|
||||
# Modify file (change mtime)
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
(vault / "note.md").write_text("Modified content", encoding="utf-8")
|
||||
|
||||
result2 = collect_directory_context(vault, "")
|
||||
assert result2["files"][0]["content"] == "Modified content"
|
||||
|
||||
def test_invalidate_cache(self, tmp_path):
|
||||
"""invalidate_cache clears the cache."""
|
||||
from backend.bookslm import collect_directory_context, invalidate_cache, _cache
|
||||
|
||||
_cache.clear()
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "note.md").write_text("Content", encoding="utf-8")
|
||||
|
||||
collect_directory_context(vault, "")
|
||||
assert len(_cache) > 0
|
||||
|
||||
count = invalidate_cache()
|
||||
assert count > 0
|
||||
assert len(_cache) == 0
|
||||
|
||||
|
||||
# ── Unit tests: secret redaction ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestRedaction:
|
||||
"""Verify that secrets are redacted in collected content."""
|
||||
|
||||
def test_secrets_are_redacted(self, tmp_path):
|
||||
"""API keys in file content are redacted."""
|
||||
from backend.bookslm import collect_directory_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
# The secret redactor looks for patterns like sk-..., AKIA..., etc.
|
||||
(vault / "secrets.md").write_text(
|
||||
"Config: api_key=AKIA1234567890ABCDEF and sk-abcdefghijklmnopqrstuvwxyz01234567890",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = collect_directory_context(vault, "")
|
||||
|
||||
# The redactor should have processed this file
|
||||
content = result["files"][0]["content"]
|
||||
# At minimum the file should be collected (redaction is best-effort)
|
||||
assert result["file_count"] == 1
|
||||
|
||||
|
||||
# ── Integration tests: API endpoints ──────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bookslm_client():
|
||||
"""Create a TestClient with auth enabled, isolated temp data."""
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
data_dir = tmp / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
# Create a test vault with some files
|
||||
test_vault = tmp / "test-vault"
|
||||
test_vault.mkdir()
|
||||
(test_vault / "README.md").write_text("# Test Vault\nWelcome to the test vault.", encoding="utf-8")
|
||||
(test_vault / "notes").mkdir()
|
||||
(test_vault / "notes" / "note1.md").write_text("# Note 1\nFirst note content.", encoding="utf-8")
|
||||
(test_vault / "notes" / "note2.md").write_text("# Note 2\nSecond note content.", encoding="utf-8")
|
||||
|
||||
from backend.auth.password import hash_password
|
||||
pw_hash = hash_password("TestPass123!")
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"testuser": {
|
||||
"id": "testuser-1",
|
||||
"username": "testuser",
|
||||
"display_name": "Test User",
|
||||
"password_hash": pw_hash,
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
}
|
||||
}
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
orig_cwd = os.getcwd()
|
||||
os.chdir(str(tmp))
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = str(test_vault)
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_ADMIN_USER"] = "testuser"
|
||||
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "TestPass123!"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.main import app
|
||||
from backend.indexer import build_index, index
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
from backend.search import init_inverted_index
|
||||
init_inverted_index()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
if hasattr(client, 'close'):
|
||||
client.close()
|
||||
loop.run_until_complete(asyncio.sleep(0))
|
||||
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
def _login_bookslm(client, username="testuser", password="TestPass123!"):
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
return resp.json().get("access_token"), resp
|
||||
|
||||
|
||||
class TestBooksLMContextEndpoint:
|
||||
"""Tests for POST /api/ai/bookslm/context."""
|
||||
|
||||
def test_context_returns_files(self, bookslm_client):
|
||||
"""Context endpoint returns files from the directory."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"vault": "TestVault", "directory": "notes"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["file_count"] == 2
|
||||
paths = [f["path"] for f in data["files"]]
|
||||
assert "notes/note1.md" in paths
|
||||
assert "notes/note2.md" in paths
|
||||
|
||||
def test_context_root_directory(self, bookslm_client):
|
||||
"""Context endpoint works for root directory."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"vault": "TestVault", "directory": ""},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["file_count"] >= 1 # At least README.md
|
||||
|
||||
def test_context_requires_auth(self, bookslm_client):
|
||||
"""Context endpoint requires authentication."""
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"vault": "TestVault", "directory": ""},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_context_vault_not_found(self, bookslm_client):
|
||||
"""Context endpoint returns 404 for unknown vault."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"vault": "NonExistent", "directory": ""},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_context_nonexistent_directory(self, bookslm_client):
|
||||
"""Context endpoint returns empty for nonexistent directory."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"vault": "TestVault", "directory": "nonexistent"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["file_count"] == 0
|
||||
|
||||
|
||||
class TestBooksLMChatEndpoint:
|
||||
"""Tests for POST /api/ai/chat."""
|
||||
|
||||
def test_chat_requires_auth(self, bookslm_client):
|
||||
"""Chat endpoint requires authentication."""
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/chat",
|
||||
json={"vault": "TestVault", "directory": "", "message": "Hello"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_chat_vault_not_found(self, bookslm_client):
|
||||
"""Chat endpoint returns 404 for unknown vault."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/chat",
|
||||
json={"vault": "NonExistent", "directory": "", "message": "Hello"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_chat_empty_directory(self, bookslm_client):
|
||||
"""Chat endpoint returns 404 for empty directory."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/chat",
|
||||
json={"vault": "TestVault", "directory": "nonexistent", "message": "Hello"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_chat_request_accepts_provider_and_model(self, bookslm_client):
|
||||
"""The chat endpoint schema accepts provider + model fields without rejecting.
|
||||
|
||||
We don't actually call the AI (would need a live key) — we just verify
|
||||
the request schema is wired correctly so a missing key is the only
|
||||
failure mode, not a 422 validation error.
|
||||
"""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
# Build a tiny valid directory so the chat endpoint doesn't 404.
|
||||
from pathlib import Path
|
||||
vault_dir = Path(os.environ.get("VAULT_1_PATH", "test-vault"))
|
||||
sub = vault_dir / "for_chat_test"
|
||||
sub.mkdir(exist_ok=True)
|
||||
(sub / "note.md").write_text("# hello\n", encoding="utf-8")
|
||||
try:
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/chat",
|
||||
json={
|
||||
"vault": "TestVault",
|
||||
"directory": "for_chat_test",
|
||||
"message": "ping",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
# Either 200 (if a real key is configured) or 500 (no key / quota).
|
||||
# Must NOT be 422 — the schema must accept the fields.
|
||||
assert resp.status_code in (200, 500), (
|
||||
f"unexpected status {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
assert resp.status_code != 422, (
|
||||
f"schema rejected provider/model fields: {resp.text[:300]}"
|
||||
)
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(sub, ignore_errors=True)
|
||||
|
||||
|
||||
# ── Unit tests: explicit documents context + general prompt ────────────
|
||||
|
||||
|
||||
class TestCollectFilesContext:
|
||||
"""Tests for collect_files_context() (Documents mode)."""
|
||||
|
||||
def test_collects_only_requested_files(self, tmp_path):
|
||||
from backend.bookslm import collect_files_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
(vault / "sub").mkdir(parents=True)
|
||||
(vault / "a.md").write_text("# A", encoding="utf-8")
|
||||
(vault / "b.md").write_text("# B", encoding="utf-8")
|
||||
(vault / "sub" / "c.md").write_text("# C", encoding="utf-8")
|
||||
|
||||
result = collect_files_context(vault, ["a.md", "sub/c.md"], scope="documents")
|
||||
|
||||
assert result["scope"] == "documents"
|
||||
assert result["file_count"] == 2
|
||||
assert [f["path"] for f in result["files"]] == ["a.md", "sub/c.md"]
|
||||
assert result["directory_tree"] == ""
|
||||
|
||||
def test_ignores_paths_outside_vault(self, tmp_path):
|
||||
from backend.bookslm import collect_files_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "ok.md").write_text("# ok", encoding="utf-8")
|
||||
outside = tmp_path / "outside.md"
|
||||
outside.write_text("# secret", encoding="utf-8")
|
||||
|
||||
result = collect_files_context(vault, ["../outside.md", "ok.md"])
|
||||
|
||||
assert result["file_count"] == 1
|
||||
assert result["files"][0]["path"] == "ok.md"
|
||||
|
||||
def test_deduplicates_paths(self, tmp_path):
|
||||
from backend.bookslm import collect_files_context
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "dup.md").write_text("# dup", encoding="utf-8")
|
||||
|
||||
result = collect_files_context(vault, ["dup.md", "dup.md"])
|
||||
|
||||
assert result["file_count"] == 1
|
||||
|
||||
def test_empty_context(self):
|
||||
from backend.bookslm import empty_context
|
||||
|
||||
ctx = empty_context("general")
|
||||
assert ctx["file_count"] == 0
|
||||
assert ctx["files"] == []
|
||||
assert ctx["scope"] == "general"
|
||||
|
||||
|
||||
class TestGeneralPrompt:
|
||||
def test_general_prompt_lists_vaults(self):
|
||||
from backend.bookslm import build_general_system_prompt
|
||||
|
||||
prompt = build_general_system_prompt(["Alpha", "Beta"])
|
||||
assert "Alpha" in prompt
|
||||
assert "Beta" in prompt
|
||||
assert "obsigate-action" in prompt
|
||||
|
||||
def test_documents_prompt_scope(self):
|
||||
from backend.bookslm import build_system_prompt
|
||||
|
||||
context = {
|
||||
"files": [{"path": "a.md", "title": "A", "content": "hello"}],
|
||||
"file_count": 1,
|
||||
"total_chars": 5,
|
||||
"directory_tree": "",
|
||||
}
|
||||
prompt = build_system_prompt(context, scope="documents")
|
||||
assert "Documents ouverts" in prompt
|
||||
assert "hello" in prompt
|
||||
|
||||
|
||||
class TestBooksLMContextModes:
|
||||
"""Tests for the mode-aware /context endpoint."""
|
||||
|
||||
def test_general_mode_needs_no_vault(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"mode": "general"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["scope"] == "general"
|
||||
assert data["file_count"] == 0
|
||||
assert data["files"] == []
|
||||
|
||||
def test_documents_mode_returns_selected_files(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={
|
||||
"mode": "documents",
|
||||
"vault": "TestVault",
|
||||
"context_files": ["notes/note1.md"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["scope"] == "documents"
|
||||
assert data["file_count"] == 1
|
||||
assert data["files"][0]["path"] == "notes/note1.md"
|
||||
|
||||
def test_documents_mode_without_files_degrades_to_general(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"mode": "documents", "vault": "TestVault", "context_files": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["scope"] == "general"
|
||||
assert data["file_count"] == 0
|
||||
|
||||
def test_documents_mode_requires_vault(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"mode": "documents", "context_files": ["notes/note1.md"]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_unknown_mode_falls_back_to_directory(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/context",
|
||||
json={"mode": "bogus", "vault": "TestVault", "directory": "notes"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["scope"] == "directory"
|
||||
|
||||
|
||||
class TestBooksLMChatModes:
|
||||
def test_general_chat_does_not_require_vault(self, bookslm_client):
|
||||
"""General mode must not 400/404 on a missing vault (only 500 if no key)."""
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/chat",
|
||||
json={"mode": "general", "message": "Bonjour"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
|
||||
assert resp.status_code != 422
|
||||
|
||||
def test_documents_chat_accepts_context_files(self, bookslm_client):
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/chat",
|
||||
json={
|
||||
"mode": "documents",
|
||||
"vault": "TestVault",
|
||||
"context_files": ["notes/note1.md"],
|
||||
"message": "Résume",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
|
||||
assert resp.status_code != 422
|
||||
@@ -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,172 @@
|
||||
# tests/test_export.py — Tests for multi-format export (HTML / MD bundle / ePub)
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.export import (
|
||||
ExportError,
|
||||
export_epub,
|
||||
export_html,
|
||||
export_md_bundle,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# export_html
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExportHtml:
|
||||
def test_valid_html(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note1.md")
|
||||
assert isinstance(html, bytes)
|
||||
text = html.decode("utf-8")
|
||||
assert text.startswith("<!DOCTYPE html>")
|
||||
assert "<html" in text
|
||||
assert "</html>" in text
|
||||
assert "<style>" in text # inlined CSS
|
||||
|
||||
def test_renders_content(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note1.md").decode("utf-8")
|
||||
assert "Introduction" in html
|
||||
assert "Python" in html
|
||||
|
||||
def test_uses_frontmatter_title(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note1.md").decode("utf-8")
|
||||
assert "<title>Introduction à Python</title>" in html
|
||||
|
||||
def test_navigation_lists_other_notes(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note1.md").decode("utf-8")
|
||||
assert "class=\"export-nav\"" in html
|
||||
# note2 should appear as a sibling link
|
||||
assert "note2.html" in html or "note2" in html
|
||||
|
||||
def test_wikilink_becomes_link(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
html = export_html(vault, vault / "note2.md").decode("utf-8")
|
||||
assert "href=" in html
|
||||
|
||||
def test_missing_file_raises(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
with pytest.raises(ExportError):
|
||||
export_html(vault, vault / "does_not_exist.md")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# export_md_bundle
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExportMdBundle:
|
||||
def test_zip_contains_files(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_md_bundle(vault, vault)
|
||||
assert isinstance(data, bytes)
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
names = zf.namelist()
|
||||
assert "note1.md" in names
|
||||
assert "note2.md" in names
|
||||
assert "config.json" in names
|
||||
# Subdirectory preserved
|
||||
assert any("Projets/projet.md" in n for n in names)
|
||||
|
||||
def test_zip_preserves_structure(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_md_bundle(vault, vault / "Projets")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
names = zf.namelist()
|
||||
assert "projet.md" in names
|
||||
|
||||
def test_single_file_bundle(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_md_bundle(vault, vault / "note1.md")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
assert "note1.md" in zf.namelist()
|
||||
|
||||
def test_missing_path_raises(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
with pytest.raises(ExportError):
|
||||
export_md_bundle(vault, vault / "nope")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# export_epub
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExportEpub:
|
||||
def test_epub_is_valid_zip(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_epub(vault, vault / "note1.md")
|
||||
assert isinstance(data, bytes)
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
assert zf.testzip() is None # archive is intact
|
||||
|
||||
def test_epub_mimetype_first(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_epub(vault, vault / "note1.md")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
names = zf.namelist()
|
||||
assert names[0] == "mimetype"
|
||||
assert zf.read("mimetype") == b"application/epub+zip"
|
||||
|
||||
def test_epub_required_files(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_epub(vault, vault / "note1.md")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
names = set(zf.namelist())
|
||||
assert "META-INF/container.xml" in names
|
||||
assert "OEBPS/content.opf" in names
|
||||
assert "OEBPS/toc.ncx" in names
|
||||
assert "OEBPS/chapter1.xhtml" in names
|
||||
|
||||
def test_epub_content_rendered(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
data = export_epub(vault, vault / "note1.md")
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
xhtml = zf.read("OEBPS/chapter1.xhtml").decode("utf-8")
|
||||
assert "Introduction" in xhtml
|
||||
assert "Python" in xhtml
|
||||
|
||||
def test_epub_missing_file_raises(self, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
with pytest.raises(ExportError):
|
||||
export_epub(vault, vault / "nope.md")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# API endpoints
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExportApi:
|
||||
def test_export_html_endpoint(self, client):
|
||||
resp = client.get("/api/export/html", params={"vault": "TestVault", "path": "note1.md"})
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
assert "attachment" in resp.headers["content-disposition"]
|
||||
assert resp.content.startswith(b"<!DOCTYPE html>")
|
||||
|
||||
def test_export_md_bundle_endpoint(self, client):
|
||||
resp = client.get("/api/export/md-bundle", params={"vault": "TestVault", "path": ""})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/zip"
|
||||
zf = zipfile.ZipFile(io.BytesIO(resp.content))
|
||||
assert "note1.md" in zf.namelist()
|
||||
|
||||
def test_export_epub_endpoint(self, client):
|
||||
resp = client.get("/api/export/epub", params={"vault": "TestVault", "path": "note1.md"})
|
||||
assert resp.status_code == 200
|
||||
assert "epub" in resp.headers["content-type"]
|
||||
zf = zipfile.ZipFile(io.BytesIO(resp.content))
|
||||
assert zf.namelist()[0] == "mimetype"
|
||||
|
||||
def test_export_missing_file_returns_400(self, client):
|
||||
resp = client.get("/api/export/html", params={"vault": "TestVault", "path": "nope.md"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_export_unknown_vault_returns_404(self, client):
|
||||
resp = client.get("/api/export/html", params={"vault": "NoVault", "path": "x.md"})
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Tests for MFA (Multi-Factor Authentication) — TOTP + recovery codes."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
|
||||
from backend.auth.mfa import (
|
||||
TOTP_ISSUER,
|
||||
generate_qr_uri,
|
||||
generate_recovery_codes,
|
||||
generate_secret,
|
||||
hash_recovery_code,
|
||||
verify_recovery_code,
|
||||
verify_totp,
|
||||
)
|
||||
|
||||
|
||||
# ── Unit tests: TOTP ───────────────────────────────────────────────────
|
||||
|
||||
class TestTotpGeneration:
|
||||
def test_generate_secret_returns_base32(self):
|
||||
secret = generate_secret()
|
||||
assert isinstance(secret, str)
|
||||
assert len(secret) >= 16
|
||||
import base64
|
||||
base64.b32decode(secret, casefold=True)
|
||||
|
||||
def test_generate_secret_unique(self):
|
||||
secrets = {generate_secret() for _ in range(100)}
|
||||
assert len(secrets) == 100
|
||||
|
||||
def test_generate_qr_uri_format(self):
|
||||
secret = generate_secret()
|
||||
uri = generate_qr_uri(secret, "testuser")
|
||||
assert uri.startswith("otpauth://totp/")
|
||||
assert "ObsiGate" in uri
|
||||
assert "testuser" in uri
|
||||
assert "secret=" in uri
|
||||
|
||||
def test_generate_qr_uri_custom_issuer(self):
|
||||
secret = generate_secret()
|
||||
uri = generate_qr_uri(secret, "testuser", issuer="CustomIssuer")
|
||||
assert "CustomIssuer" in uri
|
||||
|
||||
def test_verify_totp_valid(self):
|
||||
secret = generate_secret()
|
||||
totp = pyotp.TOTP(secret)
|
||||
code = totp.now()
|
||||
assert verify_totp(secret, code) is True
|
||||
|
||||
def test_verify_totp_invalid(self):
|
||||
secret = generate_secret()
|
||||
assert verify_totp(secret, "000000") is False
|
||||
|
||||
def test_verify_totp_wrong_secret(self):
|
||||
secret1 = generate_secret()
|
||||
secret2 = generate_secret()
|
||||
totp1 = pyotp.TOTP(secret1)
|
||||
code = totp1.now()
|
||||
assert verify_totp(secret2, code) is False
|
||||
|
||||
def test_verify_totp_window_tolerance(self):
|
||||
secret = generate_secret()
|
||||
totp = pyotp.TOTP(secret)
|
||||
current_time = time.time()
|
||||
code = totp.at(int(current_time))
|
||||
assert verify_totp(secret, str(code).zfill(6)) is True
|
||||
|
||||
|
||||
# ── Unit tests: Recovery codes ─────────────────────────────────────────
|
||||
|
||||
class TestRecoveryCodes:
|
||||
def test_generate_count(self):
|
||||
codes = generate_recovery_codes()
|
||||
assert len(codes) == 8
|
||||
|
||||
def test_generate_custom_count(self):
|
||||
codes = generate_recovery_codes(n=12)
|
||||
assert len(codes) == 12
|
||||
|
||||
def test_code_format(self):
|
||||
codes = generate_recovery_codes()
|
||||
for code in codes:
|
||||
assert len(code) == 9 # XXXX-XXXX
|
||||
assert code[4] == "-"
|
||||
assert code[:4].isalnum()
|
||||
assert code[5:].isalnum()
|
||||
|
||||
def test_codes_unique(self):
|
||||
codes = generate_recovery_codes(n=20)
|
||||
assert len(set(codes)) == 20
|
||||
|
||||
def test_codes_uppercase(self):
|
||||
codes = generate_recovery_codes()
|
||||
for code in codes:
|
||||
assert code == code.upper()
|
||||
|
||||
def test_hash_recovery_code_deterministic(self):
|
||||
code = "ABCD-1234"
|
||||
h1 = hash_recovery_code(code)
|
||||
h2 = hash_recovery_code(code)
|
||||
assert h1 == h2
|
||||
|
||||
def test_hash_case_insensitive(self):
|
||||
h1 = hash_recovery_code("abcd-1234")
|
||||
h2 = hash_recovery_code("ABCD-1234")
|
||||
assert h1 == h2
|
||||
|
||||
def test_hash_different_for_different_codes(self):
|
||||
h1 = hash_recovery_code("AAAA-AAAA")
|
||||
h2 = hash_recovery_code("BBBB-BBBB")
|
||||
assert h1 != h2
|
||||
|
||||
def test_verify_recovery_code_match(self):
|
||||
codes = generate_recovery_codes()
|
||||
hashed = [hash_recovery_code(c) for c in codes]
|
||||
for i, code in enumerate(codes):
|
||||
idx = verify_recovery_code(code, hashed)
|
||||
assert idx == i
|
||||
|
||||
def test_verify_recovery_code_case_insensitive(self):
|
||||
codes = ["ABCD-1234"]
|
||||
hashed = [hash_recovery_code(c) for c in codes]
|
||||
assert verify_recovery_code("abcd-1234", hashed) == 0
|
||||
|
||||
def test_verify_recovery_code_invalid(self):
|
||||
codes = generate_recovery_codes()
|
||||
hashed = [hash_recovery_code(c) for c in codes]
|
||||
assert verify_recovery_code("ZZZZ-ZZZZ", hashed) is None
|
||||
|
||||
def test_verify_recovery_code_empty_list(self):
|
||||
assert verify_recovery_code("AAAA-AAAA", []) is None
|
||||
|
||||
def test_recovery_code_single_use(self):
|
||||
codes = generate_recovery_codes(n=3)
|
||||
hashed = [hash_recovery_code(c) for c in codes]
|
||||
idx = verify_recovery_code(codes[0], hashed)
|
||||
assert idx == 0
|
||||
hashed.pop(idx)
|
||||
assert verify_recovery_code(codes[0], hashed) is None
|
||||
idx2 = verify_recovery_code(codes[1], hashed)
|
||||
assert idx2 == 0
|
||||
|
||||
|
||||
# ── Integration tests: MFA API endpoints ───────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def mfa_client():
|
||||
"""Create a TestClient with auth enabled, isolated temp data."""
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
data_dir = tmp / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
from backend.auth.password import hash_password
|
||||
pw_hash = hash_password("TestPass123!")
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"testuser": {
|
||||
"id": "testuser-1",
|
||||
"username": "testuser",
|
||||
"display_name": "Test User",
|
||||
"password_hash": pw_hash,
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
}
|
||||
}
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
orig_cwd = os.getcwd()
|
||||
test_vault_path = os.path.abspath("test-vault")
|
||||
os.chdir(str(tmp))
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = test_vault_path
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_ADMIN_USER"] = "testuser"
|
||||
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "TestPass123!"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.main import app
|
||||
from backend.indexer import build_index, index
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
from backend.search import init_inverted_index
|
||||
init_inverted_index()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
if hasattr(client, 'close'):
|
||||
client.close()
|
||||
loop.run_until_complete(asyncio.sleep(0))
|
||||
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
def _login(client, username="testuser", password="TestPass123!"):
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
return resp.json().get("access_token"), resp
|
||||
|
||||
|
||||
def _auth_headers(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
class TestMfaApiEndpoints:
|
||||
def test_mfa_status_initially_disabled(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
resp = mfa_client.get("/api/auth/mfa/status", headers=_auth_headers(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["mfa_enabled"] is False
|
||||
|
||||
def test_mfa_setup_generates_secret(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=_auth_headers(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "secret" in data
|
||||
assert "otpauth_uri" in data
|
||||
assert "otpauth://totp/" in data["otpauth_uri"]
|
||||
assert len(data["secret"]) >= 16
|
||||
|
||||
def test_mfa_enable_flow(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
headers = _auth_headers(token)
|
||||
# Setup
|
||||
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
|
||||
secret = setup_resp.json()["secret"]
|
||||
totp = pyotp.TOTP(secret)
|
||||
# Enable
|
||||
enable_resp = mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={
|
||||
"code": totp.now(),
|
||||
})
|
||||
assert enable_resp.status_code == 200
|
||||
data = enable_resp.json()
|
||||
assert "recovery_codes" in data
|
||||
assert len(data["recovery_codes"]) == 8
|
||||
assert data["mfa_enabled"] is True
|
||||
|
||||
def test_mfa_enable_invalid_code(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
headers = _auth_headers(token)
|
||||
mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
|
||||
resp = mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={
|
||||
"code": "000000",
|
||||
})
|
||||
assert resp.status_code in (400, 401)
|
||||
|
||||
def test_mfa_login_defers_to_totp(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
headers = _auth_headers(token)
|
||||
# Enable MFA
|
||||
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
|
||||
secret = setup_resp.json()["secret"]
|
||||
totp = pyotp.TOTP(secret)
|
||||
mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()})
|
||||
# Login again — should require MFA
|
||||
login_resp = mfa_client.post("/api/auth/login", json={
|
||||
"username": "testuser", "password": "TestPass123!",
|
||||
})
|
||||
assert login_resp.status_code == 200
|
||||
data = login_resp.json()
|
||||
assert data.get("mfa_required") is True
|
||||
assert data.get("mfa_method") == "totp"
|
||||
assert "access_token" not in data
|
||||
|
||||
def test_mfa_verify_issues_token(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
headers = _auth_headers(token)
|
||||
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
|
||||
secret = setup_resp.json()["secret"]
|
||||
totp = pyotp.TOTP(secret)
|
||||
mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()})
|
||||
# Login → MFA challenge
|
||||
mfa_client.post("/api/auth/login", json={"username": "testuser", "password": "TestPass123!"})
|
||||
# Verify TOTP
|
||||
verify_resp = mfa_client.post("/api/auth/mfa/totp/verify", json={
|
||||
"username": "testuser", "code": totp.now(),
|
||||
})
|
||||
assert verify_resp.status_code == 200
|
||||
data = verify_resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_mfa_recovery_login(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
headers = _auth_headers(token)
|
||||
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
|
||||
secret = setup_resp.json()["secret"]
|
||||
totp = pyotp.TOTP(secret)
|
||||
enable_resp = mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()})
|
||||
recovery_codes = enable_resp.json()["recovery_codes"]
|
||||
# Login → MFA challenge
|
||||
mfa_client.post("/api/auth/login", json={"username": "testuser", "password": "TestPass123!"})
|
||||
# Use recovery code
|
||||
recover_resp = mfa_client.post("/api/auth/mfa/recovery", json={
|
||||
"username": "testuser", "recovery_code": recovery_codes[0],
|
||||
})
|
||||
assert recover_resp.status_code == 200
|
||||
assert "access_token" in recover_resp.json()
|
||||
|
||||
def test_mfa_disable_flow(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
headers = _auth_headers(token)
|
||||
setup_resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=headers)
|
||||
secret = setup_resp.json()["secret"]
|
||||
totp = pyotp.TOTP(secret)
|
||||
mfa_client.post("/api/auth/mfa/totp/enable", headers=headers, json={"code": totp.now()})
|
||||
# Disable
|
||||
disable_resp = mfa_client.post("/api/auth/mfa/totp/disable", headers=headers, json={
|
||||
"password": "TestPass123!", "code": totp.now(),
|
||||
})
|
||||
assert disable_resp.status_code == 200
|
||||
# Verify disabled
|
||||
status_resp = mfa_client.get("/api/auth/mfa/status", headers=headers)
|
||||
assert status_resp.json()["mfa_enabled"] is False
|
||||
|
||||
def test_login_without_mfa_still_works(self, mfa_client):
|
||||
login_resp = mfa_client.post("/api/auth/login", json={
|
||||
"username": "testuser", "password": "TestPass123!",
|
||||
})
|
||||
assert login_resp.status_code == 200
|
||||
data = login_resp.json()
|
||||
assert "access_token" in data
|
||||
assert data.get("mfa_required") is None
|
||||
@@ -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,329 @@
|
||||
"""Tests for PDF support in ObsiGate (ROADMAP #74).
|
||||
|
||||
Covers:
|
||||
- backend/pdf_reader.py: text/metadata/TOC extraction with pypdf + pymupdf
|
||||
- backend/main.py: api_pdf_stream endpoint, is_pdf detection in api_file_view
|
||||
- backend/indexer.py: .pdf in SUPPORTED_EXTENSIONS, index_document handles PDFs
|
||||
- backend/search.py: filter `ext:pdf` returns only PDFs (already implemented)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Skip the whole module if neither PDF library is available.
|
||||
try:
|
||||
import pypdf # noqa: F401
|
||||
|
||||
HAS_PDF_LIB = True
|
||||
except ImportError:
|
||||
try:
|
||||
import fitz # noqa: F401 # pymupdf
|
||||
|
||||
HAS_PDF_LIB = True
|
||||
except ImportError:
|
||||
HAS_PDF_LIB = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not HAS_PDF_LIB, reason="Neither pypdf nor pymupdf is installed"
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures: generate a real PDF on disk ──────────────────────────────────
|
||||
|
||||
|
||||
def make_simple_pdf(path: Path, *, pages: int = 2, title: str = "", author: str = "") -> Path:
|
||||
"""Create a PDF with `pages` pages, each page containing a unique sentence."""
|
||||
try:
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.pdfgen import canvas
|
||||
except ImportError:
|
||||
pytest.skip("reportlab not available — cannot generate test PDF fixture")
|
||||
|
||||
c = canvas.Canvas(str(path), pagesize=letter)
|
||||
if title:
|
||||
c.setTitle(title)
|
||||
if author:
|
||||
c.setAuthor(author)
|
||||
for i in range(pages):
|
||||
c.drawString(72, 720, f"ObsiGate test PDF — page {i + 1} uniqueword{i}")
|
||||
c.showPage()
|
||||
c.save()
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_dir(tmp_path: Path) -> Path:
|
||||
"""A temp directory with a few PDFs of different shapes."""
|
||||
d = tmp_path / "pdfs"
|
||||
d.mkdir()
|
||||
make_simple_pdf(d / "simple.pdf", pages=2, title="Simple Test", author="Bruno")
|
||||
make_simple_pdf(d / "long.pdf", pages=3)
|
||||
make_simple_pdf(d / "single.pdf", pages=1)
|
||||
return d
|
||||
|
||||
|
||||
# ── backend/pdf_reader.py ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPdfReader:
|
||||
def test_extract_text_returns_text_with_keywords(self, pdf_dir: Path):
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
text = extract_pdf_text(pdf_dir / "simple.pdf")
|
||||
assert "ObsiGate test PDF" in text
|
||||
assert "uniqueword0" in text
|
||||
assert "uniqueword1" in text
|
||||
|
||||
def test_extract_text_truncates_at_max_chars(self, pdf_dir: Path):
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
# tight max_chars truncates after the first page
|
||||
text = extract_pdf_text(pdf_dir / "long.pdf", max_chars=10)
|
||||
assert len(text) <= 50 # allow some slack; first page may have ~30 chars
|
||||
|
||||
def test_extract_text_missing_file_returns_empty(self, tmp_path: Path):
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
result = extract_pdf_text(tmp_path / "does-not-exist.pdf")
|
||||
assert result == ""
|
||||
|
||||
def test_extract_text_corrupt_file_returns_empty(self, tmp_path: Path):
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
junk = tmp_path / "junk.pdf"
|
||||
junk.write_bytes(b"not a real pdf, just some bytes %PDF-1.4 but no xref")
|
||||
result = extract_pdf_text(junk)
|
||||
# Should not raise; returns "" on failure
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_extract_metadata_returns_pages_title_author(self, pdf_dir: Path):
|
||||
from backend.pdf_reader import extract_pdf_metadata
|
||||
|
||||
info = extract_pdf_metadata(pdf_dir / "simple.pdf")
|
||||
assert info["pages"] == 2
|
||||
assert info["title"] in ("Simple Test", "") # metadata may be empty on some readers
|
||||
assert isinstance(info["author"], str)
|
||||
|
||||
def test_extract_metadata_missing_file_returns_zeros(self, tmp_path: Path):
|
||||
from backend.pdf_reader import extract_pdf_metadata
|
||||
|
||||
info = extract_pdf_metadata(tmp_path / "missing.pdf")
|
||||
assert info == {"pages": 0, "title": "", "author": ""}
|
||||
|
||||
def test_extract_toc_returns_list(self, pdf_dir: Path):
|
||||
from backend.pdf_reader import extract_pdf_toc
|
||||
|
||||
# simple PDFs (no outline) → empty list, no exception
|
||||
toc = extract_pdf_toc(pdf_dir / "simple.pdf")
|
||||
assert isinstance(toc, list)
|
||||
|
||||
|
||||
class TestPdfIncrementalIndexing:
|
||||
"""_index_single_file_sync (watcher/update path) must handle binary PDFs.
|
||||
|
||||
Regression: it used to read_text() every file, producing garbage for PDFs.
|
||||
"""
|
||||
|
||||
def test_index_single_file_sync_extracts_pdf_text(self, pdf_dir: Path):
|
||||
from backend.indexer import _index_single_file_sync
|
||||
|
||||
info = _index_single_file_sync("v", str(pdf_dir), str(pdf_dir / "simple.pdf"))
|
||||
assert info is not None
|
||||
assert info["extension"] == ".pdf"
|
||||
assert "ObsiGate test PDF" in info["content"]
|
||||
assert "uniqueword0" in info["content"]
|
||||
|
||||
def test_index_single_file_sync_pdf_title_from_metadata(self, pdf_dir: Path):
|
||||
from backend.indexer import _index_single_file_sync
|
||||
|
||||
info = _index_single_file_sync("v", str(pdf_dir), str(pdf_dir / "simple.pdf"))
|
||||
# title metadata was set at generation time
|
||||
assert info["title"] == "Simple Test"
|
||||
|
||||
|
||||
class TestPdfSizeLimit:
|
||||
def test_oversized_pdf_skips_text_extraction(self, pdf_dir: Path, monkeypatch):
|
||||
import backend.pdf_reader as pr
|
||||
|
||||
monkeypatch.setattr(pr, "PDF_MAX_SIZE_MB", 0) # everything is "too large"
|
||||
text = pr.extract_pdf_text(pdf_dir / "simple.pdf")
|
||||
assert text == ""
|
||||
|
||||
def test_normal_pdf_within_limit_extracts(self, pdf_dir: Path, monkeypatch):
|
||||
import backend.pdf_reader as pr
|
||||
|
||||
monkeypatch.setattr(pr, "PDF_MAX_SIZE_MB", 50)
|
||||
text = pr.extract_pdf_text(pdf_dir / "simple.pdf")
|
||||
assert "ObsiGate test PDF" in text
|
||||
|
||||
|
||||
# ── API: /pdf/stream (206 Range) + /pdf/info ───────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_client():
|
||||
"""TestClient (auth disabled) over a vault containing one generated PDF."""
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
vault = tmp / "PdfVault"
|
||||
vault.mkdir()
|
||||
make_simple_pdf(vault / "doc.pdf", pages=2, title="Doc Test", author="Bruno")
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "PdfVault"
|
||||
os.environ["VAULT_1_PATH"] = str(vault)
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.indexer import build_index, index
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
|
||||
import asyncio
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
client = TestClient(backend.main.app)
|
||||
yield client
|
||||
client.close()
|
||||
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED", "OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
class TestPdfStreamApi:
|
||||
def test_stream_returns_200_application_pdf(self, pdf_client):
|
||||
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "application/pdf"
|
||||
assert r.headers.get("accept-ranges") == "bytes"
|
||||
assert r.content[:4] == b"%PDF"
|
||||
|
||||
def test_stream_full_range_returns_whole_file(self, pdf_client):
|
||||
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf",
|
||||
headers={"Range": "bytes=0-99999999"})
|
||||
assert r.status_code == 206
|
||||
assert r.headers["content-range"].startswith("bytes 0-")
|
||||
assert r.content[:4] == b"%PDF"
|
||||
|
||||
def test_stream_partial_range(self, pdf_client):
|
||||
full = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf").content
|
||||
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf",
|
||||
headers={"Range": "bytes=10-19"})
|
||||
assert r.status_code == 206
|
||||
assert r.content == full[10:20]
|
||||
assert len(r.content) == 10
|
||||
|
||||
def test_stream_open_ended_range(self, pdf_client):
|
||||
full = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf").content
|
||||
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf",
|
||||
headers={"Range": "bytes=100-"})
|
||||
assert r.status_code == 206
|
||||
assert r.content == full[100:]
|
||||
|
||||
def test_stream_bad_range_returns_416(self, pdf_client):
|
||||
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.pdf",
|
||||
headers={"Range": "bytes=999999999-"})
|
||||
assert r.status_code == 416
|
||||
assert "bytes */" in r.headers.get("content-range", "")
|
||||
|
||||
def test_stream_rejects_non_pdf(self, pdf_client):
|
||||
r = pdf_client.get("/api/file/PdfVault/pdf/stream?path=doc.md")
|
||||
assert r.status_code == 404 or r.status_code == 400
|
||||
|
||||
|
||||
class TestPdfInfoApi:
|
||||
def test_info_returns_metadata_without_content(self, pdf_client):
|
||||
r = pdf_client.get("/api/file/PdfVault/pdf/info?path=doc.pdf")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["pages"] == 2
|
||||
assert body["title"] in ("Doc Test", "doc.pdf")
|
||||
assert body["size_bytes"] > 0
|
||||
assert "path" in body and body["vault"] == "PdfVault"
|
||||
# no heavy content in the payload
|
||||
assert "html" not in body
|
||||
|
||||
def test_info_missing_file_404(self, pdf_client):
|
||||
r = pdf_client.get("/api/file/PdfVault/pdf/info?path=nope.pdf")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_info_missing_non_pdf_404(self, pdf_client):
|
||||
r = pdf_client.get("/api/file/PdfVault/pdf/info?path=readme.txt")
|
||||
assert r.status_code == 404 # missing file checked before extension
|
||||
|
||||
|
||||
# ── backend/indexer.py ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPdfIndexing:
|
||||
def test_pdf_in_supported_extensions(self):
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
|
||||
assert ".pdf" in SUPPORTED_EXTENSIONS
|
||||
|
||||
def test_scan_vault_picks_up_pdf_files(self, pdf_dir: Path, tmp_path: Path):
|
||||
"""When a vault directory is scanned with .pdf files, they appear in files list.
|
||||
|
||||
Uses the public _scan_vault() helper directly — no global state needed.
|
||||
"""
|
||||
from backend.indexer import _scan_vault
|
||||
|
||||
vault_root = tmp_path / "vault"
|
||||
vault_root.mkdir()
|
||||
shutil.copy2(pdf_dir / "simple.pdf", vault_root / "a.pdf")
|
||||
shutil.copy2(pdf_dir / "single.pdf", vault_root / "b.pdf")
|
||||
result = _scan_vault("test-vault", str(vault_root), {"name": "test-vault", "path": str(vault_root)})
|
||||
names = {f["path"] for f in result["files"]}
|
||||
assert "a.pdf" in names
|
||||
assert "b.pdf" in names
|
||||
# The PDF content should have been extracted.
|
||||
a_file = next(f for f in result["files"] if f["path"] == "a.pdf")
|
||||
assert "ObsiGate test PDF" in (a_file.get("content") or "")
|
||||
assert "uniqueword0" in (a_file.get("content") or "")
|
||||
|
||||
|
||||
# ── Search filter `ext:` ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtFilter:
|
||||
"""The `ext:` filter is parsed in backend/search.py and applied in the
|
||||
search pipeline. These tests verify the parsing + filter logic in isolation
|
||||
so we don't depend on the full index state.
|
||||
"""
|
||||
|
||||
def test_parse_ext_token(self):
|
||||
from backend.search import _parse_advanced_query
|
||||
|
||||
parsed = _parse_advanced_query("hello ext:pdf world")
|
||||
assert parsed["ext"] == "pdf"
|
||||
assert "hello" in parsed["terms"]
|
||||
assert "world" in parsed["terms"]
|
||||
|
||||
def test_parse_ext_token_with_dot(self):
|
||||
from backend.search import _parse_advanced_query
|
||||
|
||||
parsed = _parse_advanced_query("ext:.md")
|
||||
assert parsed["ext"] == "md"
|
||||
|
||||
def test_parse_no_ext_token(self):
|
||||
from backend.search import _parse_advanced_query
|
||||
|
||||
parsed = _parse_advanced_query("hello world")
|
||||
# ext key is initialized to None and stays None if no ext: token.
|
||||
assert parsed.get("ext") in (None, "")
|
||||
|
||||
def test_parse_ext_token_lowercased(self):
|
||||
from backend.search import _parse_advanced_query
|
||||
|
||||
parsed = _parse_advanced_query("ext:PDF")
|
||||
assert parsed["ext"] == "pdf"
|
||||
@@ -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
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Tests for the Custom Themes feature (#65).
|
||||
|
||||
Validates:
|
||||
- Theme JSON structure validity
|
||||
- All 15 built-in themes have required keys and valid CSS colors
|
||||
- High-contrast and sepia mode generators produce valid CSS vars
|
||||
- Import/export round-trip
|
||||
- AVAILABLE_MODES includes all 4 modes
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import pytest
|
||||
|
||||
|
||||
# ── CSS color validation ─────────────────────────────────────────────
|
||||
_CSS_COLOR_RE = re.compile(
|
||||
r"^("
|
||||
r"#[0-9a-fA-F]{3,8}" # hex
|
||||
r"|rgba?\([^)]+\)" # rgb/rgba
|
||||
r"|hsla?\([^)]+\)" # hsl/hsla
|
||||
r"|[a-zA-Z]+" # named color
|
||||
r")$"
|
||||
)
|
||||
|
||||
REQUIRED_VARS = [
|
||||
'--bg-primary', '--bg-secondary', '--bg-sidebar', '--bg-hover', '--border',
|
||||
'--text-primary', '--text-secondary', '--text-muted', '--accent',
|
||||
'--accent-green', '--tag-bg', '--tag-text', '--code-bg', '--search-bg',
|
||||
'--scrollbar', '--resize-handle', '--overlay-bg',
|
||||
'--danger', '--danger-bg', '--success', '--success-bg',
|
||||
'--accent-card', '--accent-bg', '--accent-text',
|
||||
'--green', '--green-bg', '--purple', '--purple-bg',
|
||||
'--surface', '--surface2', '--surface3',
|
||||
'--border-md', '--text', '--text-2', '--text-3',
|
||||
'--ok', '--warn', '--ai', '--err',
|
||||
]
|
||||
|
||||
AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']
|
||||
|
||||
|
||||
# ── Theme data extracted from themes.js ──────────────────────────────
|
||||
# We parse the JS file to extract theme definitions for validation.
|
||||
def _parse_themes_from_js():
|
||||
"""Read themes.js and build a dict of theme definitions using regex."""
|
||||
import os
|
||||
js_path = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'js', 'themes.js')
|
||||
with open(js_path, encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
return content
|
||||
|
||||
|
||||
def _extract_theme_names(content):
|
||||
"""Extract top-level theme keys from THEMES dict."""
|
||||
return re.findall(r"'([a-z0-9-]+)':\s*\{", content.split('var THEMES')[1].split('// ── Theme Engine')[0])
|
||||
|
||||
|
||||
def _count_css_vars_in_block(content, keyword, section_start):
|
||||
"""Count CSS variable assignments starting from a position."""
|
||||
return len(re.findall(r"'--[a-z]", content[section_start:section_start + 2000]))
|
||||
|
||||
|
||||
class TestThemeStructure:
|
||||
"""Validate theme data from themes.js."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def load_js(self):
|
||||
self.content = _parse_themes_from_js()
|
||||
|
||||
def test_themes_file_exists(self):
|
||||
"""themes.js should exist and be readable."""
|
||||
assert len(self.content) > 1000
|
||||
|
||||
def test_has_available_modes(self):
|
||||
"""AVAILABLE_MODES should define all 4 modes."""
|
||||
assert "AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']" in self.content
|
||||
|
||||
def test_has_mode_generators(self):
|
||||
"""Mode generators for high-contrast and sepia should exist."""
|
||||
assert '_genHighContrast' in self.content
|
||||
assert '_genSepia' in self.content
|
||||
|
||||
def test_theme_count(self):
|
||||
"""Should have 15 built-in themes."""
|
||||
names = _extract_theme_names(self.content)
|
||||
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}"
|
||||
|
||||
def test_required_export_functions(self):
|
||||
"""All required functions should be exported."""
|
||||
required_exports = [
|
||||
'THEMES', 'AVAILABLE_MODES',
|
||||
'applyTheme', 'toggleThemeMode',
|
||||
'getCurrentTheme', 'getCurrentMode',
|
||||
'setTheme', 'listThemes', 'listModes',
|
||||
'exportTheme', 'exportAllThemes', 'importTheme',
|
||||
'deleteCustomTheme', 'loadCustomThemes',
|
||||
'initThemes',
|
||||
]
|
||||
export_block = self.content.split('export {')[1].split('}')[0]
|
||||
for fn in required_exports:
|
||||
assert fn in export_block, f"Missing export: {fn}"
|
||||
|
||||
def test_high_contrast_vars_count(self):
|
||||
"""High-contrast generator should define 30+ CSS vars."""
|
||||
hc_start = self.content.index('_genHighContrast')
|
||||
hc_section = self.content[hc_start:hc_start + 2000]
|
||||
var_count = len(re.findall(r"'--[a-z]", hc_section))
|
||||
assert var_count >= 30, f"High-contrast defines {var_count} vars, expected >= 30"
|
||||
|
||||
def test_sepia_vars_count(self):
|
||||
"""Sepia generator should define 30+ CSS vars."""
|
||||
sepia_start = self.content.index('_genSepia')
|
||||
sepia_section = self.content[sepia_start:sepia_start + 2000]
|
||||
var_count = len(re.findall(r"'--[a-z]", sepia_section))
|
||||
assert var_count >= 30, f"Sepia defines {var_count} vars, expected >= 30"
|
||||
|
||||
def test_high_contrast_colors_valid(self):
|
||||
"""High-contrast mode should use valid CSS colors."""
|
||||
hc_start = self.content.index('_genHighContrast')
|
||||
hc_section = self.content[hc_start:hc_start + 2000]
|
||||
colors = re.findall(r":\s*'([^']+)'", hc_section)
|
||||
for color in colors:
|
||||
assert _CSS_COLOR_RE.match(color), f"Invalid CSS color in high-contrast: {color}"
|
||||
|
||||
def test_sepia_colors_valid(self):
|
||||
"""Sepia mode should use valid CSS colors."""
|
||||
sepia_start = self.content.index('_genSepia')
|
||||
sepia_section = self.content[sepia_start:sepia_start + 2000]
|
||||
colors = re.findall(r":\s*'([^']+)'", sepia_section)
|
||||
for color in colors:
|
||||
assert _CSS_COLOR_RE.match(color), f"Invalid CSS color in sepia: {color}"
|
||||
|
||||
def test_getvars_handles_all_modes(self):
|
||||
"""_getVars should handle dark, light, high-contrast, and sepia."""
|
||||
assert "mode === 'high-contrast'" in self.content
|
||||
assert "mode === 'sepia'" in self.content
|
||||
|
||||
def test_import_theme_validates_input(self):
|
||||
"""importTheme should validate JSON structure."""
|
||||
assert "missing modes" in self.content
|
||||
assert "at least dark or light" in self.content
|
||||
|
||||
def test_localstorage_keys(self):
|
||||
"""Custom themes should use proper localStorage key prefix."""
|
||||
assert "obsigate-custom-" in self.content
|
||||
assert "obsigate-theme" in self.content
|
||||
assert "obsigate-theme-mode" in self.content
|
||||
|
||||
def test_each_theme_has_dark_and_light(self):
|
||||
"""Each built-in theme should define dark and light modes."""
|
||||
names = _extract_theme_names(self.content)
|
||||
for name in names:
|
||||
pattern = re.compile(
|
||||
rf"'{re.escape(name)}':\s*\{{.*?modes:\s*\{{.*?dark:\s*\{{",
|
||||
re.DOTALL
|
||||
)
|
||||
assert pattern.search(self.content), f"Theme '{name}' missing dark mode"
|
||||
|
||||
def test_toggle_cycles_all_modes(self):
|
||||
"""toggleThemeMode should cycle through all 4 modes."""
|
||||
assert '% AVAILABLE_MODES.length' in self.content
|
||||
|
||||
|
||||
class TestI18nKeys:
|
||||
"""Validate that all theme i18n keys exist in both locales."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def load_locales(self):
|
||||
import os
|
||||
base = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'locales')
|
||||
with open(os.path.join(base, 'en.json'), encoding='utf-8') as f:
|
||||
self.en = json.load(f)
|
||||
with open(os.path.join(base, 'fr.json'), encoding='utf-8') as f:
|
||||
self.fr = json.load(f)
|
||||
|
||||
def test_theme_keys_en(self):
|
||||
"""English locale should have all theme keys."""
|
||||
required = [
|
||||
'theme.dark', 'theme.light', 'theme.high_contrast', 'theme.sepia',
|
||||
'theme.title', 'theme.change',
|
||||
'theme.export_all', 'theme.import',
|
||||
'theme.import_success', 'theme.import_error',
|
||||
'theme.delete_confirm',
|
||||
]
|
||||
for key in required:
|
||||
assert key in self.en, f"Missing EN key: {key}"
|
||||
|
||||
def test_theme_keys_fr(self):
|
||||
"""French locale should have all theme keys."""
|
||||
required = [
|
||||
'theme.dark', 'theme.light', 'theme.high_contrast', 'theme.sepia',
|
||||
'theme.title', 'theme.change',
|
||||
'theme.export_all', 'theme.import',
|
||||
'theme.import_success', 'theme.import_error',
|
||||
'theme.delete_confirm',
|
||||
]
|
||||
for key in required:
|
||||
assert key in self.fr, f"Missing FR key: {key}"
|
||||
|
||||
def test_import_success_has_placeholder(self):
|
||||
"""import_success key should accept a {count} parameter."""
|
||||
assert '{count}' in self.en['theme.import_success']
|
||||
assert '{count}' in self.fr['theme.import_success']
|
||||
|
||||
def test_key_parity(self):
|
||||
"""EN and FR should have the same set of theme.* keys."""
|
||||
en_theme = {k for k in self.en if k.startswith('theme.')}
|
||||
fr_theme = {k for k in self.fr if k.startswith('theme.')}
|
||||
assert en_theme == fr_theme, f"Key mismatch: EN-only={en_theme - fr_theme}, FR-only={fr_theme - en_theme}"
|
||||
@@ -74,6 +74,80 @@ class TestIsRelevant:
|
||||
assert handler._is_relevant("/vault/Makefile") is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Network-mount detection (NFS/SMB → polling watcher required)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestNetworkMountDetection:
|
||||
MOUNTS = [
|
||||
"overlay / overlay rw 0 0",
|
||||
"server:/export/NFS/OBSIDIAN_DOC /vaults/Obsidian_IT nfs4 rw 0 0",
|
||||
"server:/export/home/bruno /vaults/bruno nfs4 rw 0 0",
|
||||
"dev/sda1 /data ext4 rw 0 0",
|
||||
"srv:/share /mnt/smb cifs rw 0 0",
|
||||
]
|
||||
|
||||
def test_nfs_mount_detected(self):
|
||||
from backend.watcher import find_mount_fstype
|
||||
ft = find_mount_fstype("/vaults/Obsidian_IT/note.md", self.MOUNTS)
|
||||
assert ft == "nfs4"
|
||||
|
||||
def test_nested_nfs_mount(self):
|
||||
from backend.watcher import find_mount_fstype
|
||||
ft = find_mount_fstype("/vaults/bruno/sub/dir", self.MOUNTS)
|
||||
assert ft == "nfs4"
|
||||
|
||||
def test_smb_mount_detected(self):
|
||||
from backend.watcher import find_mount_fstype
|
||||
ft = find_mount_fstype("/mnt/smb/file.md", self.MOUNTS)
|
||||
assert ft == "cifs"
|
||||
|
||||
def test_local_fs_not_network(self):
|
||||
from backend.watcher import find_mount_fstype
|
||||
ft = find_mount_fstype("/data/file.md", self.MOUNTS)
|
||||
assert ft == "ext4"
|
||||
|
||||
def test_unmatched_path_returns_empty(self):
|
||||
from backend.watcher import find_mount_fstype
|
||||
assert find_mount_fstype("/nowhere/file.md", self.MOUNTS) == ""
|
||||
|
||||
def test_longest_prefix_wins(self):
|
||||
from backend.watcher import find_mount_fstype
|
||||
lines = self.MOUNTS + ["tmpfs /vaults/Obsidian_IT/tmp tmpfs rw 0 0"]
|
||||
assert find_mount_fstype("/vaults/Obsidian_IT/tmp/x", lines) == "tmpfs"
|
||||
assert find_mount_fstype("/vaults/Obsidian_IT/other", lines) == "nfs4"
|
||||
|
||||
def test_escaped_space_in_mount_point(self):
|
||||
from backend.watcher import find_mount_fstype
|
||||
lines = ["srv:/x /vaults/My\\040Vault nfs rw 0 0"]
|
||||
assert find_mount_fstype("/vaults/My Vault/a.md", lines) == "nfs"
|
||||
|
||||
def test_is_network_mount_false_on_windows(self, monkeypatch):
|
||||
import backend.watcher as w
|
||||
monkeypatch.setattr(w.os, "name", "nt")
|
||||
assert w.is_network_mount("/vaults/x") is False
|
||||
|
||||
def test_watch_vault_uses_polling_for_network_mount(self, monkeypatch):
|
||||
"""_watch_vault must pick PollingObserver when is_network_mount is True."""
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
import backend.watcher as w
|
||||
|
||||
watcher = w.VaultWatcher(on_file_change=MagicMock())
|
||||
monkeypatch.setattr(w, "is_network_mount", lambda p: True)
|
||||
mock_poll = MagicMock()
|
||||
monkeypatch.setattr(w, "PollingObserver", mock_poll)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(
|
||||
watcher._watch_vault("NetVault", tmpdir, loop)
|
||||
)
|
||||
mock_poll.assert_called_once()
|
||||
assert "NetVault" in watcher.observers
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# VaultWatcher (unit tests with mocks)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# tests/test_webauthn.py
|
||||
# WebAuthn MFA tests (ROADMAP #64).
|
||||
# Uses a virtual authenticator (EC P-256, packed-free 'none' attestation, raw
|
||||
# CBOR via cbor2) to exercise the real verification path end-to-end.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from webauthn.helpers import bytes_to_base64url
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return bytes_to_base64url(data)
|
||||
|
||||
|
||||
class VirtualAuthenticator:
|
||||
"""Minimal WebAuthn authenticator: generates a P-256 key, produces
|
||||
'none'-attestation registration responses and ES256 assertion responses."""
|
||||
|
||||
RP_ID = "localhost"
|
||||
ORIGIN = "http://localhost"
|
||||
|
||||
def __init__(self):
|
||||
self.key = ec.generate_private_key(ec.SECP256R1())
|
||||
self.credential_id = os.urandom(32)
|
||||
self.sign_count = 0
|
||||
|
||||
# ── COSE public key (ES256) ──
|
||||
def _cose_key(self) -> bytes:
|
||||
pub = self.key.public_key().public_numbers()
|
||||
x = pub.x.to_bytes(32, "big")
|
||||
y = pub.y.to_bytes(32, "big")
|
||||
return cbor2.dumps({1: 2, 3: -7, -1: 1, -2: x, -3: y}, canonical=True)
|
||||
|
||||
def _rp_id_hash(self) -> bytes:
|
||||
return hashlib.sha256(self.RP_ID.encode()).digest()
|
||||
|
||||
def _client_data(self, typ: str, challenge_b64: str) -> bytes:
|
||||
return json.dumps({
|
||||
"type": typ,
|
||||
"challenge": challenge_b64,
|
||||
"origin": self.ORIGIN,
|
||||
"crossOrigin": False,
|
||||
}).encode()
|
||||
|
||||
def make_registration(self, options: dict) -> dict:
|
||||
challenge = options["challenge"]
|
||||
auth_data = bytearray(self._rp_id_hash())
|
||||
auth_data += bytes([0x41]) # UP + AT
|
||||
auth_data += struct.pack(">I", 0)
|
||||
aaguid = b"\x00" * 16
|
||||
auth_data += aaguid
|
||||
auth_data += struct.pack(">H", len(self.credential_id))
|
||||
auth_data += self.credential_id
|
||||
auth_data += self._cose_key()
|
||||
|
||||
attestation_object = cbor2.dumps(
|
||||
{"fmt": "none", "attStmt": {}, "authData": bytes(auth_data)},
|
||||
canonical=True,
|
||||
)
|
||||
client_data = self._client_data("webauthn.create", challenge)
|
||||
return {
|
||||
"id": _b64url(self.credential_id),
|
||||
"rawId": _b64url(self.credential_id),
|
||||
"type": "public-key",
|
||||
"response": {
|
||||
"clientDataJSON": _b64url(client_data),
|
||||
"attestationObject": _b64url(attestation_object),
|
||||
},
|
||||
}
|
||||
|
||||
def make_assertion(self, options: dict) -> dict:
|
||||
challenge = options["challenge"]
|
||||
auth_data = bytearray(self._rp_id_hash())
|
||||
auth_data += bytes([0x01]) # UP
|
||||
self.sign_count += 1
|
||||
auth_data += struct.pack(">I", self.sign_count)
|
||||
|
||||
client_data = self._client_data("webauthn.get", challenge)
|
||||
signed = bytes(auth_data) + hashlib.sha256(client_data).digest()
|
||||
# WebAuthn spec: ECDSA signatures are ASN.1 DER (not raw r||s like U2F)
|
||||
der_sig = self.key.sign(signed, ec.ECDSA(hashes.SHA256()))
|
||||
|
||||
return {
|
||||
"id": _b64url(self.credential_id),
|
||||
"rawId": _b64url(self.credential_id),
|
||||
"type": "public-key",
|
||||
"response": {
|
||||
"clientDataJSON": _b64url(client_data),
|
||||
"authenticatorData": _b64url(bytes(auth_data)),
|
||||
"signature": _b64url(der_sig),
|
||||
"userHandle": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
from cryptography.hazmat.primitives import hashes # noqa: E402 (used above)
|
||||
|
||||
|
||||
# ── Unit tests: webauthn_mfa module ──────────────────────────────────
|
||||
|
||||
class TestWebauthnModule:
|
||||
def test_rp_config_defaults(self, monkeypatch):
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
|
||||
assert w.rp_id() == "localhost"
|
||||
monkeypatch.setenv("OBSIGATE_WEBAUTHN_RP_ID", "obs.example.com")
|
||||
assert w.rp_id() == "obs.example.com"
|
||||
|
||||
def test_challenge_is_single_use(self):
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
w._pending.clear()
|
||||
ch = w._store_challenge("u1:register")
|
||||
assert isinstance(ch, bytes) and len(ch) == 32
|
||||
assert w._take_challenge("u1:register") == ch
|
||||
assert w._take_challenge("u1:register") is None # popped
|
||||
|
||||
def test_take_challenge_expired(self):
|
||||
import time as _t
|
||||
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
w._pending.clear()
|
||||
w._store_challenge("u2:register")
|
||||
key = "u2:register"
|
||||
ch, _ = w._pending[key]
|
||||
w._pending[key] = (ch, _t.time() - 1)
|
||||
assert w._take_challenge(key) is None
|
||||
|
||||
def test_full_registration_and_authentication_roundtrip(self):
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
options_to_json,
|
||||
)
|
||||
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
w._pending.clear()
|
||||
auth = VirtualAuthenticator()
|
||||
|
||||
reg_opts = generate_registration_options(
|
||||
rp_id="localhost", rp_name="ObsiGate",
|
||||
user_name="alice", user_id=b"1", user_display_name="Alice",
|
||||
challenge=w._store_challenge("alice:register"),
|
||||
)
|
||||
cred = auth.make_registration(json.loads(options_to_json(reg_opts)))
|
||||
verified = w.complete_registration("alice", cred)
|
||||
assert verified["credential_id"] == cred["id"]
|
||||
assert verified["public_key"]
|
||||
|
||||
auth_opts = generate_authentication_options(
|
||||
rp_id="localhost",
|
||||
challenge=w._store_challenge("alice:login"),
|
||||
)
|
||||
assertion = auth.make_assertion(json.loads(options_to_json(auth_opts)))
|
||||
new_count = w.complete_authentication(
|
||||
"alice", assertion,
|
||||
{"public_key": verified["public_key"], "sign_count": 0})
|
||||
assert new_count == 1
|
||||
|
||||
|
||||
# ── Integration: API endpoints ───────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def wa_client(monkeypatch):
|
||||
"""Auth-enabled client with a user, WebAuthn RP configured for localhost."""
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
data_dir = tmp / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
from backend.auth.password import hash_password
|
||||
|
||||
users = {"version": 1, "users": {"testuser": {
|
||||
"id": "t-1", "username": "testuser", "display_name": "Test",
|
||||
"password_hash": hash_password("TestPass123!"), "role": "admin",
|
||||
"vaults": ["*"], "active": True,
|
||||
}}}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr("backend.auth.user_store.USERS_FILE", data_dir / "users.json")
|
||||
monkeypatch.setenv("OBSIGATE_WEBAUTHN_RP_ID", "localhost")
|
||||
monkeypatch.setenv("OBSIGATE_WEBAUTHN_ORIGINS", "http://localhost")
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = os.path.abspath("test-vault")
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(backend.main.app)
|
||||
yield client
|
||||
client.close()
|
||||
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
def _login_headers(client):
|
||||
r = client.post("/api/auth/login",
|
||||
json={"username": "testuser", "password": "TestPass123!"})
|
||||
token = r.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
class TestWebauthnApi:
|
||||
def test_register_requires_auth(self, wa_client):
|
||||
r = wa_client.post("/api/auth/mfa/webauthn/register/options")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_registration_flow_enables_mfa(self, wa_client):
|
||||
headers = _login_headers(wa_client)
|
||||
r = wa_client.post("/api/auth/mfa/webauthn/register/options", headers=headers)
|
||||
assert r.status_code == 200
|
||||
options = r.json()["options"]
|
||||
|
||||
auth = VirtualAuthenticator()
|
||||
cred = auth.make_registration(options)
|
||||
r2 = wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
|
||||
json={"credential": cred, "label": "YubiKey 5"})
|
||||
assert r2.status_code == 200, r2.text
|
||||
body = r2.json()
|
||||
assert body["mfa_enabled"] is True
|
||||
assert len(body["recovery_codes"]) == 8
|
||||
assert body["credentials"][0]["label"] == "YubiKey 5"
|
||||
|
||||
# status reflects webauthn
|
||||
r3 = wa_client.get("/api/auth/mfa/status", headers=headers)
|
||||
st = r3.json()
|
||||
assert st["mfa_enabled"] is True
|
||||
assert st["webauthn_credentials"] == 1
|
||||
assert st["totp_enabled"] is False
|
||||
|
||||
def test_login_with_webauthn_assertion(self, wa_client):
|
||||
headers = _login_headers(wa_client)
|
||||
options = wa_client.post("/api/auth/mfa/webauthn/register/options",
|
||||
headers=headers).json()["options"]
|
||||
auth = VirtualAuthenticator()
|
||||
cred = auth.make_registration(options)
|
||||
wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
|
||||
json={"credential": cred, "label": "Key"})
|
||||
|
||||
# Fresh login → MFA required via webauthn
|
||||
r = wa_client.post("/api/auth/login",
|
||||
json={"username": "testuser", "password": "TestPass123!"})
|
||||
body = r.json()
|
||||
assert body["mfa_required"] is True
|
||||
assert body["mfa_method"] == "webauthn"
|
||||
|
||||
opts_r = wa_client.post("/api/auth/mfa/webauthn/options",
|
||||
json={"username": "testuser"})
|
||||
assert opts_r.status_code == 200
|
||||
assertion = auth.make_assertion(opts_r.json()["options"])
|
||||
v = wa_client.post("/api/auth/mfa/webauthn/verify",
|
||||
json={"username": "testuser", "credential": assertion})
|
||||
assert v.status_code == 200, v.text
|
||||
assert "access_token" in v.json()
|
||||
|
||||
def test_login_with_wrong_credential_rejected(self, wa_client):
|
||||
headers = _login_headers(wa_client)
|
||||
options = wa_client.post("/api/auth/mfa/webauthn/register/options",
|
||||
headers=headers).json()["options"]
|
||||
auth = VirtualAuthenticator()
|
||||
cred = auth.make_registration(options)
|
||||
wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
|
||||
json={"credential": cred, "label": "Key"})
|
||||
|
||||
opts_r = wa_client.post("/api/auth/mfa/webauthn/options",
|
||||
json={"username": "testuser"})
|
||||
# Impostor key signs the challenge
|
||||
impostor = VirtualAuthenticator()
|
||||
bad = impostor.make_assertion(opts_r.json()["options"])
|
||||
v = wa_client.post("/api/auth/mfa/webauthn/verify",
|
||||
json={"username": "testuser", "credential": bad})
|
||||
assert v.status_code == 401
|
||||
|
||||
def test_challenge_single_use(self, wa_client):
|
||||
headers = _login_headers(wa_client)
|
||||
options = wa_client.post("/api/auth/mfa/webauthn/register/options",
|
||||
headers=headers).json()["options"]
|
||||
auth = VirtualAuthenticator()
|
||||
cred = auth.make_registration(options)
|
||||
wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
|
||||
json={"credential": cred, "label": "K"})
|
||||
|
||||
opts_r = wa_client.post("/api/auth/mfa/webauthn/options",
|
||||
json={"username": "testuser"})
|
||||
assertion = auth.make_assertion(opts_r.json()["options"])
|
||||
v1 = wa_client.post("/api/auth/mfa/webauthn/verify",
|
||||
json={"username": "testuser", "credential": assertion})
|
||||
assert v1.status_code == 200
|
||||
# replay the same credential → challenge already consumed
|
||||
v2 = wa_client.post("/api/auth/mfa/webauthn/verify",
|
||||
json={"username": "testuser", "credential": assertion})
|
||||
assert v2.status_code == 401
|
||||
|
||||
def test_login_options_enumeration_safe(self, wa_client):
|
||||
# Unknown user / no MFA -> always 200 with totp fallback, no 404/400 leak
|
||||
r = wa_client.post("/api/auth/mfa/webauthn/options",
|
||||
json={"username": "ghost-user"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"mfa_method": "totp", "options": None}
|
||||
|
||||
def test_remove_key_disables_mfa(self, wa_client):
|
||||
headers = _login_headers(wa_client)
|
||||
options = wa_client.post("/api/auth/mfa/webauthn/register/options",
|
||||
headers=headers).json()["options"]
|
||||
auth = VirtualAuthenticator()
|
||||
cred = auth.make_registration(options)
|
||||
wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
|
||||
json={"credential": cred, "label": "K"})
|
||||
cred_id = cred["id"]
|
||||
|
||||
r = wa_client.post("/api/auth/mfa/webauthn/credentials/remove",
|
||||
headers=headers,
|
||||
json={"credential_id": cred_id, "password": "wrong"})
|
||||
assert r.status_code == 400
|
||||
|
||||
r2 = wa_client.post("/api/auth/mfa/webauthn/credentials/remove",
|
||||
headers=headers,
|
||||
json={"credential_id": cred_id, "password": "TestPass123!"})
|
||||
assert r2.status_code == 200
|
||||
st = wa_client.get("/api/auth/mfa/status", headers=headers).json()
|
||||
assert st["mfa_enabled"] is False
|
||||
Reference in New Issue
Block a user