Compare commits

...
32 Commits
Author SHA1 Message Date
bruno 943005328c feat: barre d'outils de lecture epinglee, coloration syntaxique des fichiers de code et avatars predefinis (#115, #117, BUG-078)
CI / lint (push) Successful in 2m5s
CI / security (push) Successful in 1m27s
CI / test (push) Successful in 4m10s
CI / build (push) Successful in 1m23s
CI / e2e (push) Successful in 13m55s
2026-09-24 16:21:45 -04:00
bruno e1842043d8 feat: assistant IA — approbation groupee des actions, bloc d'etapes, refresh UI et bouton Stop (BUG-074, BUG-075, BUG-076, BUG-077)
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m25s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m26s
CI / e2e (push) Successful in 13m38s
2026-09-24 10:05:32 -04:00
bruno 9fb094f505 feat: refonte mobile de la section Configurations #114
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m26s
CI / test (push) Successful in 4m50s
CI / build (push) Successful in 1m22s
CI / e2e (push) Successful in 15m34s
2026-09-24 08:43:18 -04:00
bruno d142049216 fix: degagement mobile sous la barre de navigation du bas (clearance retargetee .main-body) BUG-073
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m26s
CI / test (push) Successful in 3m41s
CI / build (push) Successful in 1m21s
CI / e2e (push) Successful in 14m0s
2026-09-23 23:36:35 -04:00
bruno 33fe1a3439 feat: ordre naturel des sections Configurations et avatar utilisateur #113
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m28s
CI / test (push) Successful in 4m14s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 13m43s
2026-09-23 22:18:29 -04:00
bruno a726ad8511 feat: en-tete allege, compte utilisateur en sidebar et pellicule d'images defilable (molette + fleches) #112
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m29s
CI / test (push) Successful in 4m59s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 13m49s
2026-09-23 21:06:41 -04:00
bruno b926f01b85 feat: visionneuse d'images - navigation fluide, image ajustee au cadre, fleches laterales et pellicule persistante #111
CI / lint (push) Successful in 2m2s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 4m23s
CI / build (push) Successful in 1m19s
CI / e2e (push) Successful in 13m27s
2026-09-23 14:37:41 -04:00
bruno 8264e7ffae fix: conserver plein ecran et panneau metadonnees dans la visionneuse d'images (sidebar droite) + lanceur E2E Windows BUG-072
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m19s
CI / e2e (push) Successful in 17m45s
2026-09-23 13:43:47 -04:00
bruno 80852374a8 fix: positionnement lecteur media (mobile/desktop) et drag libre de la mini-video #110
CI / lint (push) Successful in 2m0s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 13m7s
2026-09-23 11:42:23 -04:00
bruno e3c6789776 feat: lecteur média persistant Now Playing (dock audio, mini-vidéo PiP, Media Session, mobile) #110
CI / lint (push) Successful in 2m5s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 3m57s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 13m18s
2026-09-23 10:41:15 -04:00
bruno e2417cb5ab feat: support audio & vidéo — lecteurs HTML5 intégrés #109
CI / lint (push) Successful in 1m58s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Successful in 2m6s
CI / e2e (push) Successful in 12m57s
2026-09-23 09:14:34 -04:00
bruno eccbf7474e feat: support complet des images — arborescence, visionneuse, indexation #108
CI / lint (push) Successful in 1m57s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Failing after 1m16s
CI / e2e (push) Skipped
2026-09-23 07:47:04 -04:00
bruno 69cee4d93a docs(roadmap): add #108 image support and #109 audio/video players
CI / lint (push) Successful in 1m55s
CI / security (push) Successful in 1m25s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 1m15s
CI / e2e (push) Successful in 11m59s
- #108: images parity — tree + indexing (never read bytes into TF-IDF),
  fix broken standalone viewer (img src points to JSON /raw instead of
  /api/image), zoom/pan viewer, thumbnails, SVG sandbox hardening
- #109: audio/video — HTML5 players, shared media streaming endpoint
  with Range/206 (extract helper from existing pdf/stream), codec
  fallback UI, PWA/mobile notes
- Update effort summary (8 items, ~28-43 days)
2026-09-22 23:28:55 -04:00
bruno 705f755b6b docs: guides d'utilisation, capture reelle et README ameliores
CI / lint (push) Successful in 2m2s
CI / security (push) Successful in 1m25s
CI / test (push) Successful in 4m13s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 11m56s
2026-09-22 22:40:51 -04:00
bruno 8ad8eaac71 test: spec E2E mobile pour la page Configurations BUG-071
CI / lint (push) Successful in 1m54s
CI / security (push) Successful in 1m21s
CI / test (push) Successful in 3m58s
CI / build (push) Failing after 1m22s
CI / e2e (push) Skipped
2026-09-22 22:03:42 -04:00
bruno dd9224e685 fix: page Configurations inutilisable en mode mobile BUG-071
CI / lint (push) Successful in 1m57s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 4m20s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 11m51s
2026-09-22 21:24:56 -04:00
bruno aeb7516445 fix: activation WebAuthn impossible BUG-070 (rp_id/origines derives requete, challenges multiples)
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m7s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 12m12s
2026-09-22 20:54:01 -04:00
bruno bca0fdd941 fix: login 2FA bloque sans erreur BUG-069 (challenge montait dans .login-box inexistant -> .login-card + erreur visible)
CI / lint (push) Successful in 1m56s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 12m35s
2026-09-22 20:38:37 -04:00
bruno 60da957f13 fix: section Securite du compte incomplete BUG-068 (boutons theme, QR local, mot de passe, recovery WebAuthn)
CI / lint (push) Successful in 2m25s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 3m43s
CI / build (push) Successful in 2m9s
CI / e2e (push) Successful in 12m7s
2026-09-22 20:07:25 -04:00
bruno f621620593 feat: optimisation globale des performances #86 (scan differentiel, excalidraw differe, garde-fou replace; inverted index/PDF lazy/caps regex deja livres via BUG-033/040/025)
CI / lint (push) Successful in 1m54s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m23s
CI / build (push) Successful in 1m17s
CI / e2e (push) Successful in 12m9s
2026-09-22 19:04:26 -04:00
bruno eff74cabe0 feat: #107 configuration - gestion des clés API & MCP (création/révocation, expiration 1j/1mois/6mois/1an/sans fin, une clé pour API REST + serveur MCP, dernière utilisation, store sans secret persisté; fix révocation longue durée) + script token MCP
CI / lint (push) Successful in 1m58s
CI / security (push) Successful in 1m32s
CI / test (push) Successful in 4m0s
CI / build (push) Successful in 1m15s
CI / e2e (push) Successful in 12m9s
2026-09-22 14:48:51 -04:00
bruno 6f0a6f7fd8 chore(fixtures): enrichit les vaults de test (frontmatter complet, types fichiers pour vues/aper/us, dossiers avec accents+espaces, scripts dupliques, images/logs) + purge residus e2e-diagram
CI / lint (push) Successful in 1m55s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 3m43s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 11m44s
2026-09-22 13:42:49 -04:00
bruno ab7c227b97 feat: assistant IA - actions instantanées contextuelles, catalogue Toutes les actions & frontmatter complet #106
CI / lint (push) Successful in 1m43s
CI / security (push) Successful in 1m8s
CI / test (push) Successful in 4m4s
CI / build (push) Successful in 2m10s
CI / e2e (push) Successful in 11m51s
2026-09-19 11:07:50 -04:00
bruno b8054665bc fix: guide - bouton telechargement icone seule, diagramme Architecture rendu en image dans le PDF, emoji couleur (Noto) (#105)
CI / lint (push) Successful in 1m39s
CI / security (push) Successful in 1m5s
CI / test (push) Successful in 3m25s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 11m43s
2026-09-18 15:01:14 -04:00
bruno 99a5b735c8 chore: relance CI run 1544 (echec checkout transitoire, 524 Cloudflare)
CI / lint (push) Successful in 1m41s
CI / security (push) Successful in 1m5s
CI / test (push) Successful in 3m27s
CI / build (push) Successful in 1m1s
CI / e2e (push) Successful in 12m6s
2026-09-18 13:49:16 -04:00
bruno fb2d83e9e3 feat: guide d'utilisation - couverture complete, telechargement MD/PDF, section Architecture Mermaid, guide desktop elargi (#105, BUG-067)
CI / lint (push) Successful in 1m40s
CI / security (push) Successful in 1m6s
CI / test (push) Failing after 1m52s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-18 13:06:30 -04:00
bruno 82f6b4a791 fix: icones manquantes dans la table des matieres de la configuration (BUG-066)
CI / lint (push) Successful in 1m40s
CI / security (push) Successful in 1m4s
CI / test (push) Successful in 3m2s
CI / build (push) Successful in 1m1s
CI / e2e (push) Successful in 11m53s
2026-09-18 10:14:41 -04:00
bruno 7e1f5d6852 fix: fixture E2E manquante diagram-app-export.excalidraw (test de regression BUG-064)
CI / lint (push) Successful in 1m38s
CI / security (push) Successful in 1m4s
CI / test (push) Successful in 3m26s
CI / build (push) Successful in 1m5s
CI / e2e (push) Successful in 11m24s
2026-09-18 09:23:22 -04:00
bruno ab766862a3 feat: redesign UI section Cles API IA - recherche, carte defaut, cartes depliables, footer sticky (#104)
CI / lint (push) Successful in 1m47s
CI / security (push) Successful in 1m5s
CI / test (push) Successful in 3m14s
CI / build (push) Successful in 1m1s
CI / e2e (push) Failing after 12m11s
2026-09-18 09:01:20 -04:00
bruno 2bd9dd7535 fix: Editeur Excalidraw - diagramme vide (CSS + appState) et auto-save pendant l'edition (BUG-064, BUG-065) 2026-09-18 08:59:54 -04:00
bruno 7f0f64a42e fix: TOC PDF - forcer le rechargement de l'iframe (BUG-063 complement)
CI / lint (push) Successful in 1m37s
CI / security (push) Successful in 1m15s
CI / test (push) Successful in 3m36s
CI / build (push) Successful in 59s
CI / e2e (push) Successful in 11m13s
2026-09-17 21:16:46 -04:00
bruno f7e068baed fix: viewer PDF (TOC, largeur) et plein ecran assistant (BUG-061 a BUG-063)
CI / lint (push) Successful in 1m36s
CI / security (push) Successful in 1m11s
CI / test (push) Successful in 3m33s
CI / build (push) Successful in 1m2s
CI / e2e (push) Successful in 11m23s
2026-09-17 20:45:38 -04:00
176 changed files with 19673 additions and 3343 deletions
+5 -2
View File
@@ -16,7 +16,7 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# OBSIGATE_SECURE_COOKIES=false
# Tokens TTL en secondes
# OBSIGATE_ACCESS_TOKEN_TTL=900
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
# OBSIGATE_REFRESH_TOKEN_TTL=604800
# Rate limiting
@@ -51,7 +51,10 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# 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
# WebAuthn / MFA (ROADMAP #64) — par défaut rp_id/origines sont dérivés de la
# requête (hôte exact, port inclus) : rien à configurer en accès direct.
# À renseigner uniquement pour un accès via reverse-proxy sous un autre nom
# (avec OBSIGATE_TRUST_PROXY=true pour X-Forwarded-Host/Proto) :
# OBSIGATE_WEBAUTHN_RP_ID=obsigate.example.com
# OBSIGATE_WEBAUTHN_RP_NAME=ObsiGate
# OBSIGATE_WEBAUTHN_ORIGINS=https://obsigate.example.com
+6
View File
@@ -38,8 +38,12 @@ jobs:
- name: Frontend unit tests
run: |
node tests/frontend/unit.test.mjs
node tests/frontend/image-viewer.test.mjs
node tests/frontend/pdf-viewer.test.mjs
node tests/frontend/forge-completion.test.mjs
node tests/frontend/config-mobile.test.mjs
node tests/frontend/settings-order-avatar.test.mjs
node tests/frontend/mobile-toolbar.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
run: |
@@ -58,6 +62,7 @@ jobs:
node desktop.test.mjs
node toolbar-order.test.mjs
node editor-inline.test.mjs
node ai-quick-actions.test.mjs
else
echo "tests/frontend/node_modules missing - installing jsdom"
npm install --no-audit --no-fund --silent
@@ -74,6 +79,7 @@ jobs:
node desktop.test.mjs
node toolbar-order.test.mjs
node editor-inline.test.mjs
node ai-quick-actions.test.mjs
fi
# ── Tests ─────────────────────────────────────────────────────────
+78 -21
View File
@@ -1,47 +1,101 @@
# AGENTS.md — Instructions obligatoires du dépôt ObsiGate
> Ces instructions s'appliquent à **toute** intervention (humaine ou IA) sur ce dépôt.
> Documentation et réponses en **français**.
## Règle n°1 — Méthode de livraison unique
Avant toute tâche (fonctionnalité, bug, refactor), **lire et appliquer**
[`docs/DELIVERY_WORKFLOW.md`](./docs/DELIVERY_WORKFLOW.md) (Definition of Done).
Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI vert**.
Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI vert**
(jobs `lint`, `test`, `security`, `build`, `e2e` de `.gitea/workflows/ci.yml`).
## Avant de commencer
1. Lire [`docs/ROADMAP.md`](./docs/ROADMAP.md) (travail à venir + index) et
[`docs/ISSUES_TODOLIST.md`](./docs/ISSUES_TODOLIST.md) (bugs).
2. Identifier ou créer l'**ID stable** (`#NN` pour une feature, `BUG-NNN` pour un bug)
et passer son statut à « en cours » **avant** de coder.
2. Identifier ou créer l'**ID stable** (`#NN` pour une feature, `BUG-NNN` pour un bug —
jamais réutilisé) et passer son statut à « en cours » **avant** de coder.
## Architecture (ce qui n'est pas obvious)
- **Backend** : FastAPI/Python 3.11, point d'entrée `backend/main.py` (endpoints + rendu
markdown), index en mémoire (`indexer.py`, `search.py`), watcher (`watcher.py`),
auth dans `backend/auth/`. Pas de base de données : JSON dans `data/`.
- **Frontend** : vanilla JS **zéro framework, zéro build npm** (`frontend/app.js`,
`index.html`, `style.css`). Ne pas ajouter de dépendances npm ni d'étape de build.
- **Desktop** : Tauri (Rust) dans `desktop/` ; `tauri.conf.json` embarque `backend/**` et
`frontend/**` depuis `desktop/` — les scripts de build font le **staging** (copie) avant
`cargo tauri build`, sinon le build échoue.
- **i18n** : tout texte d'interface doit exister en FR **et** EN
(`frontend/locales/fr.json` + `en.json`).
## Vérifications locales (pwsh, à faire passer avant tout commit/push)
```powershell
# Backend (venv à la racine)
.\.venv\Scripts\python.exe -m pytest tests/
.\.venv\Scripts\python.exe -m ruff check backend/
.\.venv\Scripts\python.exe -m mypy backend/ --ignore-missing-imports
# Frontend : scripts Node à exécuter directement (pas de runner)
node tests/frontend/validate-imports.mjs
node tests/frontend/unit.test.mjs
# Tests JSDOM : node_modules dans tests/frontend/ (npm install là-bas si absent), ex :
node tests/frontend/pane-manager.test.mjs
# E2E (si UI touchée, ~10 min) : reproduit le job CI e2e (port 2029, auth désactivée)
npm run test:e2e # prérequis : uv, Node >= 20, npx playwright install chromium
bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
# Windows sans bash exploitable (WSL HS, git-bash bloqué par App Control) :
npm run test:e2e:ps # équivalent PowerShell, mêmes conditions que le CI
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','nom du test')
```
- Un seul test backend : `.\.venv\Scripts\python.exe -m pytest tests/test_search.py -q`.
- **Sélection E2E** : vérifier chaque sélecteur dans le DOM réel avant de l'utiliser dans un
test ; tout test nouveau/modifié doit passer en local avant push ; pas de contournement
qui masque la flakiness (`waitForTimeout` arbitraires, fallbacks silencieux).
- La suite E2E doit finir à **100 %** sans s'appuyer sur les retries. Jamais de `git push`
avant que les 5 étapes locales soient vertes.
## Version & hooks (pièges)
- `VERSION` (racine) = **source unique de vérité** (SemVer), incrémenté **automatiquement à
chaque commit** par le hook `.githooks/prepare-commit-msg` — `feat` → mineur,
`!:` / `BREAKING CHANGE` → majeur, sinon correctif. Le même commit resynchronise
`package.json`, le desktop Tauri, `README.md`/`README.fr.md`, `docs/ROADMAP.md` et publie
la section `[Unreleased]` du `CHANGELOG.md` en `[X.Y.Z] — date` ; tag `vX.Y.Z` créé au
commit, publié au push (`push.followTags`).
- Hooks **obligatoires**, à installer une fois par clone : `scripts/install-hooks.sh`
(sinon la version ne suit plus et le CI échoue via le garde-fou `tests/test_version.py`).
- Le rattachement des fichiers de bump se fait par un `--amend` immédiat : **le SHA affiché
par `git commit` change** — ne pas s'y fier.
- Commit sans incrément (exceptionnel) : `SKIP_VERSION_BUMP=1 git commit …`.
- Ne jamais réécrire une version déjà publiée dans le CHANGELOG ; jamais de détail dupliqué
entre Roadmap et CHANGELOG.
## À la fin de chaque tâche (obligatoire)
- Ajouter/mettre à jour les **tests unitaires**.
- Vérifications locales vertes : `pytest`, `ruff`, `mypy`, tests frontend (`E2E` si UI).
- Mettre à jour la documentation requise : `CHANGELOG.md` (`[Unreleased]`), `docs/ROADMAP.md`
(statut + index), fiche `docs/features/` **ou** `docs/archive/`, `docs/ISSUES_TODOLIST.md`,
guide utilisateur i18n FR/EN + README si impact utilisateur.
- **Commit** conventionnel référençant l'ID, puis **push**.
- Version : le fichier VERSION (racine du dépôt) est la **source unique de
vérité (MAJEUR.MINEUR.CORRECTIF), incrémenté automatiquement à chaque commit** par le hook
.githooks/prepare-commit-msg — feat → mineur, !: / BREAKING CHANGE → majeur, sinon
correctif. Le même commit resynchronise package.json, le desktop Tauri, README.md/
README.fr.md, docs/ROADMAP.md et publie la section [Unreleased] du CHANGELOG.md en
[X.Y.Z] — date ; le tag vX.Y.Z est créé au commit et publié au push (push.followTags).
Hooks à installer une fois par clone : scripts/install-hooks.sh. Garde-fou :
tests/test_version.py (détail : docs/DELIVERY_WORKFLOW.md §7).
- Vérifier le **CI Gitea vert** (jobs `lint`, `test`, `security`, `build`, `e2e`).
- Tests unitaires ajoutés/mis à jour (correctif sans test de non-régression = pas terminé).
- Toutes les vérifications locales ci-dessus vertes (`E2E` si UI).
- Documentation mise à jour : `CHANGELOG.md` (`[Unreleased]`), `docs/ROADMAP.md` (statut +
index), fiche `docs/features/` **ou** `docs/archive/`, `docs/ISSUES_TODOLIST.md` (si bug),
guide utilisateur i18n FR/EN + README si impact utilisateur, docstrings +
`response_model` si API.
- **Commit** conventionnel référençant l'ID (`feat: … #12`), puis **push** et **CI vert**.
## Cartographie documentaire
| Sujet | Fichier |
|---|---|
| Méthode de livraison / DoD | `docs/DELIVERY_WORKFLOW.md` |
| Version livrée (source unique) | VERSION + scripts/bump_version.py |
| Version livrée (source unique) | `VERSION` + `scripts/bump_version.py` |
| Travail à venir + index | `docs/ROADMAP.md` |
| Historique des versions | `CHANGELOG.md` |
| Conception par feature | `docs/features/<slug>.md` |
| Guides d'utilisation | `docs/GUIDES/` |
| Archive du complété | `docs/archive/COMPLETED_v1-v2.md` |
| Bugs / TODO | `docs/ISSUES_TODOLIST.md` |
| Build & releases | `docs/DEVELOPMENT_AND_RELEASES.md` |
@@ -50,5 +104,8 @@ Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI v
## Conventions
- Commits : `type: description` — `feat`, `fix`, `perf`, `refactor`, `docs`, `style`, `chore`, `test`.
- **Ne jamais** committer de secrets, clés ou tokens.
- Réponses et documentation en **français** ; respecter le style du code existant.
- Sécurité : tout chemin fichier fourni par l'utilisateur passe par `_resolve_safe_path()`.
- **Ne jamais** committer de secrets, clés ou tokens (`.env` jamais committé ; secrets dans
`data/api_keys.json` ou variables `OBSIGATE_*`).
- Respecter le style du code existant (ruff/mypy 0 erreur ; CSS variables, pas de couleurs
hardcodées ; `safeCreateIcons()` plutôt que `lucide.createIcons()` direct).
+680 -1
View File
@@ -6,7 +6,7 @@ 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 à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.11.3**.
> [Unreleased](#unreleased). La dernière version livrée est **2.25.0**.
---
@@ -14,6 +14,685 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.25.0] — 2026-09-24
### Ajouté
- **#115 — barre d'outils de lecture toujours visible.** La barre d'actions d'un
document (pop-out, bookmark, Editer, Source, Copier, Partager…) est désormais
**épinglée en haut** de la zone de lecture : elle reste accessible en permanence,
même au bas d'un document long, au lieu de disparaître au défilement. Elle est
rendue comme un enfant direct du conteneur de défilement (`.file-toolbar`), masquée
en mode lecture, et alignée dans la fenêtre pop-out.
- **#117 — avatars prédéfinis dans le profil.** La section **Profil** propose une
galerie de **12 avatars** fournis avec l'application (`frontend/icons/avatar/`) :
un clic charge l'image, la recadre au format carré 256 px (même pipeline que
l'import) et l'enregistre sur le compte. L'avatar actif est surligné ; l'import
d'une photo personnalisée et la suppression restent disponibles.
### Corrigé
- **BUG-078 — coloration syntaxique des fichiers de code perdue.** Les feuilles de
style highlight.js étaient basculées à partir de la **clé de thème**
(`defaut-obsigate`, …) au lieu du **mode** (`dark`/`light`) : les deux feuilles se
retrouvaient désactivées et les blocs de code (`.py`, `.sh`, `.ps1`, `.yml`, JSON,
blocs Markdown…) s'affichaient en texte brut. Le basculement est désormais piloté
par le mode, de façon déterministe, dans le moteur de thème (`themes.js`) comme au
premier rendu (`ui.js`) ; sépia et contraste élevé réutilisent la palette claire.
---
## [2.24.0] — 2026-09-24
### Ajouté
- **BUG-077 — assistant IA : bouton « Stop ».** Pendant qu'une réponse se diffuse, le
bouton d'envoi du composeur devient un bouton **Stop** (icône carrée, couleur
d'alerte) : un clic interrompt immédiatement l'exécution de l'agent (abort de la
requête SSE, annulation de la tâche côté serveur à la déconnexion) et la réponse
partielle est conservée avec un marqueur « ⏹ Exécution arrêtée. ». Le bouton reste
actif (il n'est plus désactivé) tant que l'agent travaille, y compris pendant la
reprise d'une confirmation.
### Modifié
- **BUG-075 — assistant IA : approbation globale des actions.** Une même réponse du
modèle peut demander **plusieurs** mutations (créer un dossier et les fichiers
qu'il contient…). Elles sont désormais **regroupées en une seule confirmation** (au
lieu d'une action après l'autre) : la carte liste chaque action avec son libellé et
son aperçu de diff, et un unique bouton « **Tout approuver (N)** » envoie
`confirm_all` — les actions en attente sont appliquées d'un bloc, puis la suite de
l'exécution est autorisée sans nouvelle carte. Les appels en lecture du même lot
s'exécutent immédiatement (conversation valide). Côté backend, `pending.actions`
remplace le report `deferred` des mutations d'un lot (`backend/agent/loop.py`) et
`confirm_all` arme `ToolContext.confirmed` pour le reste du run
(`backend/bookslm_routes.py`).
### Corrigé
- **BUG-074 — assistant IA : bloc d'étapes sans titre et compteur figé à 1.** Le
résumé replié affiche désormais un **titre** (le libellé de la première action,
« N étapes — Fichier créé : notes/a.md ▶ »), le compteur ne compte plus les
**réflexions** (seules les actions) et la reprise d'une confirmation continue de
diffuser dans **le même message** au lieu de créer un nouveau message « 1 étape »
par approbation : le bloc d'étapes s'accumule sur tout l'échange.
- **BUG-076 — assistant IA : arborescence et document ouvert non rafraîchis.** Après
une action mutatrice de l'agent (création/suppression/renommage de fichier ou
dossier, écriture), l'arborescence est rafraîchie immédiatement (rafraîchissement
débouncé sur les événements `tool` mutateurs, sans attendre le watcher) et le
document affiché est rechargé depuis le disque ; les outils de création de documents
(xlsx/docx/csv/pdf) notifient désormais aussi la visionneuse.
---
## [2.23.0] — 2026-09-24
### Ajouté
- **#114 — Configuration, refonte mobile-responsive.** La page « Configurations »
est désormais pensée pour le tactile (≤ 768 px) : modale **plein écran**
(`100dvh`, safe-area), sommaire en **drawer coulissant** gauche
(`position: fixed`, largeur `min(320px, 88vw)`) avec fond assombri
(`#config-modal.config-toc-open::before`) et bouton de fermeture
`#config-toc-close` (tap sur le backdrop ou Échap ferme le drawer d'abord,
puis la modale) ; formulaires sur **une colonne** ; tous les boutons
(save/secondary/danger/add/sm/hamburger/fermer) et liens du sommaire en cibles
**≥ 44 px** ; champs et selects en **16 px + min-height 44 px** (anti-zoom
iOS) ; rangée « Sauvegarder / Réindexer / Réinitialiser » **sticky** en bas de
sa section avec safe-area ; MFA (codes de secours en 1 colonne, champ de code
full-width et wrap des rangées d'action) ; débordements webhook/token/share
corrigés. Dettes HTML/i18n de l'audit incluses : `.config-actions-row` replacée
dans `#cfg-backend-settings` (+ `</section>` orphelin supprimé), id dupliqué
`cfg-partages-publics` retiré du `<h2>`, conteneur mort
`#plugins-settings-container` supprimé, libellé `#mt-explorer` i18n
(`settings.explorer`), doublons `.config-btn-add` et règle morte
`.mfa-recovery-input` purgés ; i18n : clés mortes `settings.backend`,
`settings.backend_hint`, `settings.restart_badge`, `settings.save`,
`settings.plugins` supprimées, `config.toc_close` ajoutée, `settings.tabs` FR
corrigé (« Onglets »). Tests : `config-mobile.test.mjs` étendu (27, au CI) +
E2E `config-mobile.spec.js` (5, projet `chromium-mobile`). Fiche :
[docs/features/settings-mobile-114.md](./docs/features/settings-mobile-114.md).
---
## [2.22.1] — 2026-09-23
### Corrigé
- **BUG-073 — mobile, fin de contenu masquée.** La barre de navigation fixe du bas
(`#mobile-toolbar`, 64 px + safe-area) recouvrait le bas de tous les documents et
pages en vue mobile (≤ 768 px) : les dernières lignes restaient définitivement sous
la barre. Cause : la règle de clearance du bloc mobile ciblait `.main-layout`,
classe absente de `index.html` (le vrai conteneur est `.main-body`) — sélecteur
mort, aucun dégagement réservé. Correctif : cible `.main-body`
(`padding-bottom: calc(64px + env(safe-area-inset-bottom, 0))`), suppression de la
règle sœur morte `.editor-modal.active ~ .main-layout`, reset du dégagement en mode
lecture (barre masquée) et `body.np-active .content-area` ramené à `76 px` (le
dégagement dock, sans double comptage avec `.main-body`). Tests :
`tests/frontend/mobile-toolbar.test.mjs` (7, au CI) et E2E
`tests/e2e/mobile-toolbar.spec.js` (2, projet `chromium-mobile`).
---
## [2.22.0] — 2026-09-23
### Ajouté
- **#113 — avatar utilisateur.** La section « Profil » des Configurations permet de
**choisir / importer une image** (PNG, JPG, WEBP, 8 Mo max) : elle est recadrée en
carré 256 px côté client, validée côté serveur (data-URL + octets magiques, sans SVG)
et persistée sur le compte (`avatar` dans `data/users.json`, exposé par
`GET/PATCH /api/auth/me` et la réponse de login). L'image s'affiche dans le **cercle
du profil en bas de la sidebar** (initiales en repli) et peut être supprimée. UI :
aperçu circulaire avec overlay caméra au survol, boutons Choisir / Supprimer, erreurs
en ligne. Clés i18n FR/EN.
### Modifié
- **#113 — ordre des sections Configurations.** La TOC et la page sont réorganisées
dans un ordre naturel et **parfaitement synchronisées** : **Profil en premier**,
puis Sécurité, Thèmes, Recherche, Historique récent, Tags, Fichiers cachés,
Synchronisation, Backend, Diagnostics, IA, Sources connectées, Clés API & MCP,
Push, Webhooks, Partages publics, Plugins et **À propos en dernier**. Les ancres
`cfg-tags` et `cfg-partages-publics` sont désormais portées par leur `<section>`
(cible de défilement = haut de section). Garde-fous : test statique
`tests/frontend/settings-order-avatar.test.mjs` (ordre TOC = page, pas d'ancre morte).
---
## [2.21.0] — 2026-09-23
### Ajouté
- **#112 — section compte en bas de la sidebar.** L'identité (avatar avec
initiales, nom, rôle) et la **déconnexion** sont regroupées dans un bloc
épinglé au bas de la barre latérale, comme sur les sites professionnels ; un
clic sur l'identité ouvre le Profil. Nouvelle clef i18n FR/EN pour le rôle.
- **#112 — pellicule d'images défilable.** La molette de la souris fait défiler
horizontalement la bande de miniatures et deux **flèches translucides** sur les
côtés (révélées au survol) remplissent la même fonction.
### Modifié
- **#112 — en-tête allégé.** La **version** quitte le header pour le menu
**Options** (ligne « Version ») ; le **bouton de déconnexion** et le **nom de
l'utilisateur** sont retirés du header au profit de la section compte de la
sidebar. Le header droit ne conserve que l'indicateur hors-ligne, le contexte
vault et le bouton Options.
---
## [2.20.0] — 2026-09-23
### Ajouté
- **#111 — visionneuse d'images : flèches latérales translucides.** Deux boutons
superposés le long des bords du cadre passent à l'image précédente/suivante ;
quasi invisibles au repos, ils se révèlent au survol (et restent visibles sur
les appareils tactiles). Un compteur `n / total` complète la barre d'outils.
### Modifié
- **#111 — navigation d'images fluide.** Changer d'image se fait désormais **en
place** (`showSibling` remplace le `src` du `<img>`) : plus de rechargement de
la vue ni de nouvel appel `/api/browse` à chaque flèche, **même dans un dossier
contenant beaucoup d'images**. La liste du dossier est mise en cache (TTL court)
et les images voisines sont préchargées ; la **pellicule de miniatures reste
visible** et la vignette active est simplement re-marquée.
- **#111 — image toujours ajustée au cadre.** La visionneuse occupe tout l'espace
disponible (zone de contenu en flex, sans défilement de page) et l'image est
systématiquement redimensionnée dans son cadre, y compris panneau
« Métadonnées » ouvert.
---
## [2.19.2] — 2026-09-23
### Ajouté
- **Lanceur E2E Windows/PowerShell** : `scripts/run-e2e-local.ps1` (+ script npm
`test:e2e:ps`) reproduit localement le job CI `e2e` (uvicorn natif, auth
désactivée, fixtures TestVault/TestDir, port 2029, projet `chromium-desktop`)
sans dépendre de `bash` — indispensable sur les postes Windows où WSL ne
démarre pas et où git-bash est bloqué par une politique de contrôle
d'application. Le script `bash` reste la référence pour la CI/Linux.
### Corrigé
- **BUG-072 — visionneuse d'images** : le plein écran (lightbox) et le panneau
« Métadonnées » sont désormais **conservés lors de la navigation** entre images
(flèches ←/→ et clic sur la pellicule) ; auparavant chaque changement d'image
recréait la visionneuse et perdait ces états. Le panneau de métadonnées s'affiche
maintenant en **barre latérale à droite de l'image** (au lieu d'une bande sous la
pellicule) et reste visible en plein écran.
---
## [2.19.1] — 2026-09-23
### Corrigé
- **#110 — lisibilité et positionnement du lecteur média** : en mode mobile, la
barre audio passe en grille (barre de progression pleine largeur sur sa propre
ligne) et les actions secondaires (précédent/suivant/agrandir/volume) sont
masquées pour éviter tout chevauchement ; le volume est aussi masqué sur les
écrans intermédiaires en desktop, et `overflow: hidden` empêche le débordement
hors de la pilule. La mini-fenêtre vidéo n'est plus ré-aimantée sur les bords :
elle se déplace et se redimensionne **librement** (position absolue mémorisée,
centre autorisé), le glisser fonctionnant désormais depuis n'importe quel point
de la fenêtre (boutons/curseurs/poignée exclus, seuil de 4 px pour préserver
les contrôles natifs de la vidéo).
---
## [2.19.0] — 2026-09-23
### Ajouté
- **#110 — Lecteur média persistant « Now Playing »** : les fichiers audio et
vidéo continuent de jouer pendant la navigation grâce à un contrôleur global
`frontend/js/now-playing.js` qui possède **un seul** élément `<audio>`/`<video>`
téléporté entre la vue inline (onglet/panneau) et un dock flottant
(`<body>`), sans interruption de lecture. Dock desktop : pilule verre dépoli
(artwork, titre, voûte, play/pause, précédent/suivant, barre de progression,
volume, retour au média, agrandir, fermer). Mini-fenêtre vidéo flottante
déplaçable/redimensionnable avec aimantation aux coins et bouton
Picture-in-Picture natif. Mode mobile : mini-player fixé au-dessus de la barre
d'outils (safe-area `viewport-fit=cover`). Intégration **Media Session** (écran
verrouillé, touches matérielles, Windows SMTC), panneau étendu façon « Now
Playing », lecture auto du média suivant/précédent du dossier, reprise après
rechargement, notification quand l'onglet est fermé pendant la lecture. Fiche :
[docs/features/media-viewers-109.md](docs/features/media-viewers-109.md) (§ Now
Playing, #110).
---
## [2.18.0] — 2026-09-23
### Ajouté
- **#109 — Support audio & vidéo (lecteurs HTML5 intégrés)** : les fichiers audio
(`.mp3 .m4a .aac .wav .ogg .oga .opus .flac`) et vidéo (`.mp4 .webm .mov .m4v`)
apparaissent désormais dans l'arborescence et l'index (nom/taille/date
uniquement, contenu jamais lu, intégrés à `SUPPORTED_EXTENSIONS` via
`media_types.py`). Nouvel endpoint `GET /api/media/{vault}?path=…` avec support
HTTP `Range` / `206 Partial Content` (`Content-Range`, `Accept-Ranges`, `416`
sur plage invalide, `413` au-delà de `OBSIGATE_MEDIA_MAX_INLINE_MB`, défaut
500 Mo) — le helper Range de `pdf/stream` a été extrait en
`_stream_file_with_range()` et est partagé. `api_file_view()` expose
`is_audio`/`is_video`/`stream_url`/`media_mime` avant toute lecture texte.
Frontend : lecteur audio dédié (artwork, durée via `loadedmetadata`) et lecteur
vidéo (`playsinline`, scène noire letterboxée), icônes Lucide `audio-lines` /
`video`, pause à la réinitialisation de la vue, repli téléchargement si le codec
n'est pas lisible ou le fichier trop volumineux. Les médias sont exclus du
contexte textuel BooksLM et du cache hors-ligne du service worker. Fiche :
[docs/features/media-viewers-109.md](docs/features/media-viewers-109.md).
---
## [2.17.0] — 2026-09-23
### Ajouté
- **#108 — Support complet des images (arborescence, visionneuse, indexation)** :
les images (`.png .jpg .jpeg .gif .svg .webp .bmp .ico`) apparaissent désormais
dans l'arborescence et l'index comme les autres fichiers — nom/taille/date
uniquement, jamais les octets (contenu indexé vide, TF-IDF préservé). Nouveau
module partagé `backend/media_types.py` (extensions + MIME, socle réutilisé par
#109). Visionneuse dédiée : zoom molette 0,1×–8×, pan au glisser, double-clic
pour réinitialiser, boutons +/−/reset et badge de zoom, navigation ←/→ entre
les images du dossier avec pellicule de miniatures, panneau métadonnées
(dimensions, taille, type, chemin, date), lightbox plein écran, « Ouvrir
l'original » et téléchargement. Endpoint `GET /api/media/{vault}/thumb`
(miniature WebP 256 px, cache disque invalidé par mtime, repli sur l'original
pour le SVG, `pillow>=10.0`). Filtre `ext:png`/`ext:jpg` opérationnel ;
compteurs d'images séparés dans `/api/dashboard` (`image_count`/`total_images`).
Fiche : [docs/features/image-support.md](docs/features/image-support.md).
### Corrigé
- **#108-B1 — Affichage isolé d'une image** : le `<img>` généré par
`api_file_view()` pointait vers `/api/file/{vault}/raw` (qui renvoie du JSON)
au lieu de `/api/image/{vault}` (octets + MIME correct). Corrigé côté backend
et dans `viewer.js` (bouton Plein écran), avec encodage d'URL des chemins.
- **#108-B3 — XSS via SVG** : `/api/image` (et le repli miniatures) pose
`Content-Security-Policy: sandbox` sur les SVG ouverts directement, pour
empêcher l'exécution du JavaScript embarqué ; le middleware n'écrase plus une
politique stricte posée par une route.
---
## [2.16.6] — 2026-09-22
### Ajouté
- **Guides d'utilisation `docs/GUIDES/`** : nouvel index + 10 guides FR
(prise en main, recherche/PDF/Excalidraw, assistant IA & Forge,
collaboration temps réel, PWA & hors-ligne, API REST, serveur MCP,
authentification & sécurité, déploiement Docker, desktop Tauri). Le guide MCP
est déplacé dans `docs/GUIDES/MCP.md` ; `docs/MCP_GUIDE.md` devient une page
de redirection.
### Modifié
- **README.md / README.fr.md** : capture d'écran réelle de l'application en tête
(remplace l'illustration ASCII) ; un emoji sur chaque entrée de la table des
matières ; nouvelle section « Guides » avec liens vers `docs/GUIDES/` ;
renvois vers les guides depuis les sections API, Recherche, Sécurité, Desktop
et Collaboration.
---
## [2.16.5] — 2026-09-22
### Corrigé
- **BUG-071 (complément) - spec E2E mobile de la page Configurations** :
`tests/e2e/config-mobile.spec.js` (nouveau, projet `chromium-mobile`,
ignoré en `chromium-desktop` comme `mobile-editor.spec.js`) : le hamburger
révèle le sommaire, le choix d'une section y défile + lien actif + repli
auto, aucun débordement horizontal à 393px. Vérifié en local contre
l'instance de test (port 2029, auth désactivée) : 3/3.
---
## [2.16.4] — 2026-09-22
### Corrigé
- **BUG-071 - Page « Configurations » inutilisable en mode mobile** : trois
causes. (1) Le sommaire (`#config-nav`) partageait la règle `.help-nav`
qui le masque sous 768px, mais — contrairement au Guide — la modale
n'avait aucun bouton pour l'afficher : aucun moyen d'atteindre une section.
Nouvel hamburger `#config-hamburger` dans l'en-tête (même traitement
`.help-hamburger` que le Guide, libellé traduit `config.toc_toggle`
FR/EN). (2) Les liens du sommaire étaient des ancres brutes sans JS :
`config.js` les intercepte désormais (défilement doux vers la section dans
la modale, lien actif, repli automatique du sommaire sur mobile, réinit à
l'ouverture). (3) Les grilles 2 colonnes (fournisseur/modèle IA, clé/modèle
par fournisseur), les rangées d'ajout à largeurs fixes (jetons, webhooks)
et les lignes webhook/jeton/partage en flex une ligne débordaient en
360px : bloc CSS mobile scopé `#config-modal` (1 colonne, wrap, largeurs
inline neutralisées, cibles tactiles 44px, sommaire plafonné à 46vh).
`data-i18n-attr` accepte désormais plusieurs paires `attr:clé` séparées
par `;` (titre + aria-label traduits). Tests :
`tests/frontend/config-mobile.test.mjs` (nouveau, 11 — hamburger, i18n,
câblage JS, CSS mobile, garde-fou ancres mortes façon BUG-067),
enregistré dans le CI.
---
## [2.16.3] — 2026-09-22
### Corrigé
- **BUG-070 - Activation clé physique WebAuthn impossible (« Validation du
credential WebAuthn échouée »)** : deux causes. (1) Les valeurs par défaut
(`rp_id localhost`, origines `http://localhost` sans port) rejetaient toute
URL réelle — logs : `Unexpected client data origin "http://localhost:2020",
expected one of ['http://localhost']`. `rp_id`/origines sont désormais
dérivés de la requête (hôte exact, port inclus ; `X-Forwarded-Host/Proto`
si `OBSIGATE_TRUST_PROXY=true`), la config explicite restant prioritaire
(`backend/auth/webauthn_mfa.py::resolve_relying_party`, appliqué aux 4
endpoints d'enregistrement et de login). (2) Challenge à usage unique
fragile au double-clic/retry (`challenge was not expected`) : les 5
derniers challenges sont conservés et la vérification accepte le challenge
correspondant à la cérémonie en cours. `.env.example` documente le nouveau
comportement. Vérifié au navigateur avec authentificateur virtuel
(Playwright CDP, instance Docker) : enregistrement 200 + clé listée, puis
clé de test retirée. Tests : `tests/test_webauthn.py` (+8 : résolution RP,
forwarded, retry, roundtrip sans config).
---
## [2.16.2] — 2026-09-22
### Corrigé
- **BUG-069 - Login 2FA bloqué sans erreur** : après user+mot de passe corrects
sur un compte avec 2FA, la page de login restait affichée sans erreur et le
challenge MFA n'apparaissait jamais. Cause : `showMfaChallenge`
(`frontend/js/auth.js`) montait le challenge dans `.login-box`, inexistant
dans `index.html` (marquage réel : `#login-screen > .login-card`) →
`return` silencieux. Correctif : montage dans `.login-card` (repli
`#login-screen`) + erreur visible (`mfa.challenge_unavailable`, FR/EN) au
lieu d'un retour silencieux si le point de montage manque. Vérifié de bout
en bout au navigateur (Playwright, instance Docker) : challenge affiché,
code erroné → erreur, code valide → connecté. Tests :
`tests/frontend/mfa-settings.test.mjs` (+2 contrôles d'ancrage DOM).
---
## [2.16.1] — 2026-09-22
### Corrigé
- **BUG-068 - Configuration : section « 🔒 Sécurité du compte » inachevée** :
boutons `config-btn-primary` / `config-btn-danger` définis depuis les
variables du thème (`frontend/style.css`) ; QR code TOTP généré en local par
le backend (`POST /api/auth/mfa/totp/setup` → `qr_data_url`, SVG `data:`
via `segno`, `backend/requirements.txt`) au lieu de l'image tierce bloquée
par la CSP (`img-src 'self' data: blob:`, secret TOTP exposé) ; codes de
récupération affichés aussi à la première activation WebAuthn ; carte
« Mot de passe » (changement via `POST /api/auth/change-password`) et
échappement des libellés de clés WebAuthn. Tests :
`tests/test_mfa.py::test_mfa_setup_returns_local_qr_data_url`,
`tests/frontend/mfa-settings.test.mjs` (nouveau, 9 contrôles).
---
## [2.16.0] — 2026-09-22
### Modifié
- **#86 - Optimisation globale des performances (phase 3)** : ferme les deux derniers
points de la phase 3 (recherche via inverted index, PDF lazy et caps regex déjà livrés
via BUG-033/BUG-040/BUG-025). Scan **différentiel** : `_scan_vault` réutilise les
entrées inchangées (`size` + `modified`) d'un snapshot précédent — seuls `os.walk` +
`stat` tournent à chaque rebuild (`build_index`, `reload_single_vault`). Extraction
**excalidraw différée** : le scan ne lit plus les `.excalidraw` / `.excalidraw.md`
(flag `excalidraw_text_pending`), `enrich_pdf_texts()` extrait leur texte après index
comme pour les PDF. Garde-fou `MAX_REPLACE_FILE_BYTES` (5 Mio) sur `replace_in_files`.
Tests : `tests/test_perf_phase3.py` (9). Voir
[docs/features/perf-phase3-86.md](./docs/features/perf-phase3-86.md).
---
## [2.15.0] — 2026-09-22
### Ajouté
- **#107 - Configuration : gestion des clés API & MCP** — nouvelle section
« 🔑 Clés API & MCP » dans le panneau de configuration : création, liste
(créée / expire / dernière utilisation) et révocation de jetons longue
durée utilisables aussi bien sur l'API REST que sur le serveur MCP
(`/mcp`) — un seul et même jeton Bearer pour les deux. Choix
d'expiration à la création : 1 jour, 1 mois, 6 mois, 1 an, sans fin.
Le secret n'est affiché qu'une fois (jamais persisté en clair,
`data/api_tokens.json` ne contient que les métadonnées) ; révocation
immédiate des deux côtés, plafond 50 clés par utilisateur, isolation
par utilisateur, audit `config_change`. Corrigé au passage : le store
de révocation (`revoked_tokens.json`) bornait toute entrée à 7 jours —
un jeton longue durée révoqué « reprenait vie » après purge ; il est
désormais calé sur l'expiration réelle du jeton. Voir
[docs/features/api-mcp-tokens-107.md](./docs/features/api-mcp-tokens-107.md).
---
## [2.14.1] — 2026-09-22
---
## [2.14.0] — 2026-09-19
### Ajouté
- **#106 - Assistant IA : actions instantanées contextuelles + catalogue de prompts** :
la zone d'accueil remplace les 3 suggestions statiques par des **actions suggérées
selon le contexte détecté** (sélection dans l'éditeur → concis/corriger/expliquer ;
fichier de code → expliquer/bugs/tests ; 2+ docs → fusionner/comparer/frictions ;
1 doc → résumé 3 points/checklist/frontmatter ; répertoire ou général sinon), avec
badge de contexte dans l'en-tête. Un bouton **« Toutes les actions »** ouvre un
tiroir catalogue (recherche instantanée, 6 catégories, 25 actions i18n FR/EN).
Nouveau prompt **« Générer le frontmatter YAML »** au format complet du vault
(titre, auteur, dates ISO-8601, tags, aliases, booléens, NomDeVoute, Description)
et **« Mettre à jour le frontmatter »** (conserve les champs existants, actualise
`modification_date`, recalcule tags/aliases/Description, complète les manquants) —
tous deux basculent en mode agent et appliquent le résultat via la carte de
confirmation. Détail : [features/ai-quick-actions.md](./features/ai-quick-actions.md).
---
## [2.13.1] — 2026-09-18
---
## [2.13.0] — 2026-09-18
### Ajouté
- **#105 - Guide d'utilisation : audit de couverture, téléchargement Markdown/PDF,
section Architecture** : le guide intégré est aligné sur l'application réelle après
~35 features livrées. Huit nouvelles sections — **🏗️ Architecture** (diagramme Mermaid
des grandes composantes : clients SPA/PWA/Tauri → serveur FastAPI REST, index de
recherche, rendu markdown, IA, MCP, WebSocket, webhooks → vaults, `data/*.json`,
backups), **📊 Diagrammes** (Mermaid : zoom, plein écran, copie SVG/code, thèmes),
**⭐ Bibliothèque** (signets, recherches sauvegardées, backlinks & graphe, conflits
Syncthing, pièces jointes), **📴 Hors-ligne** (PWA, file d'attente IndexedDB, replay,
watcher), **👥 Collaboration** (Yjs/CRDT, curseurs awareness), **🖥️ Desktop** (Tauri,
updater signé, assistant premier lancement), **🔌 API** (OpenAPI 3.1 : `/docs`,
`/redoc`, `/api`, `/openapi.json`, auth Bearer/cookie, serveur MCP, automatisation)
et **🌍 Multilingue** — plus des compléments dans les sections existantes (recherche
sémantique hybride, notifications web push, export PDF, export HTML/ePub/ZIP,
anti-doublons d'upload, vue multi-panneaux, MFA TOTP/WebAuthn, tableau de bord
admin). Nouveau **`GET /api/guide/download?format=md|pdf&lang=fr|en`** (tag OpenAPI
« Guide ») : le document est généré depuis la modale d'aide réelle et les locales,
il reflète donc exactement le guide affiché, dans la langue de l'utilisateur ;
boutons **Markdown** et **PDF** ajoutés dans l'en-tête du guide. En **desktop**
(Tauri, `body.desktop-mode`) et sur grand écran web, le guide s'élargit
(conteneur jusqu'à 1760 px, contenu 1280–1440 px) pour une lecture confortable.
Source de vérité du nouveau contenu : `scripts/guide_content.py` (locales générées,
jamais éditées à la main). Tests : `tests/test_guide.py` (11).
### Modifié (ajustements retour utilisateur sur #105)
- Boutons de téléchargement du guide : **icônes seules** (plus de libellé « Markdown »/
« PDF »), tooltip i18n explicite sur chaque bouton.
- PDF du guide : le bloc Mermaid de la section Architecture est converti en **diagramme
rendu** (PNG pré-rendu commité via `scripts/build_guide_diagrams.py` +
`scripts/render_guide_diagram.mjs`, inséré par `diagram_png_for()`), au lieu du code
brut ; le Markdown garde le fenced ` ```mermaid ` (copiable).
- PDF du guide : emoji rendus **en couleur** au lieu de rectangles — `fonts-noto-color-emoji`
ajouté à l'image Docker et `"Noto Color Emoji"` en fin de pile de polices du PDF
(la TOC était correcte car elle utilise DejaVu, qui n'a pas les emoji pleine chasse).
### Corrigé
- **BUG-067 - Guide : entrée « 📱 Mobile » morte** : le sommaire pointait vers
`#help-mobile-editor`, section inexistante (l'édition mobile n'était qu'un `<h3>`
de la section Édition). Le bloc est devenu une section dédiée ancrée ; l'ancre
`#help-ia` (également morte) a été corrigée en `#help-ai` et un attribut
`data-i18n-placeholder` dupliqué nettoyé. Garde-fou : `test_guide_nav_has_no_dead_anchors`.
---
## [2.12.2] — 2026-09-18
### Corrigé
- **BUG-066 — Configuration : icônes manquantes dans la table des matières** :
les entrées « Fichiers cachés » et « Partages publics » du sommaire de la page de
configuration n'affichaient pas d'icône (les titres de section, eux, en avaient une).
Les libellés i18n `config.section_hidden` (🗂️) et `config.section_shares` (📤) sont
alignés sur leurs titres de section, en FR **et** EN. Test de non-régression ajouté
dans `tests/frontend/unit.test.mjs` (toutes les entrées du sommaire doivent porter une
icône dans les deux langues).
---
## [2.12.1] — 2026-09-18
---
## [2.12.0] — 2026-09-18
### Ajouté
- **#104 - Configuration : redesign UI de la section « 🤖 Clés API Intelligence Artificielle »** :
la longue liste plate de champs devient une interface structurée et dépliable —
1. **En-tête de section** : titre + sous-titre explicatifs et une **barre de recherche**
(`#cfg-ai-search`) pour filtrer les fournisseurs (normalisée, insensible à la casse et aux
accents), avec état vide « Aucun fournisseur ne correspond ».
2. **Carte « Configuration par défaut »** visuellement distincte : grille **2 colonnes**
fournisseur / modèle par défaut, et **capacités du modèle rendues en badges colorés**
(nouveau `renderCapabilityBadges()` dans `frontend/js/ai.js`, uniquement les capacités
actives) au lieu des cases à cocher.
3. **Fournisseurs d'API en cartes dépliables** (rendu dynamique depuis `AI_PROVIDER_NAMES` par
`_renderAIProviderCards()`, `frontend/js/config.js`) : état replié = logo (initiale), nom,
**badge de statut** (« Configuré » vert / « Non configuré » gris) et **corbeille discrète**
pour supprimer la clé (visible uniquement si configurée, confirmation conservée) ; état
déplié = libellés au-dessus des champs, **API key à 60 % / modèle à 40 %** (grille `3fr 2fr`).
Les boutons « Configuré/Supprimer » redondants à l'intérieur des champs disparaissent — le
statut vit désormais uniquement dans l'en-tête de la carte.
4. **Barre d'actions** : « Sauvegarder » (primaire) et « Tester » (outline) dans un footer
**sticky** en bas de section, toujours accessible pendant le défilement, avec le statut de
test à côté.
Styles `.ai-keys-*` / `.ai-provider-*` en variables CSS (profondeur fond/carte, espacement
généreux, chevron animé, focus visible, responsive 1 colonne < 600 px) ; textes i18n FR/EN
(`config.ai_*`). Les ID d'éléments (`cfg-<provider>-key/model/badge/delete`) et le
comportement de sauvegarde/test/suppression restent inchangés (rétrocompatible pickers IA).
Tests : `tests/frontend/config-ai-keys.test.mjs` (7 — rendu, badges, bascule, filtre, save,
suppression, badges de capacités).
---
## [2.11.6] — 2026-09-18
### Corrigé
- **BUG-064 - Éditeur Excalidraw : le diagramme ne s'affiche jamais (canvas vide)** — deux
causes cumulées :
1. **La feuille de style d'Excalidraw n'était jamais chargée** (`@excalidraw/excalidraw` exige
un import CSS explicite). Sans elle, l'éditeur est non stylisé et `.excalidraw` n'a aucune
hauteur fixe : la boucle de redimensionnement d'Excalidraw fait grossir le canvas jusqu'au
plafond codé en dur de `2^25` (33 554 432 px), que le navigateur ne peut pas dessiner → scène
blanche. Correctifs : `<link>` vers `…/@excalidraw/[email protected]/dist/prod/index.css` dans
`frontend/excalidraw-editor.html` et ajout de `https://esm.sh` à `style-src` de la CSP
(`backend/main.py`).
2. `appState.collaborators` est une `Map` sérialisée en objet JSON (`{}`) par l'app Excalidraw /
le plugin Obsidian ; réinjectée via `initialData`, Excalidraw 0.18 appelait `.forEach()` dessus
et plantait (`e.appState.collaborators.forEach is not a function`). `sanitizeAppState()` la
reconvertit en `Map` et écarte la géométrie de viewport importée (`width`, `height`,
`offsetLeft`, `offsetTop`) pour les deux formats (`.excalidraw` et `.excalidraw.md`).
Tests de non-régression : `tests/frontend/excalidraw-viewer.test.mjs` (CSS lié + CSP),
`tests/test_security_hardening.py::TestCspExcalidrawStylesheet`, `tests/e2e/excalidraw.spec.js`
(hauteur de canvas bornée) + fixtures `test_vault/diagram-app-export.excalidraw`.
- **BUG-064 (complément) - Éditeur Excalidraw : pleine largeur quand la navigation est masquée** :
la règle `.sidebar.hidden ~ .content-wrapper .content-area { max-width: 1200px }` (colonne de
lecture centrée) s'appliquait aussi au viewer Excalidraw. Ajout de
`.content-area:has(iframe[src*="excalidraw-editor.html"])` en `max-width: none; margin: 0`,
comme pour les viewers PDF/image (BUG-062). Fichier : `frontend/style.css`.
- **BUG-065 - Excalidraw : l'auto-save rechargeait la page en pleine édition** : chaque
modification déclenchait, 2 s plus tard, un `PUT /api/file/…/save` ; l'écriture émettait
`index_updated` (SSE) qui re-rendait la vue et **recréait l'iframe** — un refresh visible qui
interrompait le dessin. L'auto-save est supprimée (`frontend/js/excalidraw-viewer.js`) :
sauvegarde explicite par le bouton « 💾 Save » ou `Ctrl+S`. En complément, `reloadExternalWrite`
ne re-rend plus la vue quand un iframe Excalidraw est déjà ouvert sur le fichier
(`iframe[data-excalidraw-*]`), et le badge « Modified » ne se déclenche plus sur les
changements d'`appState` (resize, zoom) mais uniquement sur le contenu (signature des éléments).
- **#78 (complément) - Bouton plein écran pour les diagrammes Excalidraw** : nouveau bouton
`#btn-fullscreen` dans la barre d'outils de l'éditeur (`frontend/excalidraw-editor.html`) qui
bascule le mode plein écran natif ; l'iframe est créée avec `allow="fullscreen" allowfullscreen`
(`frontend/js/excalidraw-viewer.js`). Fichiers : `frontend/excalidraw-editor.html`,
`frontend/js/excalidraw-viewer.js`, `tests/frontend/excalidraw-viewer.test.mjs`.
- **#78 (complément) - Barre d'outils Excalidraw en icônes, verticale à droite** : les boutons
Save / PNG / SVG / plein écran passent en **icônes seules** (34×34 px) dans une colonne
**collée au bord droit** (`right: 0`), débutant à `45%` de la hauteur, empilée verticalement,
avec infobulles et `aria-label`. L'icône du bouton Save devient une coche après une sauvegarde
réussie. Fichier : `frontend/excalidraw-editor.html`.
---
## [2.11.5] — 2026-09-17
### Corrigé
- **BUG-063 (complément) — Viewer PDF : la table des matières ne déplaçait toujours pas la
page** : le premier correctif réassignait `iframe.src` avec le seul fragment `#page=N`, ce qui
ne change que le fragment → navigation *same-document* que le lecteur PDF natif ignore (il
n'applique `#page=N` qu'au chargement). Diagnostic en Chrome *headful* par comparaison de
captures. `navigatePdfToPage()` ajoute désormais un paramètre de query horodaté
(`&_pdfpage=<ts>#page=N`) pour forcer un vrai rechargement de l'iframe. Fichiers :
`frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js`.
---
## [2.11.4] — 2026-09-17
### Corrigé
- **BUG-061 — Assistant IA : le bouton « Plein écran » n'agrandissait plus le panneau** : la
largeur du panneau est écrite en style inline par la poignée de redimensionnement (et par la
largeur persistée en `localStorage`) ; cet inline l'emportait sur la règle
`.bookslm-panel.fullscreen { width: 100vw }`, donc le panneau restait à sa largeur courante.
La règle plein écran est désormais prioritaire (`!important`). Fichier : `frontend/style.css`.
- **BUG-062 — Viewer PDF : largeur incomplète quand la navigation est masquée** : la règle de
colonne de lecture centrée (`.sidebar.hidden … { max-width: 1200px }`) s'appliquait aussi aux
viewers plein cadre. Les conteneurs PDF et image sont maintenant exemptés
(`:has(.pdf-viewer-container)` / `:has(.image-viewer-container)` → `max-width: none`). Fichier :
`frontend/style.css`.
- **BUG-063 — Viewer PDF : la table des matières ne naviguait pas** : les liens faisaient
`contentWindow.location.hash = 'page=N'`, mais le lecteur PDF natif vit dans une fenêtre
`about:blank` et l'affectation n'atteignait jamais le document. Nouveau helper
`navigatePdfToPage()` qui recharge l'iframe avec le fragment `#page=N` ; les entrées portent un
`data-page` et sont câblées par des écouteurs (plus d'`onclick` inline). Fichiers :
`frontend/js/viewer.js`, `frontend/style.css`.
- **Tests** : `tests/frontend/ai.test.mjs` (+1), `tests/frontend/pdf-viewer.test.mjs` (TOC, plein
largeur), `tests/e2e/pdf-viewer.spec.js` (TOC `#page=N`, largeur, fixture
`test_vault/sample-pdf-toc.pdf`).
---
## [2.11.3] — 2026-09-17
### Corrigé
+1 -1
View File
@@ -24,7 +24,7 @@ COPY --from=builder /install /usr/local
# WeasyPrint runtime dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 shared-mime-info \
&& apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 shared-mime-info fonts-noto-color-emoji \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+89 -40
View File
@@ -1,62 +1,77 @@
# ObsiGate
> **Version française** — ce document est le miroir synchronisé de [README.md](README.md) (référence complète). Dernière synchronisation : juin 2026.
> **Version française** — ce document est le miroir synchronisé de [README.md](README.md) (référence complète). Dernière synchronisation : septembre 2026.
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.11.3-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.25.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
[![CI/CD](https://img.shields.io/badge/CI%2FCD-Gitea_Actions-green.svg)](https://git.dracodev.net/Projets/ObsiGate/actions)
```
┌─────────────────────────────────────────────────────────┐
│ [🔍 Recherche...] [☀/🌙 Thème] ObsiGate │
├──────────────┬──────────────────────────────────────────┤
│ SIDEBAR │ CONTENT AREA │
│ ▼ Recettes │ 📄 Titre du fichier │
│ 📁 Soupes │ Tags: #recette #rapide │
│ 📄 Pizza │ [Contenu Markdown rendu] │
│ ▼ IT │ │
│ 📁 Docker │ │
│ Tags Cloud │ │
└──────────────┴──────────────────────────────────────────┘
```
![Interface ObsiGate — tableau de bord Statistiques avec vaults, tags et raccourcis clavier](docs/images/obsigate-home.png)
> Interface web d'ObsiGate : sidebar multi-vault, recherche globale, statistiques et raccourcis.
---
## 📚 Guides
Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/GUIDES/) :
| Guide | Contenu |
|---|---|
| 🚀 [Prise en main](docs/GUIDES/PRISE_EN_MAIN.md) | Premier lancement, interface, navigation, vaults, raccourcis |
| 🔍 [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
| 🤖 [Assistant IA & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
| 📝 [Édition & collaboration](docs/GUIDES/COLLABORATION.md) | Édition simultanée, curseurs distants, persistance |
| 📱 [PWA & hors-ligne](docs/GUIDES/PWA_HORS_LIGNE.md) | Installation, cache hors-ligne, file de synchro, notifications |
| 🔌 [API REST](docs/GUIDES/API_REST.md) | Authentification, clés API, endpoints, exemples `curl`, SSE |
| 🧩 [Serveur MCP](docs/GUIDES/MCP.md) | Brancher Claude Desktop, Cursor, Cline… sur vos vaults |
| 🔒 [Authentification & sécurité](docs/GUIDES/AUTHENTIFICATION_SECURITE.md) | Utilisateurs, MFA, permissions par vault, durcissement |
| 🐳 [Déploiement Docker](docs/GUIDES/DEPLOIEMENT_DOCKER.md) | `docker-compose`, volumes, reverse proxy, mises à jour |
| 🖥️ [Desktop (Tauri)](docs/GUIDES/DESKTOP.md) | Installation, premier lancement, build depuis les sources, dépannage |
> Index complet : [`docs/GUIDES/README.md`](docs/GUIDES/README.md).
---
## 📋 Table des matières
- [Fonctionnalités](#fonctionnalites)
- [Prérequis](#prerequis)
- [Installation rapide](#installation-rapide)
- [Configuration détaillée](#configuration-detaillee)
- [Variables d'environnement](#variables-denvironnement)
- [🔒 Authentification](#authentification)
- [Ajouter une nouvelle vault](#ajouter-une-nouvelle-vault)
- [Build & déploiement avec build.sh](#build-deploiement-avec-buildsh)
- [Rendu d'images Obsidian](#rendu-dimages-obsidian)
- [Desktop (Tauri) — Application native](#desktop-tauri-application-native)
- [Utilisation](#utilisation)
- [API](#api)
- [Recherche avancée](#recherche-avancee)
- [Dépannage](#depannage)
- [Performance](#performance)
- [Sécurité](#securite)
- [Stack technique](#stack-technique)
- [Architecture](#architecture)
- [Développement](#developpement)
- [Licence](#licence)
- [Changelog](#changelog)
- ✨ [Fonctionnalités](#fonctionnalites)
- 📚 [Guides](#guides)
- 🚀 [Prérequis](#prerequis)
- ⚡ [Installation rapide](#installation-rapide)
- ⚙️ [Configuration détaillée](#configuration-detaillee)
- 🌍 [Variables d'environnement](#variables-denvironnement)
- 🔒 [Authentification](#authentification)
- ➕ [Ajouter une nouvelle vault](#ajouter-une-nouvelle-vault)
- 🔨 [Build & déploiement avec build.sh](#build-deploiement-avec-buildsh)
- 🖼️ [Rendu d'images Obsidian](#rendu-dimages-obsidian)
- 🖥️ [Desktop (Tauri) — Application native](#desktop-tauri-application-native)
- 📖 [Utilisation](#utilisation)
- 👥 [Collaboration temps réel](#collaboration-temps-reel)
- 🔌 [API](#api)
- 🔍 [Recherche avancée](#recherche-avancee)
- 🔧 [Dépannage](#depannage)
- ⚡ [Performance](#performance)
- 🛡️ [Sécurité](#securite)
- 🏗️ [Stack technique](#stack-technique)
- 🏠 [Architecture](#architecture)
- 📝 [Développement](#developpement)
- 📄 [Licence](#licence)
- 🤝 [Support](#support)
- 📝 [Changelog](#changelog)
---
## ✨ Fonctionnalités
- **🤖 AI Editor intégré** — Éditeur CodeMirror 6 avec toolbar IA : amélioration, correction, traduction, génération, réécriture personnalisée, toolbox (liste, tableau, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
- **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/MCP_GUIDE.md))
- **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/GUIDES/MCP.md))
- **👥 Collaboration temps réel** — Édition simultanée d'un même document (Yjs/CRDT) : curseurs distants colorés, indicateur de présence, fusion sans conflit, reconnexion automatique et persistance serveur ([détail](docs/features/collaboration.md))
- **📖 Guide d'utilisation intégré** — Aide complète en FR/EN accessible depuis le menu Options : interface, navigation, recherche, fichiers, IA, sécurité, API & intégrations (OpenAPI, MCP), hors-ligne, collaboration, desktop, plus une section **Architecture** avec diagramme Mermaid ; téléchargeable en **Markdown** et **PDF** dans la langue courante ([détail](docs/features/guide-coverage-105.md))
- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md))
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
@@ -68,6 +83,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
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
@@ -281,6 +297,7 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Autoriser les webhooks non HTTPS | `false` |
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Autoriser les webhooks vers des adresses privées/boucle | `false` |
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Taille max pour la lecture audio/vidéo intégrée (au-delà : téléchargement) | `500` |
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Fournisseurs de recherche web à clé (essayés avant SearXNG) | — |
| `OBSIGATE_WEB_PROVIDERS` | Ordre des fournisseurs de recherche (ex. `brave,searxng`) | — |
@@ -391,6 +408,18 @@ ObsiGate supporte **toutes les syntaxes d'images Obsidian** avec résolution int
6. Index de démarrage (match le plus proche)
7. Fallback : placeholder stylisé `[image not found: filename.ext]`
### Visionneuse & arborescence
Les images sont de plein droit des fichiers du vault : elles apparaissent dans
l'arborescence, sont indexées (nom + métadonnées, **jamais les octets**) et
s'ouvrent dans une **visionneuse dédiée** — zoom molette 0,1×–8×, pan au
glisser, double-clic pour réinitialiser, navigation ←/→ entre les images du
dossier (avec pellicule de miniatures WebP), panneau de métadonnées, lightbox
plein écran, ouverture de l'original et téléchargement. Le filtre de recherche
`ext:png`/`ext:jpg` est disponible. Formats décodables : PNG, JPEG, GIF, WebP,
BMP, ICO, SVG (SVG servi avec une politique CSP `sandbox`). **HEIC/HEIF**
(iPhone) n'est pas décodable par les navigateurs et n'est pas pris en charge.
### Configuration
```yaml
@@ -411,6 +440,8 @@ curl -X POST http://localhost:2020/api/attachments/rescan/MonVault
## 🖥️ Desktop (Tauri) — Application native
> 📖 Guide complet : [Desktop (Tauri)](docs/GUIDES/DESKTOP.md)
ObsiGate Desktop est une application native construite avec [Tauri](https://tauri.app/) (Rust + webview système). Elle embarque le backend Python et le frontend dans un exécutable standalone — zéro Docker, zéro ligne de commande.
> 🚧 **Version 2.0.0 — binaires en cours de stabilisation.** Pour l'instant, le build depuis les sources est recommandé.
@@ -570,6 +601,8 @@ Cycle de vie : Tauri spawn le backend Python → health check → splash de dém
## 👥 Collaboration temps réel
> 📖 Guide complet : [Édition & collaboration](docs/GUIDES/COLLABORATION.md)
Plusieurs utilisateurs peuvent éditer le même document markdown simultanément (façon Google Docs) :
- **Fusion sans conflit** grâce à Yjs (CRDT) : deux personnes peuvent taper au même endroit, aucune
@@ -589,6 +622,8 @@ fenêtres) pour voir la collaboration en action.
## 🔌 API
> 📖 Guide complet : [API REST](docs/GUIDES/API_REST.md) · [Serveur MCP](docs/GUIDES/MCP.md)
ObsiGate expose une API REST complète :
| Endpoint | Description | Méthode | Auth |
@@ -616,6 +651,7 @@ ObsiGate expose une API REST complète :
| `/api/events` | Flux SSE temps réel | GET | Oui |
| `/api/vaults/add` / `/api/vaults/{name}` | Gestion dynamique des vaults | POST/DELETE | Admin |
| `/api/image/{vault}?path=` | Servir une image | GET | Oui |
| `/api/media/{vault}/thumb?path=&size=` | Miniature WebP (cache disque) | GET | Oui |
| `/api/config` | Lire / écrire la configuration | GET/POST | Oui/Admin |
| `/api/diagnostics` | Statistiques index et mémoire | GET | Admin |
@@ -636,6 +672,8 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
## 🔍 Recherche avancée
> 📖 Guide complet : [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
### Syntaxe de requête
| Opérateur | Description | Exemple |
@@ -757,6 +795,8 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
## 🛡️ Sécurité
> 📖 Guide complet : [Authentification & sécurité](docs/GUIDES/AUTHENTIFICATION_SECURITE.md)
- **Path traversal** : tous les endpoints fichier valident que le chemin résolu reste dans la vault
- **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives)
- **Audit log** : écritures/suppressions/config journalisées dans `data/audit.log` (JSON lines, rotation 10 MB)
@@ -832,7 +872,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
| Validation des imports frontend | `node tests/frontend/validate-imports.mjs` | `lint` |
| Tests unitaires frontend | `node tests/frontend/unit.test.mjs` | `lint` |
| Tests backend | `pytest tests/ -q` | `test` |
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
#### Tests E2E locaux (`npm run test:e2e`)
@@ -855,6 +895,15 @@ bash scripts/run-e2e-local.sh --headed # navigateur visible
bash scripts/run-e2e-local.sh -g "reset panes" # filtre sur un test
```
Sous Windows, si `bash` n'est pas exploitable (WSL indisponible, git-bash
bloqué par une politique de contrôle d'application), utiliser le lanceur
PowerShell équivalent :
```powershell
npm run test:e2e:ps
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
```
La suite doit se terminer sur **tous les tests passant** (60 actuellement),
sans échec ni dépendance aux retries. En cas d'échec : corriger et relancer
localement jusqu'à 100 %, puis seulement commiter.
@@ -926,8 +975,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.11.3).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.25.0).
---
*Projet : ObsiGate | Version : 2.11.3 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.25.0 | Dernière mise à jour : Septembre 2026*
+90 -35
View File
@@ -2,54 +2,75 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.11.3-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.25.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
[![CI/CD](https://img.shields.io/badge/CI%2FCD-Gitea_Actions-green.svg)](https://git.dracodev.net/Projets/ObsiGate/actions)
```
┌─────────────────────────────────────────────────────────┐
│ [🔍 Search...] [☀/🌙 Theme] ObsiGate │
├──────────────┬──────────────────────────────────────────┤
│ SIDEBAR │ CONTENT AREA │
│ ▼ Recipes │ 📄 File Title │
│ 📁 Soups │ Tags: #recipe #quick │
│ 📄 Pizza │ [Rendered Markdown Content] │
│ ▼ IT │ │
│ 📁 Docker │ │
│ Tags Cloud │ │
└──────────────┴──────────────────────────────────────────┘
```
![ObsiGate interface — statistics dashboard with vaults, tags and keyboard shortcuts](docs/images/obsigate-home.png)
> ObsiGate web interface: multi-vault sidebar, global search, dashboard stats and shortcuts.
---
## 📚 Guides
Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
| Guide | What it covers |
|---|---|
| 🚀 [Getting Started](docs/GUIDES/PRISE_EN_MAIN.md) | First run, interface, navigation, vaults, shortcuts |
| 🔍 [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF viewer, diagrams |
| 🤖 [AI Assistant & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Providers, AI editor, BooksLM, Forge, `@` / `/` commands |
| 📝 [Editing & Collaboration](docs/GUIDES/COLLABORATION.md) | Simultaneous editing, remote cursors, persistence |
| 📱 [PWA & Offline](docs/GUIDES/PWA_HORS_LIGNE.md) | Install as an app, offline cache, sync queue, push |
| 🔌 [REST API](docs/GUIDES/API_REST.md) | Authentication, API keys, endpoints, `curl` examples, SSE |
| 🧩 [MCP Server](docs/GUIDES/MCP.md) | Connect Claude Desktop, Cursor, Cline… to your vaults |
| 🔒 [Auth & Security](docs/GUIDES/AUTHENTIFICATION_SECURITE.md) | Users, MFA, per-vault permissions, hardening |
| 🐳 [Docker Deployment](docs/GUIDES/DEPLOIEMENT_DOCKER.md) | `docker-compose`, volumes, reverse proxy, updates |
| 🖥️ [Desktop (Tauri)](docs/GUIDES/DESKTOP.md) | Install, first run, build from source, troubleshooting |
> All guides are currently written in **French**. See the full index:
> [`docs/GUIDES/README.md`](docs/GUIDES/README.md).
---
## 📋 Table of Contents
- [Features](#features)
- [Architecture](#architecture)
- [Prerequisites](#prerequisites)
- [Quick Installation](#quick-installation)
- [Detailed Configuration](#detailed-configuration)
- [Environment Variables](#environment-variables)
- [🔒 Authentication](#authentication)
- [Adding a New Vault](#adding-a-new-vault)
- [Build & Deployment with build.sh](#build--deployment-with-buildsh)
- [Desktop (Tauri) — Native Application](#desktop-tauri--native-application)
- [Usage](#usage)
- [API](#api)
- [Performance](#performance)
- [Troubleshooting](#troubleshooting)
- [Tech Stack](#tech-stack)
- [Changelog](#changelog)
- ✨ [Features](#features)
- 📚 [Guides](#guides)
- 🚀 [Prerequisites](#prerequisites)
- ⚡ [Quick Installation](#quick-installation)
- ⚙️ [Detailed Configuration](#detailed-configuration)
- 🌍 [Environment Variables](#environment-variables)
- 🔒 [Authentication](#authentication)
- ➕ [Adding a New Vault](#adding-a-new-vault)
- 🔨 [Build & Deployment with build.sh](#build--deployment-with-buildsh)
- 🖼️ [Obsidian Image Rendering](#obsidian-image-rendering)
- 🖥️ [Desktop (Tauri) — Native Application](#desktop-tauri--native-application)
- 📖 [Usage](#usage)
- 👥 [Real-time Collaboration](#real-time-collaboration)
- 🔌 [API](#api)
- 🔍 [Advanced Search](#advanced-search)
- 🛡️ [Security](#security)
- ⚡ [Performance](#performance)
- 🔧 [Troubleshooting](#troubleshooting)
- 🏗️ [Tech Stack](#tech-stack)
- 🏠 [Architecture](#architecture)
- 📝 [Development](#development)
- 📄 [License](#license)
- 🤝 [Support](#support)
- 📝 [Changelog](#changelog)
---
## ✨ Features
- **🤖 Integrated AI Editor** — CodeMirror 6 editor with AI toolbar: improve, correct, translate, generate, custom rewrite, toolbox (list, table, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
- **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/MCP_GUIDE.md))
- **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/GUIDES/MCP.md))
- **👥 Real-time Collaboration** — Simultaneous editing of the same document (Yjs/CRDT): colored remote cursors, presence indicator, conflict-free merge, automatic reconnection and server-side persistence ([details](docs/features/collaboration.md))
- **📖 Built-in User Guide** — Complete FR/EN help from the Options menu: interface, navigation, search, files, AI, security, API & integrations (OpenAPI, MCP), offline, collaboration, desktop, plus an **Architecture** section with a Mermaid diagram; downloadable as **Markdown** and **PDF** in the current language ([details](docs/features/guide-coverage-105.md))
- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md))
- **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click)
- **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously
@@ -61,6 +82,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
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
@@ -319,6 +341,7 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Allow non-HTTPS webhook targets | `false` |
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Allow webhooks to private/loopback addresses | `false` |
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Max size for inline audio/video playback (above: download) | `500` |
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Keyed web-search providers (tried before SearXNG) | — |
| `OBSIGATE_WEB_PROVIDERS` | Search provider order (e.g. `brave,searxng`) | — |
@@ -495,6 +518,17 @@ ObsiGate uses 7 resolution strategies in order of priority:
6. **Startup index (closest match)** : If multiple files have the same name
7. **Fallback** : Display a styled placeholder `[image not found: filename.ext]`
### Viewer & file tree
Images are first-class vault files: they appear in the tree, are indexed (name +
metadata, **never the bytes**) and open in a **dedicated viewer** — wheel zoom
0.1×–8×, drag pan, double-click to reset, ←/→ navigation between images in the
same folder (WebP thumbnail filmstrip), metadata panel, full-screen lightbox,
open original and download. The `ext:png`/`ext:jpg` search filter is available.
Decodable formats: PNG, JPEG, GIF, WebP, BMP, ICO, SVG (SVG served with a
`sandbox` CSP). **HEIC/HEIF** (iPhone) is not decodable by browsers and is not
supported.
### Configuration
To optimize resolution, configure the attachments folder for each vault:
@@ -519,6 +553,8 @@ curl -X POST http://localhost:2020/api/attachments/rescan/MyVault
## 🖥️ Desktop (Tauri) — Native Application
> 📖 Full guide: [Desktop (Tauri)](docs/GUIDES/DESKTOP.md)
ObsiGate Desktop is a native application built with [Tauri](https://tauri.app/) (Rust + system webview). It embeds the Python backend and frontend in a standalone executable — zero Docker, zero command line.
> 🚧 **Version 2.0.0 — binaries are being stabilized.** For now, building from source is recommended.
@@ -686,6 +722,8 @@ Lifecycle: Tauri spawns the Python backend → health check → opens the webvie
## 👥 Real-time Collaboration
> 📖 Full guide: [Editing & Collaboration](docs/GUIDES/COLLABORATION.md)
Multiple users can edit the same markdown document simultaneously (Google Docs style):
- **Conflict-free merge** via Yjs (CRDT): two people can type in the same place, no change is lost.
@@ -702,6 +740,8 @@ No configuration is required: open the same file in two browsers (or two windows
## 🔌 API
> 📖 Full guide: [REST API](docs/GUIDES/API_REST.md) · [MCP Server](docs/GUIDES/MCP.md)
ObsiGate exposes a complete REST API :
| Endpoint | Description | Method | Auth |
@@ -729,6 +769,7 @@ ObsiGate exposes a complete REST API :
| `/api/events` | Real-time SSE stream | GET | Yes |
| `/api/vaults/add` / `/api/vaults/{name}` | Dynamic vault management | POST/DELETE | Admin |
| `/api/image/{vault}?path=` | Serve an image | GET | Yes |
| `/api/media/{vault}/thumb?path=&size=` | WebP thumbnail (disk cache) | GET | Yes |
| `/api/config` | Read / write configuration | GET/POST | Yes/Admin |
| `/api/diagnostics` | Index and memory statistics | GET | Admin |
@@ -762,6 +803,8 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
## 🔍 Advanced Search
> 📖 Full guide: [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
### Query Syntax
| Operator | Description | Example |
@@ -914,6 +957,8 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
## 🛡️ Security
> 📖 Full guide: [Auth & Security](docs/GUIDES/AUTHENTIFICATION_SECURITE.md)
- **Path traversal** : All file endpoints validate that the resolved path stays within the vault
- **Rate limiting** : 10 login attempts max per IP over 15 minutes + per-account lockout (5 attempts)
- **Audit log** : All writes, deletions, and config changes are logged in `data/audit.log` (JSON lines, 10 MB rotation)
@@ -997,7 +1042,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
| Frontend import validation | `node tests/frontend/validate-imports.mjs` | `lint` |
| Frontend unit tests | `node tests/frontend/unit.test.mjs` | `lint` |
| Backend tests | `pytest tests/ -q` | `test` |
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
#### Local E2E Tests (`npm run test:e2e`)
@@ -1020,6 +1065,14 @@ bash scripts/run-e2e-local.sh --headed # visible browser
bash scripts/run-e2e-local.sh -g "reset panes" # filter on a test
```
On Windows, when `bash` is unusable (WSL unavailable, git-bash blocked by an
Application Control policy), use the equivalent PowerShell launcher:
```powershell
npm run test:e2e:ps
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
```
The suite must end with **all tests passing** (60 currently), with no failure
or reliance on retries. In case of failure: fix and re-run locally until 100 %,
then only commit.
@@ -1069,7 +1122,9 @@ ObsiGate/
├── Dockerfile # Multi-stage, healthcheck, non-root
├── docker-compose.yml # Deployment with healthcheck and auth env vars
├── build.sh # Automated build & deployment (docker compose build + up)
└── docs/CONTRIBUTING.md # Contribution guide
└── docs/
├── GUIDES/ # User guides (getting started, API, MCP, desktop…)
└── CONTRIBUTING.md # Contribution guide
```
### Contributing
@@ -1095,8 +1150,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.11.3).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.25.0).
---
*Project: ObsiGate | Version: 2.11.3 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.25.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.11.3
2.25.0
+121 -71
View File
@@ -28,6 +28,7 @@ from backend.tools.api import (
ToolError,
ToolScope,
call_tool,
get_tool,
get_tool_schemas,
)
from backend.tools.labels import thought_step_label, tool_step_label
@@ -121,12 +122,15 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]:
"""Answer a tool call that was not reached because the run stopped early.
A single LLM response may carry several tool calls. When one of them is
mutating and pauses the run for confirmation, the assistant message already
lists *all* of them, so every ``tool_call_id`` must get a tool result before
the next LLM call (the OpenAI tool protocol rejects dangling ids). The calls
that were not reached get a synthetic ``deferred`` result; the model
re-issues them once the confirmed call has been applied (BUG-050).
A single LLM response may carry several tool calls; when the run stops
before reaching some of them (tool-call quota), the assistant message still
lists *all* of them, so every ``tool_call_id`` must get a tool result
before the next LLM call (the OpenAI tool protocol rejects dangling ids).
The calls that were not reached get a synthetic ``deferred`` result.
Note: mutating calls that pause the run for confirmation are no longer
deferred — they are batched and applied together on resume (BUG-075); this
helper remains for budget stops (BUG-050/BUG-052).
"""
return {
"role": "tool",
@@ -135,13 +139,29 @@ def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, An
"content": json.dumps({
"status": "deferred",
"reason": reason or (
"Not executed: the run paused to confirm an earlier tool call. "
"Not executed: the run stopped before reaching this tool call. "
"Re-issue this call if it is still needed."
),
}, ensure_ascii=False),
}
def _action_descriptor(call: Any) -> dict[str, Any]:
"""Describe one paused mutating tool call for the confirmation payload.
A single LLM response may request several mutations (create a folder and
the files inside it…). They are batched into one confirmation so the user
approves the whole plan in one click (BUG-075). ``step`` reuses the
Notion-style label, so the confirmation card reads like the steps block.
"""
return {
"id": call.id,
"tool": call.name,
"arguments": call.arguments,
"step": tool_step_label(call.name, call.arguments),
}
def _fallback_summary(executed: list[ToolCallRecord]) -> str:
"""Deterministic non-empty answer built from the gathered tool results.
@@ -212,53 +232,66 @@ def _execute_confirmed(
executed: list[ToolCallRecord],
on_tool_call: Callable[[ToolCallRecord], None] | None,
) -> None:
"""Apply a previously-paused mutating tool call and feed its result back.
"""Apply previously-paused mutating tool calls and feed their results back.
The pending payload is the ``error`` object emitted by a ``confirmation``
event. The assistant tool-call message is expected to already be in
event, optionally carrying an ``actions`` list with every mutating call of
the LLM turn (BUG-075). Each action is applied with a one-shot confirmation
and its ``tool_call_id`` answered, keeping the conversation valid for the
resumed turn. The assistant tool-call message is expected to already be in
``convo`` (it is part of the snapshot returned with the confirmation).
"""
from backend.ai_chat import ToolCall
error = confirm_pending.get("error", confirm_pending)
name = error.get("tool")
arguments = error.get("arguments") or {}
call_id = error.get("id") or "call_pending"
error = confirm_pending.get("error", confirm_pending) or {}
actions = confirm_pending.get("actions")
if not isinstance(actions, list) or not actions:
# Legacy single-action payload (no ``actions`` list).
actions = [{
"id": error.get("id") or "call_pending",
"tool": error.get("tool"),
"arguments": error.get("arguments") or {},
}]
if not name:
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
for action in actions:
name = action.get("tool")
arguments = action.get("arguments") or {}
call_id = action.get("id") or "call_pending"
# Make sure the assistant tool-call message is present in the snapshot.
if not any(
m.get("role") == "assistant" and any(
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
if not name:
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
# Make sure the assistant tool-call message is present in the snapshot.
if not any(
m.get("role") == "assistant" and any(
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
)
for m in convo
):
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
try:
result = call_tool(name, ctx, arguments, confirm=True)
payload = result.data
ok = True
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=name, arguments=arguments, ok=ok, result=payload,
step=tool_step_label(name, arguments),
)
for m in convo
):
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
executed.append(record)
if on_tool_call is not None:
on_tool_call(record)
try:
result = call_tool(name, ctx, arguments, confirm=True)
payload = result.data
ok = True
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=name, arguments=arguments, ok=ok, result=payload,
step=tool_step_label(name, arguments),
)
executed.append(record)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
convo.append({
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
async def run_agent(
@@ -315,6 +348,36 @@ async def run_agent(
convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)]
executed: list[ToolCallRecord] = []
def _run_call(call: Any) -> None:
"""Execute one tool call, record it and answer its ``tool_call_id``.
``ToolConfirmationRequired`` propagates to the caller so the loop can
pause and batch the mutating calls of the turn (BUG-075).
"""
try:
result = call_tool(call.name, ctx, call.arguments)
payload: Any = result.data
ok = True
except ToolConfirmationRequired:
raise
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=call.name, arguments=call.arguments, ok=ok, result=payload,
step=tool_step_label(call.name, call.arguments),
)
executed.append(record)
steps.append(record.step)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call.id,
"name": call.name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
if confirm_pending:
if quota is not None and len(executed) >= quota:
return AgentResult(
@@ -357,19 +420,25 @@ async def run_agent(
llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED
)
try:
result = call_tool(call.name, ctx, call.arguments)
payload = result.data
ok = True
_run_call(call)
except ToolConfirmationRequired as e:
logger.info(f"Agent paused: confirmation required for '{call.name}'")
pending = e.to_dict()
# Include the tool-call id so the client can echo it back.
pending["error"]["id"] = call.id
# BUG-050: the assistant message lists every tool call of this
# batch, so answer the ones we did not reach to keep the
# conversation valid for the resumed turn.
for skipped in response.tool_calls[index + 1:]:
convo.append(_deferred_tool_message(skipped))
# BUG-075: batch every mutating call of this LLM turn so the
# user approves the whole plan at once (one resume applies them
# all) instead of approving one action after another. Read-only
# calls of the batch run immediately and answer their
# ``tool_call_id`` so the resumed turn stays valid.
actions = [_action_descriptor(call)]
for after in response.tool_calls[index + 1:]:
spec = get_tool(after.name)
if spec is not None and spec.requires_confirmation:
actions.append(_action_descriptor(after))
else:
_run_call(after)
pending["actions"] = actions
return AgentResult(
content=response.content or "",
messages=convo,
@@ -379,25 +448,6 @@ async def run_agent(
stopped=STOP_CONFIRMATION_REQUIRED,
pending=pending,
)
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=call.name, arguments=call.arguments, ok=ok, result=payload,
step=tool_step_label(call.name, call.arguments),
)
executed.append(record)
steps.append(record.step)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call.id,
"name": call.name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
logger.warning(f"Agent reached max iterations ({max_iterations})")
return await _finalize_answer(
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

+2 -3
View File
@@ -4,10 +4,9 @@ import threading
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.attachment_indexer")
from backend.media_types import IMAGE_EXTENSIONS
# Image file extensions to index
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
logger = logging.getLogger("obsigate.attachment_indexer")
# Global attachment index: {vault_name: {filename_lower: [absolute_path, ...]}}
attachment_index: dict[str, dict[str, list[Path]]] = {}
+175 -16
View File
@@ -7,6 +7,7 @@ import json
import logging
import os
import secrets
import threading
import time
import uuid
from pathlib import Path
@@ -23,6 +24,22 @@ ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_SECONDS = int(os.environ.get("OBSIGATE_ACCESS_TOKEN_TTL", "3600")) # default 1 hour
REFRESH_TOKEN_EXPIRE_SECONDS = int(os.environ.get("OBSIGATE_REFRESH_TOKEN_TTL", "604800")) # default 7 days
#: Persistent API/MCP access tokens (user-managed, shown in the config panel).
API_TOKENS_FILE = Path("data/api_tokens.json")
#: Accepted values for the expiry selector in the UI (1 day, 1 month, 6 months,
#: 1 year, never). "never" → no ``exp`` claim → token valid until revoked.
API_TOKEN_EXPIRY_CHOICES = {
"1d": 24 * 3600,
"30d": 30 * 24 * 3600,
"180d": 180 * 24 * 3600,
"365d": 365 * 24 * 3600,
"never": None,
}
#: Max active tokens per user (anti hoarding; revoking frees a slot).
API_TOKEN_MAX_PER_USER = 50
#: AES-GCM key derived once from the JWT secret to encrypt stored tokens.
_API_TOKEN_KEY: bytes | None = None
# In-memory revoked token set (loaded from disk on startup)
_revoked_jtis: set = set()
_revoked_loaded = False
@@ -92,43 +109,61 @@ def decode_token(token: str) -> dict | None:
# ---------------------------------------------------------------------------
# Token revocation
# ---------------------------------------------------------------------------
# The store is a dict {jti: valid_until}: the revocation record may be dropped
# once the underlying token's own expiry has passed (by then the JWT is dead
# anyway). Long-lived API/MCP tokens (see create_api_token) must therefore be
# revoked with their real expiry — a 1-year token revoked last week must not
# silently come back to life when a 7-day cleanup purges the record (BUG in
# the previous set-based store, fixed with feature #107).
_revoked_map: dict[str, int] = {}
_revoked_loaded = False
def _load_revoked():
"""Load revoked token JTIs from disk into memory (once)."""
global _revoked_loaded, _revoked_jtis
global _revoked_loaded, _revoked_map
if _revoked_loaded:
return
if REVOKED_TOKENS_FILE.exists():
try:
data = json.loads(REVOKED_TOKENS_FILE.read_text())
# Clean expired entries (older than 7 days)
# Drop entries whose underlying token has itself expired.
now = int(time.time())
_revoked_jtis = {
jti for jti, exp in data.items()
if exp > now
_revoked_map = {
jti: int(exp) for jti, exp in data.items()
if int(exp) > now
}
except Exception as e:
logger.warning(f"Failed to load revoked tokens: {e}")
_revoked_jtis = set()
_revoked_map = {}
_revoked_loaded = True
def _save_revoked():
"""Persist revoked JTIs to disk."""
"""Persist revoked JTIs to disk with their per-token expiry."""
REVOKED_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True)
# Store with expiry timestamp for cleanup
now = int(time.time())
# Keep entries for 7 days max
data = {jti: now + REFRESH_TOKEN_EXPIRE_SECONDS for jti in _revoked_jtis}
tmp = REVOKED_TOKENS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data))
tmp.write_text(json.dumps(_revoked_map))
tmp.replace(REVOKED_TOKENS_FILE)
def revoke_token(jti: str):
"""Add a token JTI to the revocation list."""
def revoke_token(jti: str, expires_at: int | None = None):
"""Add a token JTI to the revocation list.
``expires_at`` is the revoked token's own ``exp`` (unix seconds) — the
record is kept at least that long so a long-lived API token cannot
outlive its revocation. ``None`` means the token never expires (API/MCP
"sans fin") → the record is kept forever (capped at ~100 years, the JWT
store's practical infinity). Default keeps 7 days (session tokens).
"""
_load_revoked()
_revoked_jtis.add(jti)
now = int(time.time())
if expires_at is None:
until = now + 100 * 365 * 24 * 3600
else:
until = max(int(expires_at), now + REFRESH_TOKEN_EXPIRE_SECONDS)
_revoked_map[jti] = until
_save_revoked()
logger.debug(f"Revoked token JTI: {jti[:8]}...")
@@ -136,4 +171,128 @@ def revoke_token(jti: str):
def is_token_revoked(jti: str) -> bool:
"""Check if a token JTI has been revoked."""
_load_revoked()
return jti in _revoked_jtis
return jti in _revoked_map
# ---------------------------------------------------------------------------
# API / MCP tokens (feature #107)
# ---------------------------------------------------------------------------
# Long-lived access tokens the user creates from the config panel. They are
# plain HS256 access-type JWTs (``api: true`` claim), so they authenticate
# against BOTH the REST API and the MCP endpoint (/mcp) — which share
# ``get_current_user``. The raw token is shown exactly once at creation; the
# store keeps metadata only (name, owner, expiry, last use) — no secret
# material is written to disk.
#
# File: data/api_tokens.json
# {"version": 1, "tokens": {jti: {name, username, created_at, expires_at, last_used_at}}}
_api_tokens_lock = threading.RLock()
_touch_last_write: dict[str, float] = {}
def _load_api_tokens() -> dict:
if not API_TOKENS_FILE.exists():
return {"version": 1, "tokens": {}}
try:
return json.loads(API_TOKENS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.error(f"Failed to read api_tokens.json: {e}")
return {"version": 1, "tokens": {}}
def _save_api_tokens(data: dict):
API_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = API_TOKENS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
tmp.replace(API_TOKENS_FILE)
def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]:
"""Create a persistent API/MCP token. Returns (record, jwt_string).
``expiry_key`` must be one of API_TOKEN_EXPIRY_CHOICES; "never" omits the
``exp`` claim (valid until explicitly revoked).
"""
if expiry_key not in API_TOKEN_EXPIRY_CHOICES:
raise ValueError("Expiration invalide")
seconds = API_TOKEN_EXPIRY_CHOICES[expiry_key]
with _api_tokens_lock:
data = _load_api_tokens()
tokens = data["tokens"]
mine = sum(1 for t in tokens.values() if t["username"] == user["username"])
if mine >= API_TOKEN_MAX_PER_USER:
raise ValueError(f"Maximum {API_TOKEN_MAX_PER_USER} tokens par utilisateur")
now = int(time.time())
jti = str(uuid.uuid4())
payload = {
"sub": user["username"],
"role": user.get("role", "user"),
"vaults": user.get("vaults", []),
"jti": jti,
"iat": now,
"type": "access",
"api": True,
}
if seconds is not None:
payload["exp"] = now + seconds
token = jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM)
record = {
"jti": jti,
"name": name[:64] or "API token",
"username": user["username"],
"created_at": now,
"expires_at": payload.get("exp"),
"expiry_key": expiry_key,
"last_used_at": None,
}
tokens[jti] = record
_save_api_tokens(data)
return record, token
def list_api_tokens(username: str) -> list[dict]:
"""Token metadata for one user, newest first."""
data = _load_api_tokens()
now = int(time.time())
items = [
{**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
for t in data["tokens"].values()
if t["username"] == username
]
return sorted(items, key=lambda t: t["created_at"], reverse=True)
def delete_api_token(jti: str, username: str) -> dict:
"""Revoke and remove an API token. Raises KeyError when unknown/not owned."""
with _api_tokens_lock:
data = _load_api_tokens()
record = data["tokens"].get(jti)
if not record or record["username"] != username:
raise KeyError(jti)
# Revoke by jti so the presented JWT stops working even though it is
# stateless — kept until its natural expiry (no-expiry → forever).
revoke_token(jti, record.get("expires_at"))
del data["tokens"][jti]
_save_api_tokens(data)
return record
def maybe_touch_api_token(jti: str | None, created_or_expires: bool = False):
"""Record last usage of an API token, throttled to one disk write/hour."""
if not jti:
return
now = time.time()
if now - _touch_last_write.get(jti, 0) < 3600:
return
_touch_last_write[jti] = now
try:
with _api_tokens_lock:
data = _load_api_tokens()
record = data["tokens"].get(jti)
if record is None:
return
record["last_used_at"] = int(now)
_save_api_tokens(data)
except Exception as e: # never fail an authenticated request over stats
logger.debug(f"api_token touch failed: {e}")
+5 -1
View File
@@ -11,7 +11,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend.services.net import get_client_ip
from .jwt_handler import decode_token, is_token_revoked
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
from .user_store import get_user
logger = logging.getLogger("obsigate.auth.middleware")
@@ -115,6 +115,10 @@ def get_current_user(
user["_token_vaults"] = payload.get("vaults", [])
# Attach the token id for per-token rate limiting (AI tool layer).
user["_token_jti"] = payload.get("jti")
# Feature #107: track last usage of user-managed API/MCP tokens
# (throttled write — this dependency runs on both REST and /mcp paths).
if payload.get("api"):
maybe_touch_api_token(payload.get("jti"))
# BUG-030: expose the real client IP to the audit log.
user["_request_ip"] = get_client_ip(request)
return user
+145 -13
View File
@@ -2,6 +2,8 @@
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
# and admin user CRUD.
import base64
import binascii
import logging
import re
@@ -17,10 +19,14 @@ from backend.services.net import get_client_ip
from .jwt_handler import (
ACCESS_TOKEN_EXPIRE_SECONDS,
API_TOKEN_EXPIRY_CHOICES,
create_access_token,
create_api_token,
create_refresh_token,
decode_token,
delete_api_token,
is_token_revoked,
list_api_tokens,
revoke_token,
)
from .mfa import (
@@ -105,6 +111,43 @@ class UpdateUserRequest(BaseModel):
return validate_password_strength(v)
# ── Profile avatar (#113) ───────────────────────────────────────────
#: Avatar data-URL pattern — PNG/JPEG/WebP only (no SVG: XSS surface).
_AVATAR_DATA_URL_RE = re.compile(
r"^data:image/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$"
)
#: ~300 KB of base64 payload (a 256px JPEG is ~15 KB; generous headroom).
_AVATAR_MAX_CHARS = 400_000
def _validate_avatar(data_url: str) -> str | None:
"""Validate an avatar data-URL for storage on the user profile.
Returns the normalized data-URL, or ``None`` when clearing the avatar
(empty string). Raises ``HTTPException(400)`` on anything else.
"""
if data_url == "":
return None
if len(data_url) > _AVATAR_MAX_CHARS:
raise HTTPException(400, "Avatar image too large")
if not _AVATAR_DATA_URL_RE.match(data_url):
raise HTTPException(400, "Avatar must be a PNG, JPEG or WebP data URL")
try:
raw = base64.b64decode(data_url.split(",", 1)[1], validate=True)
except (ValueError, binascii.Error) as exc: # pragma: no cover — regex guards
raise HTTPException(400, "Avatar payload is not valid base64") from exc
# Confirm the decoded bytes really are a supported image (magic numbers).
is_png = raw.startswith(b"\x89PNG\r\n\x1a\n")
is_jpeg = raw.startswith(b"\xff\xd8\xff")
is_webp = (
len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP"
)
if not (is_png or is_jpeg or is_webp):
raise HTTPException(400, "Avatar payload is not a PNG, JPEG or WebP image")
return data_url
# ── Public endpoints ──────────────────────────────────────────────────
@router.get("/status")
@@ -217,6 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
"display_name": user["display_name"],
"role": user["role"],
"vaults": user["vaults"],
"avatar": user.get("avatar"),
},
}
@@ -339,6 +383,7 @@ async def get_me(current_user=Depends(require_auth)):
"vaults": current_user["vaults"],
"language": current_user.get("language", "fr"),
"last_login": current_user.get("last_login"),
"avatar": current_user.get("avatar"),
}
@@ -346,19 +391,23 @@ class UpdateMeRequest(BaseModel):
"""Fields the user can update on their own profile."""
display_name: str | None = None
language: str | None = None
#: Image data-URL (PNG/JPEG/WebP), or ``""`` to remove the avatar (#113).
avatar: str | None = None
@router.patch("/me")
async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
"""Update current user's profile fields (display_name, language)."""
"""Update current user's profile fields (display_name, language, avatar)."""
from .user_store import update_user
updates = {}
updates: dict[str, object] = {}
if req.display_name is not None:
updates["display_name"] = req.display_name
if req.language is not None:
if req.language not in ("fr", "en"):
raise HTTPException(400, "language must be 'fr' or 'en'")
updates["language"] = req.language
if req.avatar is not None:
updates["avatar"] = _validate_avatar(req.avatar)
if not updates:
raise HTTPException(400, "No fields to update")
updated = update_user(current_user["username"], updates)
@@ -369,6 +418,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
"vaults": updated["vaults"],
"language": updated.get("language", "fr"),
"last_login": updated.get("last_login"),
"avatar": updated.get("avatar"),
}
@@ -444,7 +494,9 @@ class MfaEnableRequest(BaseModel):
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.
Returns the secret, the otpauth URI and a ready-to-display QR code
(`qr_data_url`, SVG `data:` URI — no third-party service, CSP-safe).
Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify.
"""
from .user_store import update_user
@@ -454,10 +506,21 @@ async def mfa_totp_setup(current_user=Depends(require_auth)):
update_user(current_user["username"], {
"mfa_secret_pending": secret,
})
# BUG-068: the QR code is generated locally (segno, stdlib-free SVG data
# URI). The previous client-side https://api.qrserver.com image was blocked
# by the CSP (img-src 'self' data: blob:) and leaked the otpauth URI —
# including the TOTP secret — to a third party.
qr_data_url: str | None = None
try:
import segno
qr_data_url = segno.make(qr_uri).svg_data_uri(scale=5)
except Exception:
qr_data_url = None
return {
"secret": secret,
"qr_uri": qr_uri,
"otpauth_uri": qr_uri,
"qr_data_url": qr_data_url,
}
@@ -559,18 +622,25 @@ class WebauthnRemoveRequest(BaseModel):
@router.post("/mfa/webauthn/register/options")
async def mfa_webauthn_register_options(current_user=Depends(require_auth)):
async def mfa_webauthn_register_options(request: Request,
current_user=Depends(require_auth)):
"""Start WebAuthn key enrolment — returns publicKey creation options for the browser."""
from .webauthn_mfa import begin_registration
from .webauthn_mfa import begin_registration, resolve_relying_party
# BUG-070: rp_id/origins derive from the request (exact host incl. port)
# unless explicitly configured — the old localhost defaults rejected
# every real access URL ("Unexpected client data origin").
rp, _ = resolve_relying_party(request)
options = begin_registration(current_user["username"],
current_user.get("display_name", ""))
current_user.get("display_name", ""),
rp_id_override=rp)
return {"options": options}
@router.post("/mfa/webauthn/register")
async def mfa_webauthn_register(
req: WebauthnRegisterRequest,
request: Request,
current_user=Depends(require_auth),
):
"""Verify the created credential, store it, and enable MFA if not already on.
@@ -580,14 +650,17 @@ async def mfa_webauthn_register(
from datetime import datetime, timezone
from .user_store import get_user, update_user
from .webauthn_mfa import complete_registration
from .webauthn_mfa import complete_registration, resolve_relying_party
user = get_user(current_user["username"])
if user is None:
raise HTTPException(404, "Utilisateur introuvable")
rp, origins = resolve_relying_party(request)
try:
record = complete_registration(current_user["username"], req.credential,
label=req.label)
label=req.label,
rp_id_override=rp,
origins_override=origins)
except ValueError as e:
raise HTTPException(400, str(e))
except Exception as e:
@@ -666,7 +739,7 @@ async def mfa_webauthn_remove(
@router.post("/mfa/webauthn/options")
async def mfa_webauthn_login_options(body: dict = Body(...)):
async def mfa_webauthn_login_options(request: Request, 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
@@ -678,8 +751,9 @@ async def mfa_webauthn_login_options(body: dict = Body(...)):
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)
from .webauthn_mfa import begin_authentication, resolve_relying_party
rp, _ = resolve_relying_party(request)
options = begin_authentication(username, creds, rp_id_override=rp)
if options is None:
return {"mfa_method": "totp", "options": None}
return {"mfa_method": "webauthn", "options": options}
@@ -693,7 +767,7 @@ async def mfa_webauthn_verify(
):
"""Unauthenticated: verify the WebAuthn assertion and issue JWT tokens."""
from .user_store import get_user, update_user
from .webauthn_mfa import complete_authentication
from .webauthn_mfa import complete_authentication, resolve_relying_party
client_ip = _enforce_mfa_rate_limit(request, body.username)
@@ -704,13 +778,16 @@ async def mfa_webauthn_verify(
if not user.get("mfa_enabled"):
raise HTTPException(400, "MFA non activé pour cet utilisateur")
rp, origins = resolve_relying_party(request)
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)
new_count = complete_authentication(body.username, body.credential, stored,
rp_id_override=rp,
origins_override=origins)
except ValueError as e:
_record_mfa_failure(client_ip, body.username)
raise HTTPException(401, str(e))
@@ -861,3 +938,58 @@ async def delete_user_endpoint(
return {"message": f"Utilisateur '{username}' supprimé"}
except ValueError as e:
raise HTTPException(404, str(e))
# ── API / MCP tokens (feature #107) ──────────────────────────────────
# One long-lived token authenticates BOTH the REST API and the MCP
# endpoint (/mcp): the MCP server resolves the caller through the same
# get_current_user() dependency, so the same Bearer JWT works everywhere.
class CreateApiTokenRequest(BaseModel):
name: str
expiry: str # 1d | 30d | 180d | 365d | never
@router.get("/tokens")
async def list_user_tokens(current_user=Depends(require_auth)):
"""List the caller's API/MCP tokens (metadata only — the secret is never stored)."""
return {
"tokens": list_api_tokens(current_user["username"]),
"expiry_choices": list(API_TOKEN_EXPIRY_CHOICES.keys()),
}
@router.post("/tokens")
async def create_user_token(
req: CreateApiTokenRequest,
request: Request,
current_user=Depends(require_auth),
):
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE."""
try:
record, token = create_api_token(current_user, req.name.strip(), req.expiry)
except ValueError as e:
raise HTTPException(400, str(e))
from backend.audit import log_config_change
log_config_change(current_user["username"],
{"action": "api_token_create", "name": record["name"],
"expiry": record["expiry_key"]}, ip=get_client_ip(request))
return {"token": token, **record}
@router.delete("/tokens/{jti}")
async def delete_user_token(
jti: str,
request: Request,
current_user=Depends(require_auth),
):
"""Revoke + delete an API/MCP token (immediate effect on API and MCP)."""
try:
record = delete_api_token(jti, current_user["username"])
except KeyError:
raise HTTPException(404, "Token introuvable")
from backend.audit import log_config_change
log_config_change(current_user["username"],
{"action": "api_token_revoke", "name": record["name"]},
ip=get_client_ip(request))
return {"message": f"Token '{record['name']}' révoqué"}
+1
View File
@@ -96,6 +96,7 @@ def create_user(
"vaults": vaults or [],
"active": True,
"language": "fr", # default UI language
"avatar": None, # profile picture data-URL (#113)
"created_at": datetime.now(timezone.utc).isoformat(),
"password_changed_at": datetime.now(timezone.utc).timestamp(),
"last_login": None,
+157 -36
View File
@@ -38,8 +38,16 @@ 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]] = {}
# How many outstanding challenges to keep per key. BUG-070: a single slot made
# the flow fragile — a double-click on "add key" (or any retry) overwrote the
# pending challenge and the in-flight ceremony failed with
# "Client data challenge was not expected challenge". The verifier now accepts
# any recent challenge for the key.
MAX_PENDING_PER_KEY = 5
# In-memory pending challenges: key -> [(challenge_bytes, expires_at), ...]
# (newest last)
_pending: dict[str, list[tuple[bytes, float]]] = {}
def rp_id() -> str:
@@ -55,24 +63,100 @@ def expected_origins() -> list[str]:
return [o.strip() for o in raw.split(",") if o.strip()]
def resolve_relying_party(request: Any = None) -> tuple[str, list[str]]:
"""Resolve the WebAuthn (rp_id, expected_origins) for a ceremony.
BUG-070: the previous defaults (rp_id ``localhost``, origins
``http://localhost``) rejected every real-world access URL — any port
(``http://localhost:2020``), ``127.0.0.1``, a LAN host or a public domain
failed verification with "Unexpected client data origin".
Explicit configuration still wins: when ``OBSIGATE_WEBAUTHN_RP_ID`` /
``OBSIGATE_WEBAUTHN_ORIGINS`` are set they are used unchanged. Otherwise
the values are derived from the incoming request (exact ``Host``, port
included, since the browser origin carries non-default ports).
Behind a reverse proxy the external host/proto come from
``X-Forwarded-Host`` / ``X-Forwarded-Proto``, honored only when
``OBSIGATE_TRUST_PROXY=true`` (same rule as ``get_client_ip``).
"""
env_rp = os.environ.get("OBSIGATE_WEBAUTHN_RP_ID")
env_raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS")
if request is None:
return (env_rp or "localhost",
[o.strip() for o in env_raw.split(",") if o.strip()]
if env_raw else ["http://localhost"])
from backend.services.net import is_trusted_proxy
if is_trusted_proxy():
fwd_host = request.headers.get("x-forwarded-host", "")
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "")
fwd_proto = request.headers.get("x-forwarded-proto", "")
scheme = fwd_proto.split(",")[0].strip() or request.url.scheme
else:
host = request.headers.get("host", "")
scheme = request.url.scheme
if not host:
url = request.url
host = url.netloc or url.hostname or ""
scheme = scheme or url.scheme or "http"
rp = env_rp or _hostname_only(host) or "localhost"
if env_raw:
origins = [o.strip() for o in env_raw.split(",") if o.strip()]
else:
origins = [f"{scheme or 'http'}://{host}"] if host else ["http://localhost"]
return rp, origins
def _hostname_only(host: str) -> str:
"""Strip the port (and IPv6 brackets) from a Host header value."""
host = host.strip()
if host.startswith("["): # [::1]:8080 or [::1]
end = host.find("]")
return host[1:end] if end > 0 else host
if host.count(":") == 1:
name, _, port = host.partition(":")
return name if port.isdigit() else host
return host
def _prune_expired() -> None:
now = time.time()
for key in [k for k, (_, exp) in _pending.items() if exp < now]:
_pending.pop(key, None)
for key in list(_pending):
remaining = [(c, exp) for c, exp in _pending[key] if exp >= now]
if remaining:
_pending[key] = remaining
else:
_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)
slot = _pending.setdefault(key, [])
slot.append((challenge, time.time() + CHALLENGE_TTL_SECONDS))
del slot[:-MAX_PENDING_PER_KEY] # keep only the most recent ones
return challenge
def _take_challenge(key: str) -> bytes | None:
"""Pop a challenge (single-use). Returns None if missing/expired."""
"""Pop the newest challenge (single-use). Returns None if missing/expired."""
_prune_expired()
entry = _pending.pop(key, None)
return entry[0] if entry else None
slot = _pending.get(key)
if not slot:
return None
challenge, _ = slot.pop()
if not slot:
_pending.pop(key, None)
return challenge
def _take_all_challenges(key: str) -> list[bytes]:
"""Pop every outstanding challenge for *key* (newest last)."""
_prune_expired()
slot = _pending.pop(key, None)
return [c for c, _ in slot] if slot else []
def clear_pending(username: str) -> None:
@@ -83,9 +167,12 @@ def clear_pending(username: str) -> None:
# ── Registration (enrol a key in settings) ─────────────────────────────
def begin_registration(username: str, display_name: str) -> dict:
def begin_registration(username: str, display_name: str,
rp_id_override: str | None = None,
origins_override: list[str] | None = None) -> dict:
_ = origins_override # origins only matter at verification time
options = generate_registration_options(
rp_id=rp_id(),
rp_id=rp_id_override or rp_id(),
rp_name=rp_name(),
user_name=username,
user_display_name=display_name or username,
@@ -98,19 +185,44 @@ def begin_registration(username: str, display_name: str) -> dict:
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")
def _verify_with_any_challenge(key: str, verify_one: Any, empty_message: str) -> Any:
"""Run *verify_one(challenge)* against every outstanding challenge.
Returns the first success; re-raises the last error when all fail.
BUG-070: lets an in-flight ceremony survive a re-requested options call
(double-click / retry) that stored a newer challenge afterwards.
"""
challenges = _take_all_challenges(key)
if not challenges:
raise ValueError(empty_message)
last_error: Exception | None = None
for challenge in challenges:
try:
return verify_one(challenge)
except Exception as e: # try the next candidate challenge
last_error = e
assert last_error is not None
raise last_error
def complete_registration(username: str, credential_json: dict[str, Any],
label: str = "", rp_id_override: str | None = None,
origins_override: list[str] | None = None) -> dict:
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(),
)
effective_rp = rp_id_override or rp_id()
effective_origins = origins_override or expected_origins()
def _verify(challenge: bytes) -> Any:
return verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=effective_rp,
expected_origin=effective_origins,
)
verification = _verify_with_any_challenge(
f"{username}:register", _verify,
"Session d'enregistrement expirée — recommencez")
transports = credential.response.transports or []
label = (label or str(credential_json.get("label") or "")).strip() or "Security key"
@@ -126,9 +238,12 @@ def complete_registration(username: str, credential_json: dict[str, Any],
# ── Authentication (assertion at login) ────────────────────────────────
def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
def begin_authentication(username: str, credentials: list[dict],
rp_id_override: str | None = None,
origins_override: list[str] | None = None) -> dict | None:
if not credentials:
return None
_ = origins_override # origins only matter at verification time
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
allow = [
@@ -136,7 +251,7 @@ def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
for c in credentials
]
options = generate_authentication_options(
rp_id=rp_id(),
rp_id=rp_id_override or rp_id(),
challenge=_store_challenge(f"{username}:login"),
allow_credentials=allow,
)
@@ -147,21 +262,27 @@ def complete_authentication(
username: str,
credential_json: dict[str, Any],
stored: dict,
rp_id_override: str | None = None,
origins_override: list[str] | None = None,
) -> 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")
"""Verify an assertion. Returns the new sign_count. Raises on failure."""
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)),
)
effective_rp = rp_id_override or rp_id()
effective_origins = origins_override or expected_origins()
def _verify(challenge: bytes) -> Any:
return verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=effective_rp,
expected_origin=effective_origins,
credential_public_key=base64url_to_bytes(stored["public_key"]),
credential_current_sign_count=int(stored.get("sign_count", 0)),
)
verification = _verify_with_any_challenge(
f"{username}:login", _verify,
"Session expirée — rechargez la page")
return int(verification.new_sign_count)
+5
View File
@@ -15,6 +15,7 @@ import time
from pathlib import Path
from typing import Any
from backend.media_types import is_media
from backend.secret_redactor import redact_file_content
logger = logging.getLogger("obsigate.bookslm")
@@ -185,6 +186,10 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any
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()
# #109-D3 — audio/video (and images) carry no extractable text; never feed
# raw bytes to the model. Images are handled separately via vision data URLs.
if is_media(suffix):
return None
try:
if suffix == ".pdf":
from backend.pdf_reader import extract_pdf_text
+15 -3
View File
@@ -110,6 +110,12 @@ class BooksLMChatRequest(BaseModel):
description="Conversation snapshot returned alongside a ``confirmation`` event, "
"echoed back to resume the agent run.",
)
confirm_all: bool = Field(
default=False,
description="Global approval (BUG-075): apply every pending action of the batch "
"and auto-approve the remaining mutating calls of the same run, "
"so the run does not pause on each action.",
)
app_context: dict[str, Any] | None = Field(
default=None,
description="Live client UI state for the General assistant: open_documents, "
@@ -506,9 +512,11 @@ async def api_bookslm_agent(
Same context as ``/chat`` but the model may call tools (read/search the
vault) through the shared tool layer. Emits one ``tool`` event per executed
tool call, then a final ``message`` event. Mutating tools pause the run with
a ``confirmation`` event (two-step propose/apply) carrying the pending call
and the conversation snapshot; the client resumes by echoing them back in
``confirm`` / ``confirm_messages``.
a ``confirmation`` event (two-step propose/apply) carrying the pending
``actions`` (every mutating call of the turn) and the conversation snapshot;
the client resumes by echoing them back in ``confirm`` / ``confirm_messages``,
optionally with ``confirm_all`` to apply the whole batch and auto-approve the
rest of the run (BUG-075).
"""
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user, agent=True)
@@ -523,6 +531,10 @@ async def api_bookslm_agent(
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
if req.confirm_all:
# BUG-075: a single global approval authorizes the whole plan, so the
# run no longer pauses on every subsequent mutating call.
ctx.confirmed = True
async def _llm(msgs, tool_schemas):
return await chat_completion(
+545
View File
@@ -0,0 +1,545 @@
"""Génération du Guide d'utilisation téléchargeable en Markdown et PDF (#105).
Source unique de vérité : la modale ``#help-modal`` de ``frontend/index.html``
(comme dans l'application) + les blocs ``data-i18n`` résolus dans les locales
``frontend/locales/{fr,en}.json`` — le téléchargement reflète donc exactement
ce que voit l'utilisateur, dans sa langue.
Le Markdown est produit par un convertisseur HTML→MD minimal (stdlib) ; le
PDF passe par le moteur d'export existant (WeasyPrint) avec repli reportlab
quand les bibliothèques natives GTK manquent (Windows).
"""
from __future__ import annotations
import datetime
import hashlib
import html
import json
import logging
import re
from html.parser import HTMLParser
from pathlib import Path
logger = logging.getLogger("obsigate.guide")
ROOT = Path(__file__).resolve().parent.parent
INDEX_HTML = ROOT / "frontend" / "index.html"
LOCALES_DIR = ROOT / "frontend" / "locales"
VERSION_FILE = ROOT / "VERSION"
DIAGRAMS_DIR = ROOT / "backend" / "assets" / "guide_diagrams"
def diagram_png_for(code: str) -> Path | None:
"""Chemin du PNG pré-rendu (scripts/build_guide_diagrams.py) pour un code
Mermaid, ou None. Le hash doit rester synchrone avec le script de build :
sha1(unescape(code).strip())[:16]."""
normalized = html.unescape(code).strip()
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16]
png = DIAGRAMS_DIR / (sha + ".png")
return png if png.exists() else None
# Éléments décoratifs exclus des exports
_SKIP_CLASSES = {"help-hero-visual", "editor-modal", "help-nav"}
# En-tête HTML du guide (mode lecture)
_HEADER_BLOCK = "ObsiGate User Guide"
class Node:
"""Noeud DOM minimal (stdlib only)."""
__slots__ = ("attrs", "children", "parent", "tag")
def __init__(self, tag: str, attrs: dict[str, str | None], parent: Node | None = None):
self.tag = tag
self.attrs = attrs
self.children: list[Node | str] = []
self.parent = parent
def cls(self) -> str:
return self.attrs.get("class") or ""
def i18n(self) -> str | None:
v = self.attrs.get("data-i18n")
return v if isinstance(v, str) else None
def find_all(self, tag: str) -> list[Node]:
out: list[Node] = []
for c in self.children:
if isinstance(c, Node):
if c.tag == tag:
out.append(c)
out.extend(c.find_all(tag))
return out
_VOID_TAGS = {"br", "img", "hr", "input", "meta", "link"}
class _TreeBuilder(HTMLParser):
"""Constructeur d'arbre tolérant (ignore les balises orphelines)."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root = Node("#root", {})
self.cur = self.root
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
a = {k: v for k, v in attrs}
node = Node(tag, a, self.cur)
self.cur.children.append(node)
if tag not in _VOID_TAGS:
self.cur = node
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
a = {k: v for k, v in attrs}
self.cur.children.append(Node(tag, a, self.cur))
def handle_endtag(self, tag: str) -> None:
n: Node | None = self.cur
while n is not None and n.tag != tag:
n = n.parent
if n is not None and n.parent is not None:
self.cur = n.parent
def handle_data(self, data: str) -> None:
self.cur.children.append(data)
# ---------------------------------------------------------------------------
# Extraction / cache
# ---------------------------------------------------------------------------
_cache: dict[tuple[str, str], tuple[tuple[float, int, float, int], bytes]] = {}
def _read_index_html() -> str:
return INDEX_HTML.read_text(encoding="utf-8")
def _guide_fragment(index_html: str) -> str:
"""Le HTML de #help-modal…help-content jusqu'au footer du guide."""
start = index_html.index('id="help-modal"')
cstart = index_html.index('<div class="help-content">', start)
end = index_html.index('<div class="help-footer">', cstart)
return index_html[cstart:end]
def _locale_strings(lang: str) -> dict[str, str]:
path = LOCALES_DIR / (lang if lang in ("fr", "en") else "fr")
return json.loads(Path(path).with_suffix(".json").read_text(encoding="utf-8"))
def _signature() -> tuple[float, int, float, int]:
st = INDEX_HTML.stat()
lt = (LOCALES_DIR / "fr.json").stat()
return (st.st_mtime, st.st_size, lt.st_mtime, lt.st_size)
def _app_version() -> str:
try:
return VERSION_FILE.read_text(encoding="utf-8").strip() or "dev"
except OSError:
return "dev"
# ---------------------------------------------------------------------------
# Résolution i18n : un node portant data-i18n est REMPLACÉ par le contenu
# (HTML) de la locale — exactement comme _applyDOM() dans le navigateur.
# ---------------------------------------------------------------------------
def _resolve_i18n(node: Node, loc: dict[str, str]) -> list[Node | str]:
"""Retourne les children effectifs d'un node (locale si data-i18n[-html])."""
key = node.i18n() or node.attrs.get("data-i18n-html")
if not isinstance(key, str):
return node.children
value = loc.get(key)
if value is None:
# clé absente de la locale : garder le texte FR inline de index.html
return node.children
tb = _TreeBuilder()
tb.feed(f"<span>{value}</span>")
span = tb.root.children[0]
assert isinstance(span, Node)
return span.children
# ---------------------------------------------------------------------------
# Markdown
# ---------------------------------------------------------------------------
_WS_RE = re.compile(r"[ \t]*\n[ \t]*")
def _collapse(text: str) -> str:
return _WS_RE.sub(" ", text).strip()
def _md_inline(node: Node | str, loc: dict[str, str]) -> str:
if isinstance(node, str):
return _collapse(node)
tag = node.tag
kids = _resolve_i18n(node, loc)
inner = "".join(_md_inline(c, loc) for c in kids)
if tag == "br":
return " "
if tag in ("strong", "b"):
t = inner.strip()
return f"**{t}**" if t else ""
if tag in ("em", "i"):
if node.cls().startswith("lucide") or tag == "i" and not inner.strip():
return ""
t = inner.strip()
return f"*{t}*" if t else ""
if tag == "code":
t = inner.replace("`", "'").strip()
return f"`{t}`" if t else ""
if tag == "kbd":
t = inner.strip()
return f"`{t}`" if t else ""
if tag == "a":
href = node.attrs.get("href") or ""
t = inner.strip()
if href.startswith("http") and t:
return f"[{t}]({href})"
return t
if tag == "img":
alt = node.attrs.get("alt") or ""
return f"![{alt}]"
return inner
def _md_block(node: Node | str, out: list[str], loc: dict[str, str], depth: int = 0) -> None:
"""Remplit ``out`` (bloc courant) — ``pending`` gère listes imbriquées."""
if isinstance(node, str):
t = _collapse(node)
if t:
out.append(t)
return
if any(c in node.cls().split() for c in _SKIP_CLASSES):
return
tag = node.tag
if tag == "pre":
raw = _pre_text(node)
lang = "mermaid" if "mermaid" in raw[:40] or "language-mermaid" in _pre_classes(node) else ""
out.append(f"```{lang}\n{raw.rstrip()}\n```")
return
kids = _resolve_i18n(node, loc)
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
level = int(tag[1])
text = _collapse("".join(_md_inline(c, loc) for c in kids))
if text:
out.append("#" * level + " " + text)
return
if tag == "p":
text = _collapse("".join(_md_inline(c, loc) for c in kids))
if text:
out.append(text)
return
if tag in ("ul", "ol"):
_md_list(kids, out, loc, tag, depth)
return
if tag == "table":
_md_table(node, out, loc)
return
# conteneurs neutres (section, div, span de bloc, li imbriqué…)
for c in kids:
_md_block(c, out, loc, depth)
def _md_list(items: list[Node | str], out: list[str], loc: dict[str, str], kind: str, depth: int) -> None:
n = 0
for li in items:
if isinstance(li, str):
continue
if li.tag == "li":
n += 1
marker = "- " if kind == "ul" else f"{n}. "
text_parts: list[str] = []
nested: list[Node] = []
for c in li.children:
if isinstance(c, Node) and c.tag in ("ul", "ol"):
nested.append(c)
else:
text_parts.append(_md_inline(c, loc))
line = _collapse("".join(text_parts))
if line:
out.append(" " * depth + marker + line)
for sub in nested:
_md_list(sub.children, out, loc, sub.tag, depth + 1)
elif li.tag in ("ul", "ol"):
_md_list(li.children, out, loc, li.tag, depth)
def _md_table(node: Node, out: list[str], loc: dict[str, str]) -> None:
rows = node.find_all("tr")
if not rows:
return
grid: list[list[str]] = []
for tr in rows:
cells = []
for td in tr.children:
if isinstance(td, Node) and td.tag in ("td", "th"):
cells.append(_collapse("".join(_md_inline(c, loc) for c in td.children)).replace("|", "\\|") or " ")
if cells:
grid.append(cells)
if not grid:
return
width = max(len(r) for r in grid)
grid = [r + [" "] * (width - len(r)) for r in grid]
out.append("| " + " | ".join(grid[0]) + " |")
out.append("|" + "|".join([" --- "] * width) + "|")
for r in grid[1:]:
out.append("| " + " | ".join(r) + " |")
def _pre_text(node: Node) -> str:
"""Texte brut préservé d'un <pre> (les locales n'y touchent pas)."""
buf: list[str] = []
def walk(n: Node | str) -> None:
if isinstance(n, str):
buf.append(n)
return
for c in n.children:
walk(c)
walk(node)
return "".join(buf).strip("\n")
def _pre_classes(node: Node) -> str:
cls = node.cls()
for c in node.find_all("code"):
cls += " " + c.cls()
return cls
def build_guide_markdown(lang: str = "fr") -> bytes:
"""Guide complet en Markdown (UTF-8), dans la langue demandée."""
index_html = _read_index_html()
loc = _locale_strings(lang)
tree = _TreeBuilder()
tree.feed(_guide_fragment(index_html))
root = tree.root.children[0]
assert isinstance(root, Node)
blocks: list[str] = []
content = _guide_title_fr if lang == "fr" else _guide_title_en
blocks.append("# " + content)
for section in root.find_all("section"):
_md_block(section, blocks, loc)
blocks.append(
"---\n\n"
+ _export_footer(lang)
)
md = "\n\n".join(b for b in blocks if b.strip()) + "\n"
return md.encode("utf-8")
_guide_title_fr = "Guide d'utilisation ObsiGate"
_guide_title_en = "ObsiGate User Guide"
def _export_footer(lang: str) -> str:
loc = _locale_strings(lang)
template = loc.get("guide105.export_footer", "")
if "%s" not in template and "{" not in template:
template = "ObsiGate {version}"
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
return _collapse(template).format(version=_app_version(), date=today)
# ---------------------------------------------------------------------------
# HTML (pour le PDF) — mêmes règles, sortie balisée propre
# ---------------------------------------------------------------------------
def _html_inline(node: Node | str, loc: dict[str, str]) -> str:
if isinstance(node, str):
return html.escape(_collapse(node), quote=False)
tag = node.tag
kids = _resolve_i18n(node, loc)
inner = "".join(_html_inline(c, loc) for c in kids)
if tag == "br":
return " "
if tag in ("strong", "b") and inner.strip():
return f"<strong>{inner}</strong>"
if tag in ("em",) and inner.strip():
return f"<em>{inner}</em>"
if tag == "code":
t = inner.strip()
return f"<code>{t}</code>" if t else ""
if tag == "kbd":
t = inner.strip()
return f"<code>{t}</code>" if t else ""
if tag == "a":
href = node.attrs.get("href") or ""
if href.startswith("http"):
return f'<a href="{html.escape(href, quote=True)}">{inner}</a>'
return inner
return inner
def _html_block(node: Node | str, out: list[str], loc: dict[str, str]) -> None:
if isinstance(node, str):
t = _collapse(node)
if t:
out.append(f"<p>{html.escape(t, quote=False)}</p>")
return
if any(c in node.cls().split() for c in _SKIP_CLASSES):
return
tag = node.tag
if tag == "pre":
raw = _pre_text(node)
classes = _pre_classes(node)
if "language-mermaid" in classes:
png = diagram_png_for(raw)
if png is not None:
url = "file:///" + str(png).replace("\\", "/").lstrip("/")
out.append(f'<img src="{url}" style="max-width: 100%" />')
return
out.append(f"<pre><code>{html.escape(raw, quote=False)}</code></pre>")
return
kids = _resolve_i18n(node, loc)
if tag in ("h2", "h3", "h4"):
text = _collapse("".join(_html_inline(c, loc) for c in kids))
if text:
out.append(f"<{tag}>{text}</{tag}>")
return
if tag == "p":
text = "".join(_html_inline(c, loc) for c in kids).strip()
if text:
out.append(f"<p>{text}</p>")
return
if tag in ("ul", "ol"):
out.append(_html_list(kids, loc, tag))
return
if tag == "table":
out.append(_html_table(node, loc))
return
for c in kids:
_html_block(c, out, loc)
def _html_list(items: list[Node | str], loc: dict[str, str], kind: str) -> str:
parts: list[str] = []
n = 0
for li in items:
if isinstance(li, str):
continue
if li.tag == "li":
n += 1
text_parts: list[str] = []
nested: list[Node] = []
for c in li.children:
if isinstance(c, Node) and c.tag in ("ul", "ol"):
nested.append(c)
else:
text_parts.append(_html_inline(c, loc))
line = "".join(text_parts).strip()
inner = line + "".join(_html_list(s.children, loc, s.tag) for s in nested)
if inner:
parts.append(f"<li>{inner}</li>")
elif li.tag in ("ul", "ol"):
parts.append(_html_list(li.children, loc, li.tag))
body = "".join(parts)
return f"<{kind}>{body}</{kind}>"
def _html_table(node: Node, loc: dict[str, str]) -> str:
rows_html: list[str] = []
for tr in node.find_all("tr"):
cells: list[str] = []
for td in tr.children:
if isinstance(td, Node) and td.tag in ("td", "th"):
tag = td.tag
inner = _collapse("".join(_html_inline(c, loc) for c in td.children))
cells.append(f"<{tag}>{inner}</{tag}>")
if cells:
rows_html.append("<tr>{}</tr>".format("".join(cells)))
return "<table>{}</table>".format("".join(rows_html))
def build_guide_html(lang: str = "fr") -> str:
"""Corps HTML autonome du guide (pour rendu PDF)."""
index_html = _read_index_html()
loc = _locale_strings(lang)
tree = _TreeBuilder()
tree.feed(_guide_fragment(index_html))
root = tree.root.children[0]
assert isinstance(root, Node)
blocks: list[str] = []
for section in root.find_all("section"):
_html_block(section, blocks, loc)
return "\n".join(blocks)
# ---------------------------------------------------------------------------
# PDF (WeasyPrint, repli reportlab)
# ---------------------------------------------------------------------------
def build_guide_pdf(lang: str = "fr") -> bytes:
lang_norm = lang if lang in ("fr", "en") else "fr"
title = _guide_title_fr if lang_norm == "fr" else _guide_title_en
loc = _locale_strings(lang_norm)
note = loc.get("guide105.arch_diagram_note", "")
footer = _export_footer(lang_norm)
try:
from backend.pdf_export import build_pdf_html, generate_pdf
body = build_guide_html(lang_norm)
body += (
f"<hr><p style='color:#777;font-size:11px'>{html.escape(note, quote=False)} — {html.escape(footer, quote=False)}</p>"
)
return generate_pdf(build_pdf_html(body, title), title)
except Exception as e: # WeasyPrint lève à l'import OU au rendu (GTK absent)
logger.warning("WeasyPrint indisponible pour le guide PDF (%s) — repli reportlab", e)
md = build_guide_markdown(lang_norm).decode("utf-8")
from backend.tools.documents import _render_reportlab_pdf
return _render_reportlab_pdf(md, title)
# ---------------------------------------------------------------------------
# Point d'entrée + cache
# ---------------------------------------------------------------------------
def get_guide_document(fmt: str, lang: str) -> tuple[bytes, str, str]:
"""Retourne (octets, media_type, filename) pour le format demandé.
``fmt`` : ``md`` | ``pdf``. Résultat mis en cache tant que index.html et
fr.json ne changent pas (les locales en ne divergent jamais sur les
structures ; la signature couvre l'essentiel).
"""
fmt = "pdf" if fmt == "pdf" else "md"
lang = "en" if lang == "en" else "fr"
key = (fmt, lang)
sig = _signature()
hit = _cache.get(key)
if hit and hit[0] == sig:
payload = hit[1]
else:
payload = build_guide_pdf(lang) if fmt == "pdf" else build_guide_markdown(lang)
_cache[key] = (sig, payload)
fname = f"ObsiGate-Guide-{_app_version()}-{lang}.{fmt}"
media = "application/pdf" if fmt == "pdf" else "text/markdown; charset=utf-8"
return payload, media, fname
+146 -31
View File
@@ -11,6 +11,8 @@ from typing import Any
import frontmatter
from backend.media_types import AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, is_media
logger = logging.getLogger("obsigate.indexer")
# Global in-memory index
@@ -69,7 +71,7 @@ SUPPORTED_EXTENSIONS = {
".dockerfile", ".makefile", ".cmake",
".excalidraw",
".excalidraw.md",
}
} | set(IMAGE_EXTENSIONS) | set(AUDIO_EXTENSIONS) | set(VIDEO_EXTENSIONS)
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
@@ -397,31 +399,52 @@ def parse_markdown_file(raw: str) -> frontmatter.Post:
return frontmatter.Post(content)
def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any]:
def _scan_vault(
vault_name: str,
vault_path: str,
vault_cfg: dict[str, Any] | None = None,
previous_files: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Synchronously scan a single vault directory and build file index.
Walks the vault tree, reads supported files, extracts metadata
(tags, title, content preview) and stores a capped content snapshot
for in-memory full-text search.
All files and directories are indexed, including hidden files (starting with '.').
Differential scan (#86): when ``previous_files`` maps a relative path to
its previous ``file_info`` dict, entries whose ``size`` and ``modified``
timestamp are unchanged are reused verbatim (no disk read, no re-parse).
Only the cheap ``os.walk`` + ``stat`` runs on every pass; heavy content
extraction (PDF metadata excepted — always cheap) is skipped for
unchanged files. This replaces the full ``rglob`` re-read on rebuilds.
Excalidraw diagrams (#86, like PDFs since BUG-040) are deferred: the scan
only records the title and sets ``excalidraw_text_pending``; the expensive
JSON/lz-string text extraction runs in ``enrich_pdf_texts()`` after the
index is queryable.
Args:
vault_name: Display name of the vault.
vault_path: Absolute filesystem path to the vault root.
vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility).
previous_files: Optional ``{relative_path: file_info}`` snapshot from a
previous scan used for differential reuse.
Returns:
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str), ``paths`` (list).
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str),
``paths`` (list) and ``reused`` (int, differential hits).
"""
vault_root = Path(vault_path)
files: list[dict[str, Any]] = []
tag_counts: dict[str, int] = {}
paths: list[dict[str, str]] = []
reused = 0
if not vault_root.exists():
logger.warning(f"Vault path does not exist: {vault_path}")
return {"files": [], "tags": {}, "path": vault_path, "paths": []}
return {"files": [], "tags": {}, "path": vault_path, "paths": [], "reused": 0}
root_resolved = vault_root.resolve(strict=False)
@@ -479,9 +502,37 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
# #86 differential scan: reuse the previous entry when neither
# size nor mtime changed — skips the disk read + parse below.
if previous_files:
prev = previous_files.get(rel_path_str)
if (
prev is not None
and prev.get("size") == stat.st_size
and prev.get("modified") == modified
):
file_info = {**prev, "tags": list(prev.get("tags", []))}
files.append(file_info)
for tag in file_info.get("tags", []):
tag_counts[tag] = tag_counts.get(tag, 0) + 1
reused += 1
# The global backlink index is rebuilt on every scan,
# so re-register this file's wikilinks from its
# (cached) content instead of re-reading the disk.
if file_info.get("extension") == ".md" and file_info.get("content"):
try:
_extract_wikilinks_for_backlinks(
vault_name, file_info["path"],
file_info.get("title", ""), file_info["content"],
)
except Exception:
pass
continue
# PDF handling — special path (binary, uses pdf_reader)
tags: list[str] = []
pdf_text_pending = False
excalidraw_text_pending = False
if ext == ".pdf":
from backend.pdf_reader import extract_pdf_metadata
# BUG-040: only the (cheap) metadata is read during the
@@ -494,10 +545,20 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
content_preview = ""
pdf_text_pending = True
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
raw = fpath.read_text(encoding="utf-8", errors="replace")
raw = extract_excalidraw_indexable(raw)
# #86: defer the expensive JSON/lz-string text extraction
# (read + decompress + element walk) to ``enrich_pdf_texts``
# so the scan stays cheap; title comes from the filename.
raw = ""
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
content_preview = ""
excalidraw_text_pending = True
elif is_media(ext):
# #108 — images (and future media, #109) are binary: index
# name/size/mtime only and never read the bytes. ``content``
# stays empty so the TF-IDF index remains clean.
raw = ""
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = ""
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
title = fpath.stem.replace("-", " ").replace("_", " ")
@@ -528,6 +589,8 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
}
if pdf_text_pending:
file_info["pdf_text_pending"] = True
if excalidraw_text_pending:
file_info["excalidraw_text_pending"] = True
files.append(file_info)
for tag in tags:
@@ -540,28 +603,49 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
logger.error(f"Error indexing {fpath}: {e}")
continue
logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(paths)} paths, {len(tag_counts)} unique tags")
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}}
logger.info(
f"Vault '{vault_name}': indexed {len(files)} files "
f"({reused} reused), {len(paths)} paths, {len(tag_counts)} unique tags"
)
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}, "reused": reused}
def _read_excalidraw_indexable_text(file_path: Path) -> str:
"""Read an excalidraw file and return its indexable text (blocking helper).
Runs inside an executor via ``enrich_pdf_texts`` so the lz-string
decompression of large diagrams never blocks the event loop.
"""
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
try:
return extract_excalidraw_indexable(raw)
except Exception: # pragma: no cover - defensive
return ""
async def enrich_pdf_texts(vault_name: str | None = None) -> int:
"""Extract text from PDFs whose extraction was deferred during the scan (BUG-040).
"""Extract text deferred during the scan: PDFs (BUG-040) + excalidraw (#86).
``_scan_vault`` only reads PDF metadata so a vault with many or large PDFs
starts serving immediately. This coroutine runs *after* the index (and the
inverted index) is ready, extracts the missing text off the event loop and
updates the in-memory entry plus the incremental index hooks.
``_scan_vault`` only reads PDF metadata and excalidraw filenames so a vault
with many or large heavy files starts serving immediately. This coroutine
runs *after* the index (and the inverted index) is ready, extracts the
missing text off the event loop and updates the in-memory entry plus the
incremental index hooks.
Args:
vault_name: Restrict the pass to a single vault; ``None`` covers every
indexed vault.
Returns:
Number of deferred PDFs whose text extraction was attempted.
Number of deferred files (PDF + excalidraw) whose text extraction was
attempted.
"""
from backend.pdf_reader import extract_pdf_text
pending: list[tuple[str, dict[str, Any], Path]] = []
pending: list[tuple[str, dict[str, Any], Path, str]] = []
with _index_lock:
for name, vault_data in index.items():
if vault_name is not None and name != vault_name:
@@ -569,32 +653,38 @@ async def enrich_pdf_texts(vault_name: str | None = None) -> int:
vault_root = Path(vault_data.get("path", ""))
for file_info in vault_data.get("files", []):
if file_info.get("pdf_text_pending"):
pending.append((name, file_info, vault_root / file_info["path"]))
pending.append((name, file_info, vault_root / file_info["path"], "pdf"))
elif file_info.get("excalidraw_text_pending"):
pending.append((name, file_info, vault_root / file_info["path"], "excalidraw"))
if not pending:
return 0
loop = asyncio.get_running_loop()
enriched = 0
for name, file_info, file_path in pending:
for name, file_info, file_path, kind in pending:
try:
raw = await loop.run_in_executor(None, extract_pdf_text, file_path, 100000)
if kind == "pdf":
raw = await loop.run_in_executor(None, extract_pdf_text, file_path, 100000)
else:
raw = await loop.run_in_executor(None, _read_excalidraw_indexable_text, file_path)
except Exception as exc: # pragma: no cover - defensive
logger.warning("PDF enrichment failed for %s: %s", file_path, exc)
logger.warning("Deferred text enrichment failed for %s: %s", file_path, exc)
raw = ""
file_info["content"] = raw[:SEARCH_CONTENT_LIMIT]
file_info["content_preview"] = raw[:200].strip()
file_info.pop("pdf_text_pending", None)
file_info.pop("excalidraw_text_pending", None)
enriched += 1
if _on_index_change:
try:
_on_index_change("add", name, file_info["path"], file_info)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"Index hook failed after PDF enrichment for %s: %s", file_path, exc
"Index hook failed after deferred enrichment for %s: %s", file_path, exc
)
logger.info("PDF enrichment: extracted text for %d deferred PDF(s)", enriched)
logger.info("Deferred text enrichment: extracted text for %d file(s)", enriched)
return enriched
@@ -603,16 +693,24 @@ async def build_index(progress_callback=None) -> None:
Runs vault scans concurrently, inserting them incrementally into the global index.
Notifies progress via the provided callback.
#86 differential rebuild: the previous per-vault ``{path: file_info}``
snapshots are captured before the clear and handed to ``_scan_vault`` so
unchanged files (same size + mtime) are reused without disk re-reads.
"""
global index, vault_config
vault_config.clear()
vault_config.update(load_vault_config())
# Note: vault_settings are now only used for UI display preferences (hideHiddenFiles)
# Indexing always includes all files regardless of settings
global _index_generation
with _index_lock:
previous_snapshot: dict[str, dict[str, dict[str, Any]]] = {
name: {f["path"]: f for f in vdata.get("files", [])}
for name, vdata in index.items()
}
index.clear()
_file_lookup.clear()
path_index.clear()
@@ -631,8 +729,13 @@ async def build_index(progress_callback=None) -> None:
loop = asyncio.get_event_loop()
async def _process_vault(name: str, config: dict[str, Any]):
import functools
vault_path = config["path"]
vault_data = await loop.run_in_executor(None, _scan_vault, name, vault_path, config)
scan = functools.partial(
_scan_vault, name, vault_path, config, previous_snapshot.get(name)
)
vault_data = await loop.run_in_executor(None, scan)
vault_data["config"] = config
# Build lookup entries for the new vault
@@ -695,7 +798,7 @@ async def reload_index() -> dict[str, Any]:
Dict mapping vault names to their file/tag counts.
"""
await build_index()
# BUG-040: complete the deferred PDF extraction for the rebuilt index.
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
await enrich_pdf_texts()
stats = {}
for name, data in index.items():
@@ -724,14 +827,22 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
raise ValueError(f"Vault '{vault_name}' not found in configuration")
config = vault_config[vault_name]
# #86 differential rescan: snapshot this vault's entries before removal so
# unchanged files are reused without disk re-reads.
with _index_lock:
_previous = {f["path"]: f for f in index.get(vault_name, {}).get("files", [])}
# Remove old vault data from index structures
await remove_vault_from_index(vault_name)
# Re-add the vault with updated configuration
import functools
vault_path = config["path"]
loop = asyncio.get_event_loop()
vault_data = await loop.run_in_executor(None, _scan_vault, vault_name, vault_path, config)
scan = functools.partial(_scan_vault, vault_name, vault_path, config, _previous)
vault_data = await loop.run_in_executor(None, scan)
vault_data["config"] = config
# Build lookup entries for the vault
@@ -761,7 +872,7 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
from backend.attachment_indexer import build_attachment_index
await build_attachment_index({vault_name: config})
# BUG-040: complete the deferred PDF extraction for this vault.
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
await enrich_pdf_texts(vault_name)
stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])}
@@ -832,6 +943,10 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
raw = extract_excalidraw_indexable(raw)
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
elif is_media(ext):
# #108 — binary media: metadata only, never read the bytes.
raw = ""
content_preview = ""
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
content_preview = raw[:200].strip()
+293 -60
View File
@@ -2,7 +2,6 @@ import asyncio
import html as html_mod
import json as _json
import logging
import mimetypes
import os
import re
import secrets
@@ -16,6 +15,7 @@ from datetime import datetime, timezone
from functools import partial
from pathlib import Path
from typing import Any
from urllib.parse import quote
import frontmatter
import mistune
@@ -52,6 +52,8 @@ from backend.indexer import (
remove_vault_from_index,
update_single_file,
)
from backend.media_thumbs import generate_thumbnail, is_decodable
from backend.media_types import IMAGE_EXTENSIONS, is_audio, is_image, is_video, media_mime_type
from backend.openapi_docs import (
API_DESCRIPTION,
TAGS_METADATA,
@@ -203,6 +205,11 @@ class FileContentResponse(BaseModel):
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
is_pdf: bool | None = Field(default=None, description="True for PDF files")
is_image: bool | None = Field(default=None, description="True for image files")
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
is_csv: bool | None = Field(default=None, description="True for CSV files")
is_json: bool | None = Field(default=None, description="True for JSON files")
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
@@ -699,20 +706,23 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
"frame-src 'self' blob:; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'self';"
)
# A route may set a stricter per-response policy (e.g. ``sandbox`` for
# standalone SVG, #108-B3); keep it instead of overwriting it.
if "Content-Security-Policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
"frame-src 'self' blob:; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'self';"
)
# Static assets are NOT content-hashed, so they must revalidate:
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
# a stale build for a year (the service worker cache compounded it).
@@ -1032,6 +1042,27 @@ def _content_disposition(disposition: str, filename: str) -> str:
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
def _media_max_inline_bytes() -> int:
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
above the limit are not streamed in the viewer (the UI falls back to the
download button), which keeps a single uvicorn worker from being pinned by
multi-gigabyte media. Invalid or non-positive values fall back to default.
"""
default_mb = 500
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
if not raw:
return default_mb * 1024 * 1024
try:
mb = float(raw)
except ValueError:
return default_mb * 1024 * 1024
if mb <= 0:
return default_mb * 1024 * 1024
return int(mb * 1024 * 1024)
def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a relative path safely within the vault root.
@@ -1747,6 +1778,37 @@ def _safe_export_name(name: str) -> str:
return cleaned or "document"
@app.get(
"/api/guide/download",
response_class=Response,
responses={200: {"content": {"application/pdf": {}, "text/markdown": {}}}},
)
async def api_guide_download(
format: str = Query("md", description="Download format: 'md' or 'pdf'"),
lang: str = Query("fr", description="Guide language: 'fr' or 'en'"),
current_user=Depends(require_auth),
):
"""Download the in-app user guide as Markdown or PDF (#105).
The document is generated from the live help modal in index.html resolved
through the locale files, so it always mirrors exactly what the user sees.
"""
from backend.guide_export import get_guide_document
if format not in ("md", "pdf"):
raise HTTPException(status_code=400, detail="format doit être 'md' ou 'pdf'")
try:
payload, media, fname = get_guide_document(format, lang)
except Exception as e: # weasyprint/reportlab unavailable
logger.exception("guide export failed")
raise HTTPException(status_code=500, detail=f"Export impossible: {e}") from e
return Response(
content=payload,
media_type=media,
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
async def api_file_save(
vault_name: str,
@@ -2378,19 +2440,18 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
# === Images: return as viewable image ===
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
if ext in IMAGE_EXTENSIONS:
if is_image(ext):
size = file_path.stat().st_size
mime_map = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
".bmp": "image/bmp", ".ico": "image/x-icon",
}
mime = mime_map.get(ext, "application/octet-stream")
mime = media_mime_type(str(file_path))
# #108-B1 — the raw endpoint returns JSON (FileRawResponse), so the
# standalone <img> must point to /api/image, which serves the bytes
# with the right MIME type. Paths are URL-encoded (accents, spaces).
img_url = f"/api/image/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
html = (
f'<div class="image-viewer">'
f'<img src="/api/file/{vault_name}/raw?path={path}" '
f'alt="{file_path.name}" style="max-width:100%;max-height:80vh;object-fit:contain" />'
f'<img src="{img_url}" '
f'alt="{html_mod.escape(file_path.name, quote=True)}" '
f'style="max-width:100%;max-height:80vh;object-fit:contain" />'
f'</div>'
)
return {
@@ -2408,6 +2469,61 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
"size_bytes": size,
}
# === Audio / Video: HTML5 players streamed from /api/media (roadmap #109) ===
if is_audio(ext) or is_video(ext):
size = file_path.stat().st_size
mime = media_mime_type(str(file_path))
media_kind = "audio" if is_audio(ext) else "video"
# #109-A3 — beyond the inline limit the viewer falls back to download
# (a single uvicorn worker must not be pinned by multi-GB media).
if size > _media_max_inline_bytes():
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"media_too_large": True,
"size_bytes": size,
}
# #109-A2 — byte-range endpoint: enables scrub and is required by Safari.
stream_url = f"/api/media/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
if media_kind == "audio":
html = (
f'<div class="audio-viewer">'
f'<audio controls preload="metadata" src="{stream_url}"></audio>'
f'</div>'
)
else:
html = (
f'<div class="video-viewer">'
f'<video controls playsinline preload="metadata" src="{stream_url}"></video>'
f'</div>'
)
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_audio": media_kind == "audio",
"is_video": media_kind == "video",
"media_mime": mime,
"stream_url": stream_url,
"size_bytes": size,
}
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
@@ -2583,32 +2699,21 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
}
@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
async def api_pdf_stream(
request: Request,
vault_name: str,
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
def _stream_file_with_range(file_path: Path, request: Request, media_type: str):
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
Supports HTTP Range requests (206 Partial Content) so browsers can
progressively render large PDFs in the native viewer.
Shared by ``pdf/stream`` and ``/api/media``: a plain :class:`FileResponse`
with ``Accept-Ranges: bytes`` when no range is requested, or a
:class:`StreamingResponse` (206 Partial Content, 64 KiB chunks) for a valid
single range. An unsatisfiable range yields ``416`` with a
``Content-Range: bytes */size`` header.
Reads are offloaded to threads so the event loop is never blocked
(ASYNC230), matching the previous inline implementation.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() != ".pdf":
raise HTTPException(status_code=400, detail="Not a PDF file")
file_size = file_path.stat().st_size
range_header = request.headers.get("range")
disposition = _content_disposition("inline", file_path.name)
if range_header:
# Parse "bytes=start-end" (single range only; multi-range is not used by viewers)
@@ -2636,7 +2741,6 @@ async def api_pdf_stream(
chunk_size = end - start + 1
async def _partial():
# Open + reads offloaded to threads (avoid blocking the event loop — ASYNC230)
f = await asyncio.to_thread(open, str(file_path), "rb")
try:
await asyncio.to_thread(f.seek, start)
@@ -2653,18 +2757,45 @@ async def api_pdf_stream(
return StreamingResponse(
_partial(),
status_code=206,
media_type="application/pdf",
media_type=media_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Disposition": _content_disposition("inline", file_path.name),
"Content-Disposition": disposition,
},
)
return FileResponse(str(file_path), media_type="application/pdf", headers={
return FileResponse(str(file_path), media_type=media_type, headers={
"Accept-Ranges": "bytes",
"Content-Disposition": _content_disposition("inline", file_path.name)})
"Content-Disposition": disposition})
@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
async def api_pdf_stream(
request: Request,
vault_name: str,
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
Supports HTTP Range requests (206 Partial Content) so browsers can
progressively render large PDFs in the native viewer.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() != ".pdf":
raise HTTPException(status_code=400, detail="Not a PDF file")
return _stream_file_with_range(file_path, request, "application/pdf")
@app.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
@@ -3152,16 +3283,19 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
# Determine MIME type
mime_type, _ = mimetypes.guess_type(str(file_path))
if not mime_type:
# Default to octet-stream if unknown
mime_type = "application/octet-stream"
mime_type = media_mime_type(str(file_path))
# #108-B3 — a standalone SVG opened in a tab executes its embedded JS
# (same-origin XSS). ``sandbox`` forces a unique opaque origin with no
# script execution; inside an <img> tag the header is irrelevant.
headers = {"X-Content-Type-Options": "nosniff"}
if file_path.suffix.lower() == ".svg":
headers["Content-Security-Policy"] = "sandbox"
try:
# Read and return the image file
content = file_path.read_bytes()
return Response(content=content, media_type=mime_type)
return Response(content=content, media_type=mime_type, headers=headers)
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except Exception as e:
@@ -3169,6 +3303,91 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
@app.get("/api/media/{vault_name}", response_class=FileResponse)
async def api_media_stream(
request: Request,
vault_name: str,
path: str = Query(..., description="Relative path to audio/video file"),
current_user=Depends(require_auth),
):
"""Stream an audio/video file with HTTP Range support (roadmap #109-A2).
Serves the bytes with the correct MIME type and honours ``Range`` requests
(``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is
what enables scrubbing in ``<audio>``/``<video>`` and is required by Safari
for MP4. Files above ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB) are
refused with ``413`` — the viewer falls back to the download button.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"Media not found: {path}")
ext = file_path.suffix.lower()
if not (is_audio(ext) or is_video(ext)):
raise HTTPException(status_code=400, detail="Not an audio/video file")
if file_path.stat().st_size > _media_max_inline_bytes():
raise HTTPException(status_code=413, detail="Media too large for inline streaming")
return _stream_file_with_range(file_path, request, media_mime_type(str(file_path)))
@app.get("/api/media/{vault_name}/thumb", response_class=FileResponse)
async def api_media_thumb(
vault_name: str,
path: str = Query(..., description="Relative path to image"),
size: int = Query(256, ge=32, le=1024, description="Max thumbnail edge in pixels"),
current_user=Depends(require_auth),
):
"""Serve a cached WebP thumbnail of an image (roadmap #108-C).
SVG (and any format Pillow cannot decode) falls back to the original
bytes. Generation runs in a thread and is capped at 2 s; on timeout or
failure the original is served so the UI never breaks.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
if not is_image(file_path.suffix.lower()):
raise HTTPException(status_code=400, detail="Not an image file")
mime_type = media_mime_type(str(file_path))
if not is_decodable(file_path):
# SVG: never let a standalone navigation execute embedded JS (#108-B3).
svg_headers = {"X-Content-Type-Options": "nosniff"}
if file_path.suffix.lower() == ".svg":
svg_headers["Content-Security-Policy"] = "sandbox"
return FileResponse(str(file_path), media_type=mime_type, headers=svg_headers)
loop = asyncio.get_running_loop()
thumb: Path | None = None
try:
thumb = await asyncio.wait_for(
loop.run_in_executor(None, generate_thumbnail, file_path, size),
timeout=2.0,
)
except Exception:
thumb = None
if thumb is not None and thumb.exists():
return FileResponse(str(thumb), media_type="image/webp")
return FileResponse(str(file_path), media_type=mime_type)
@app.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse)
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
"""Rescan attachments for a specific vault.
@@ -4061,6 +4280,7 @@ async def api_dashboard(current_user=Depends(require_auth)):
total_files = 0
total_tags = set()
total_size = 0
total_images = 0
for vname, vdata in index.items():
if "*" not in user_vaults and vname not in user_vaults:
continue
@@ -4069,13 +4289,26 @@ async def api_dashboard(current_user=Depends(require_auth)):
total_files += fc
vtags = set()
vsize = 0
vimages = 0
for f in files:
vtags.update(f.get("tags", []))
vsize += f.get("size", 0)
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
vimages += 1
total_tags.update(vtags)
total_size += vsize
vault_stats.append({"name": vname, "file_count": fc, "tag_count": len(vtags), "total_size_bytes": vsize})
return {"vaults": vault_stats, "total_files": total_files, "total_tags": len(total_tags), "total_size_bytes": total_size}
total_images += vimages
vault_stats.append({
"name": vname, "file_count": fc, "tag_count": len(vtags),
"total_size_bytes": vsize, "image_count": vimages,
})
return {
"vaults": vault_stats,
"total_files": total_files,
"total_tags": len(total_tags),
"total_size_bytes": total_size,
"total_images": total_images,
}
# ---------------------------------------------------------------------------
+74
View File
@@ -0,0 +1,74 @@
"""Image thumbnail generation and disk cache (roadmap #108-C).
Thumbnails are generated on demand with Pillow and cached under
``<OBSIGATE_DATA_DIR>/.obsigate-cache/thumbs/<sha1>.webp``. The cache key
embeds the source path, mtime (ns) and size, so an edited image naturally
invalidates its stale thumbnail without any explicit cleanup.
"""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
DEFAULT_THUMB_SIZE = 256
# Extensions Pillow cannot decode without extra native libraries: served as-is.
_UNDECODABLE = {".svg"}
def thumbs_cache_dir() -> Path:
"""Return (and create) the thumbnail cache directory."""
base = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / ".obsigate-cache" / "thumbs"
base.mkdir(parents=True, exist_ok=True)
return base
def thumb_cache_path(file_path: Path, size: int) -> Path:
"""Compute the deterministic cache path for *file_path* at *size*."""
try:
st = file_path.stat()
stamp = f"{st.st_mtime_ns}:{st.st_size}"
except OSError:
stamp = "0:0"
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest()
return thumbs_cache_dir() / f"{key}.webp"
def is_decodable(file_path: Path) -> bool:
"""True when Pillow can be expected to decode *file_path*."""
return file_path.suffix.lower() not in _UNDECODABLE
def generate_thumbnail(file_path: Path, size: int = DEFAULT_THUMB_SIZE) -> Path | None:
"""Generate (or reuse) a WebP thumbnail and return its path.
Returns ``None`` when the file cannot be decoded (e.g. SVG) or Pillow is
unavailable, so the caller can fall back to serving the original.
"""
cache_path = thumb_cache_path(file_path, size)
if cache_path.exists():
return cache_path
try:
from PIL import Image, ImageOps
except Exception: # pragma: no cover - Pillow is an optional runtime dep
return None
try:
with Image.open(file_path) as opened:
# Animated formats: keep only the first frame.
if getattr(opened, "is_animated", False):
opened.seek(0)
img = ImageOps.exif_transpose(opened) or opened
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA")
img.thumbnail((size, size))
tmp = cache_path.with_suffix(".tmp")
img.save(tmp, "WEBP", quality=80)
os.replace(tmp, cache_path)
return cache_path
except Exception:
return None
+76
View File
@@ -0,0 +1,76 @@
"""Shared media type constants and helpers.
Single source of truth for the file extensions and MIME types handled by the
image support (roadmap #108) and reused by the audio/video players (#109).
Keeping these sets here avoids the previous duplication (``indexer.py``,
``attachment_indexer.py`` and ``main.py`` each carried their own copy).
"""
from __future__ import annotations
import mimetypes
# Image extensions viewable in the browser (HEIC/HEIF deliberately excluded —
# no browser decodes them natively; see roadmap #108).
IMAGE_EXTENSIONS: frozenset[str] = frozenset({
".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico",
})
# Audio extensions (socle for #109, not wired into the index yet).
AUDIO_EXTENSIONS: frozenset[str] = frozenset({
".mp3", ".m4a", ".aac", ".wav", ".ogg", ".oga", ".opus", ".flac",
})
# Video extensions (socle for #109, not wired into the index yet).
VIDEO_EXTENSIONS: frozenset[str] = frozenset({
".mp4", ".webm", ".mov", ".m4v",
})
MEDIA_EXTENSIONS: frozenset[str] = IMAGE_EXTENSIONS | AUDIO_EXTENSIONS | VIDEO_EXTENSIONS
# Explicit MIME types for extensions ``mimetypes`` gets wrong or does not know.
_MIME_OVERRIDES: dict[str, str] = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".webp": "image/webp",
".m4a": "audio/mp4",
".oga": "audio/ogg",
".opus": "audio/ogg",
".mov": "video/quicktime",
".m4v": "video/mp4",
}
def is_image(ext: str) -> bool:
"""Return True when *ext* (with leading dot, any case) is an image."""
return ext.lower() in IMAGE_EXTENSIONS
def is_audio(ext: str) -> bool:
"""Return True when *ext* is an audio extension."""
return ext.lower() in AUDIO_EXTENSIONS
def is_video(ext: str) -> bool:
"""Return True when *ext* is a video extension."""
return ext.lower() in VIDEO_EXTENSIONS
def is_media(ext: str) -> bool:
"""Return True when *ext* is any supported image/audio/video extension."""
return ext.lower() in MEDIA_EXTENSIONS
def media_mime_type(path: str) -> str:
"""Return the best MIME type for *path* (extension based).
Falls back to ``application/octet-stream`` when the type is unknown.
"""
lower = path.lower()
for ext, mime in _MIME_OVERRIDES.items():
if lower.endswith(ext):
return mime
guessed, _ = mimetypes.guess_type(path)
return guessed or "application/octet-stream"
+2
View File
@@ -30,6 +30,7 @@ TAGS_METADATA: list[dict[str, str]] = [
{"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": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
{"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": "MCP", "description": "Model Context Protocol server (Streamable HTTP) exposing the shared AI tool layer to external clients (Claude Desktop, Cursor…)."},
@@ -98,6 +99,7 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
(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/guide"), "Guide"),
(re.compile(r"^/api/file/[^/]+/pdf"), "PDF"),
(re.compile(r"^/api/search"), "Search"),
(re.compile(r"^/api/tags"), "Search"),
+1 -1
View File
@@ -43,7 +43,7 @@ def build_pdf_html(body_html: str, title: str, theme: str = "light") -> str:
<head><meta charset="utf-8"><title>{title}</title>
<style>
body {{
font-family: Georgia, "Times New Roman", serif;
font-family: Georgia, "Times New Roman", serif, "Noto Color Emoji";
max-width: 720px;
margin: 40px auto;
padding: 0 20px;
+2
View File
@@ -15,6 +15,7 @@ weasyprint>=60.0
httpx>=0.27.0
pypdf>=4.0
pyotp>=2.10.0
segno>=1.5.0
webauthn==2.6.0
psutil>=5.9
pywebpush>=2.3.0
@@ -23,3 +24,4 @@ sse-starlette==2.1.3
openpyxl>=3.1
python-docx>=1.1
reportlab>=4.0
pillow>=10.0
+2
View File
@@ -395,6 +395,7 @@ class DashboardVaultStat(BaseModel):
file_count: int
tag_count: int
total_size_bytes: int
image_count: int = 0
class DashboardResponse(BaseModel):
@@ -404,6 +405,7 @@ class DashboardResponse(BaseModel):
total_files: int
total_tags: int
total_size_bytes: int
total_images: int = 0
# ---------------------------------------------------------------------------
+15
View File
@@ -26,6 +26,11 @@ from backend.services.vaults import get_vault_root
logger = logging.getLogger("obsigate.services.mutations")
# #86: per-file size cap for find/replace passes (CPU guard — complements the
# BUG-025 regex caps). Files larger than this are skipped instead of being
# read fully into memory and scanned with a user-supplied pattern.
MAX_REPLACE_FILE_BYTES = 5_000_000
# Skeleton injected into empty ``.excalidraw`` files (mirrors the route logic).
_EXCALIDRAW_SKELETON = (
'{"type":"excalidraw","version":2,"elements":[],'
@@ -509,6 +514,16 @@ def replace_in_files(
continue
if not file_path.exists() or not file_path.is_file():
continue
# #86 CPU guard: skip files too large to scan safely in one pass.
try:
if file_path.stat().st_size > MAX_REPLACE_FILE_BYTES:
logger.warning(
"replace_in_files: skipping oversized file %s/%s (%d bytes)",
result_vault, result["path"], file_path.stat().st_size,
)
continue
except OSError:
continue
try:
original = file_path.read_text(encoding="utf-8", errors="replace")
except OSError:
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.11.3"
version = "2.25.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.11.3"
version = "2.25.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.11.3",
"version": "2.25.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+3 -3
View File
@@ -308,7 +308,7 @@ Pour répondre au besoin de cibler un fournisseur/modèle sans dépendre uniquem
- Lecture : `backend/ai.py` (`_read_app_config`, `get_default_provider`, `_load_provider_keys`).
- Écriture : `POST /api/config` (admin) — clés ajoutées à `_DEFAULT_CONFIG` (`backend/main.py:4270`).
- Rechargement à chaud : `reload_ai_config()` met à jour `PROVIDERS` **en place** (les imports existants restent valides).
- UI : section « Clés API Intelligence Artificielle » (`frontend/index.html` `#cfg-ai`), sélecteurs « Fournisseur par défaut » + « Modèle par défaut », sauvegardés par `saveAIKeys()` (`frontend/js/config.js`).
- UI : section « Clés API Intelligence Artificielle » (`frontend/index.html` `#cfg-ai`, cartes dépliables par fournisseur — #104), sélecteurs « Fournisseur par défaut » + « Modèle par défaut », sauvegardés par `saveAIKeys()` (`frontend/js/config.js`).
**Précédence de résolution du modèle** : override par requête > `ai_default_models[provider]` > variable d'environnement `*_MODEL` > défaut codé en dur.
@@ -347,7 +347,7 @@ Pour répondre au besoin de cibler un fournisseur/modèle sans dépendre uniquem
| **0 — Fondations** | `backend/tools/` (registry, context, service, audit) + extraction des services métier + tests unitaires | Couche d'outils testable sans IA |
| **1 — Function calling in-app** | Abstraction tool-calling multi-provider, agent loop, confirmations UI, SSE réel, outils de navigation | Assistant qui lit/cherche/lit/ouvre/modifie avec confirmation |
| **2 — Serveur MCP** | `backend/mcp/server.py` (tools + resources + prompts), **Streamable HTTP** (`/mcp`, auth JWT), confirmation two-step | ObsiGate accessible comme serveur MCP (local + distant, multi-utilisateur) |
| **3 — Durcissement** ✅ | Rate limiting (`backend/tools/ratelimit.py`), quotas `BOOKSLM_MAX_*`, redaction systématique des résultats (`backend/tools/redaction.py`), doc OpenAPI (tag/path MCP) + [guide MCP](./MCP_GUIDE.md), tests E2E | Observabilité et sécurité complètes |
| **3 — Durcissement** ✅ | Rate limiting (`backend/tools/ratelimit.py`), quotas `BOOKSLM_MAX_*`, redaction systématique des résultats (`backend/tools/redaction.py`), doc OpenAPI (tag/path MCP) + [guide MCP](./GUIDES/MCP.md), tests E2E | Observabilité et sécurité complètes |
Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités.
@@ -380,7 +380,7 @@ Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités.
- `backend/mcp/confirmations.py` — jetons de confirmation signés (two-step, anti-rejeu)
- `backend/tools/ratelimit.py` — rate limiting par jeton/outil (phase F)
- `backend/tools/redaction.py` — redaction récursive des résultats d'outils (phase F)
- `docs/MCP_GUIDE.md` — guide d'installation et d'utilisation des clients MCP
- `docs/GUIDES/MCP.md` — guide d'installation et d'utilisation des clients MCP
- `backend/bookslm.py`, `backend/bookslm_routes.py` — assistant contextuel (+ endpoint `/agent`)
- `frontend/js/ai.js`, `frontend/js/bookslm.js` — UI IA
- `backend/auth/middleware.py` — permissions
+330
View File
@@ -0,0 +1,330 @@
# 🔌 Guide de l'API REST
ObsiGate expose une **API REST complète** couvrant toute l'application :
vaults, fichiers, recherche, sauvegardes, exports, IA, partage, webhooks et
administration. Ce guide explique l'authentification, la création de clés et
donne des exemples prêts à l'emploi.
> **Public :** développeurs, intégrateurs, scripts d'automatisation
> **Doc interactive :** `/docs` (Swagger UI) · `/redoc` (ReDoc) · `/openapi.json`
> **Voir aussi :** [Serveur MCP](./MCP.md) · [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md)
---
## 1. Base et conventions
| Élément | Valeur |
|---|---|
| URL de base | `http://<hôte>:2020` (Docker) ou `http://127.0.0.1:17890` (desktop) |
| Préfixe API | `/api` |
| Format | JSON (`application/json`) |
| Version | suit la version d'ObsiGate (header `X-…`, `/api/health`) |
| Erreurs | `{"detail": "..."}` + code HTTP (`400`, `401`, `403`, `404`, `409`, `422`, `500`) |
Quand l'authentification est **désactivée** (`OBSIGATE_AUTH_ENABLED=false`), tous
les endpoints sont accessibles sans jeton (utilisateur anonyme avec accès à tous
les vaults).
---
## 2. Authentification
### 2.1 Jeton de session (JWT)
Obtenu via `POST /api/auth/login`. Le jeton d'accès a une durée de vie courte
(`OBSIGATE_ACCESS_TOKEN_TTL`, défaut 3600 s) et un refresh token longue durée est
posé en cookie HTTP-only.
```bash
curl -s -X POST http://localhost:2020/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"votre_mot_de_passe"}'
```
Réponse (extrait) :
```json
{
"access_token": "eyJ...",
"token_type": "bearer",
"expires_in": 3600,
"user": { "username": "admin", "role": "admin", "vaults": ["*"] }
}
```
Deux façons de présenter le jeton :
```http
Authorization: Bearer <access_token>
```
ou, pour un client navigateur, le cookie HTTP-only avec
`credentials: "include"` (le login pose aussi un cookie `access_token`).
### 2.2 Clés API longue durée (recommandé pour scripts & MCP)
Une **seule clé** authentifie **l'API REST et le serveur MCP**. Créez-la depuis
l'interface (Configurations → **🔑 Clés API & MCP**) ou par API :
```bash
# 1. Se connecter, récupérer le token (section 2.1)
# 2. Créer une clé valable 30 jours
curl -s -X POST http://localhost:2020/api/auth/tokens \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Script backup","expiry":"30d"}'
```
Réponse (`token` affiché **une seule fois**) :
```json
{
"token": "eyJ...",
"jti": "…",
"name": "Script backup",
"created_at": 1790000000,
"expires_at": 1792592000,
"expiry_key": "30d"
}
```
| `expiry` | Durée |
|---|---|
| `1d` | 1 jour |
| `30d` | 1 mois |
| `180d` | 6 mois |
| `365d` | 1 an |
| `never` | sans expiration |
Gestion :
| Endpoint | Rôle |
|---|---|
| `GET /api/auth/tokens` | Lister vos clés (`last_used_at`, statut) |
| `POST /api/auth/tokens` | Créer (`{name, expiry}`) |
| `DELETE /api/auth/tokens/{jti}` | Révoquer immédiatement (API **et** MCP) |
> Le JWT brut n'est **jamais persisté** : copiez-le à la création. Plafond :
> 50 clés actives par utilisateur.
---
## 3. Référence des endpoints
> Liste non exhaustive — la référence faisant foi est `/openapi.json`. Les
> colonnes **Auth** indiquent le niveau requis (`—`, `Oui`, `Admin`).
### 3.1 Système
| Endpoint | Description | Méthode | Auth |
|---|---|---|---|
| `/api/health` | Santé (statut, version, stats) | GET | — |
| `/api/health/detailed` | Santé détaillée | GET | — |
| `/api/config` | Lire / écrire la configuration | GET/POST | Oui/Admin |
| `/api/diagnostics` | Statistiques index & mémoire | GET | Admin |
| `/api/dashboard` | Statistiques du tableau de bord | GET | Oui |
| `/api/events` | Flux SSE temps réel | GET | Oui |
### 3.2 Vaults
| Endpoint | Description | Méthode | Auth |
|---|---|---|---|
| `/api/vaults` | Liste (filtrée par permissions) | GET | Oui |
| `/api/vaults/status` | Statut de toutes les vaults | GET | Oui |
| `/api/vaults/add` | Ajouter une vault (volume déjà monté) | POST | Admin |
| `/api/vaults/{name}` | Supprimer une vault | DELETE | Admin |
| `/api/index/reload` | Réindexation complète | GET | Admin |
| `/api/index/reload/{vault}` | Réindexer une vault | GET | Oui |
| `/api/vaults/{vault}/settings` | Lire / écrire les réglages | GET/POST | Oui |
| `/api/attachments/rescan/{vault}` | Rescanner les attachements | POST | Oui |
### 3.3 Fichiers
| Endpoint | Description | Méthode | Auth |
|---|---|---|---|
| `/api/browse/{vault}?path=` | Naviguer dans les dossiers | GET | Oui |
| `/api/file/{vault}?path=` | Contenu rendu (Markdown) | GET | Oui |
| `/api/file/{vault}/raw?path=` | Contenu brut | GET | Oui |
| `/api/file/{vault}/download?path=` | Télécharger | GET | Oui |
| `/api/file/{vault}/save?path=` | Enregistrer | PUT | Oui |
| `/api/file/{vault}` | Créer | POST | Oui |
| `/api/file/{vault}` | Renommer | PATCH | Oui |
| `/api/file/{vault}` | Supprimer | DELETE | Oui |
| `/api/directory/{vault}` | Créer / renommer / supprimer un dossier | POST/PATCH/DELETE | Oui |
| `/api/move/{vault}` | Déplacer un fichier/dossier | POST | Oui |
| `/api/vault/{vault}/batch-upload` | Upload multiple (multipart) | POST | Oui |
| `/api/image/{vault}?path=` | Servir une image | GET | Oui |
### 3.4 Recherche & graphe
| Endpoint | Description | Méthode | Auth |
|---|---|---|---|
| `/api/search` | Recherche simple (legacy) | GET | Oui |
| `/api/search/advanced` | Recherche TF-IDF avancée (facettes, tri, pagination, `semantic=`) | GET | Oui |
| `/api/search/replace` | Recherche/remplacement multi-fichiers | POST | Oui |
| `/api/tags?vault=` | Tags uniques avec compteurs | GET | Oui |
| `/api/suggest?q=` | Autocomplétion de titres | GET | Oui |
| `/api/tags/suggest?q=` | Autocomplétion de tags | GET | Oui |
| `/api/tree-search` | Recherche de fichiers/dossiers | GET | Oui |
| `/api/vault/{vault}/paths` | Liste de chemins | GET | Oui |
| `/api/graph/{vault}` | Graphe de liens | GET | Oui |
### 3.5 Sauvegardes
| Endpoint | Description | Méthode | Auth |
|---|---|---|---|
| `/api/file/{vault}/backups` | Backups d'un fichier | GET | Oui |
| `/api/file/{vault}/diff` | Diff avec une version | GET | Oui |
| `/api/file/{vault}/restore` | Restaurer une version | POST | Oui |
| `/api/backups` | Lister les backups | GET | Oui |
| `/api/backups/content` | Contenu d'un backup | GET | Oui |
| `/api/backups/delete` / `/purge` / `/compress` / `/auto` | Gestion & purge | POST | Oui |
### 3.6 Exports
| Endpoint | Description | Méthode |
|---|---|---|
| `/api/export/html` | Exporter en HTML | GET |
| `/api/export/md-bundle` | Exporter en bundle Markdown (ZIP) | GET |
| `/api/export/epub` | Exporter en ePub | GET |
| `/api/guide/download?format=md\|pdf&lang=fr\|en` | Télécharger le guide intégré | GET |
### 3.7 PDF
| Endpoint | Description | Méthode |
|---|---|---|
| `/api/file/{vault}/pdf/info` | Métadonnées sans transfert | GET |
| `/api/file/{vault}/pdf/stream` | Streaming (HTTP Range, 206) | GET |
### 3.8 IA
| Endpoint | Description | Méthode |
|---|---|---|
| `/api/ai/status` | Statut des fournisseurs | GET |
| `/api/ai/improve`, `/fix-spelling`, `/summarize`, `/translate`, `/rewrite`, `/to-list`, `/to-table`, `/frontmatter`, `/inline-complete`, `/to-canvas`… | Actions éditeur IA | POST |
| `/api/ai/model-capabilities?provider=&model=` | Capacités d'un modèle | GET |
| `/api/ai/bookslm/*` | Console IA par répertoire | POST/GET |
| `/api/ai/skills` | Lister / créer / supprimer des skills | GET/POST/DELETE |
| `/api/config/ai-keys` · `/api/config/tool-keys` | Clés fournisseurs & sources | GET/POST/DELETE |
### 3.9 Authentification & administration
| Endpoint | Description | Méthode | Auth |
|---|---|---|---|
| `/api/auth/status` | Statut de l'auth | GET | — |
| `/api/auth/login` · `/refresh` · `/logout` | Cycle de session | POST | — / Cookie / Oui |
| `/api/auth/me` | Profil courant | GET/PATCH | Oui |
| `/api/auth/change-password` | Changer le mot de passe | POST | Oui |
| `/api/auth/mfa/*` | TOTP, WebAuthn, recovery | POST/GET | Oui |
| `/api/auth/tokens` | Clés API (voir §2.2) | GET/POST/DELETE | Oui |
| `/api/auth/admin/users` | Lister / créer des utilisateurs | GET/POST | Admin |
| `/api/auth/admin/users/{u}` | Modifier / supprimer | PATCH/DELETE | Admin |
| `/api/admin/stats` · `/audit` · `/backup-stats` · `/stream` | Monitoring admin | GET | Admin |
> `PATCH /api/auth/me` accepte `{"avatar": "<data-url>"}` (PNG/JPEG/WebP, 400 000
> caractères max, octets magiques contrôlés) ; `{"avatar": ""}` supprime la photo.
> La valeur est renvoyée par `GET /api/auth/me` et par le payload `user` du login.
### 3.10 Partage, webhooks, conflits, plugins, push
| Endpoint | Description | Méthode |
|---|---|---|
| `/api/share/{vault}` | Créer un lien de partage public | POST |
| `/api/shares` | Lister / supprimer les partages | GET/DELETE |
| `/api/webhooks` | CRUD webhooks (HMAC-SHA256) | GET/POST/PATCH/DELETE |
| `/api/conflicts` · `/api/conflicts/resolve` | Conflits Syncthing | GET/POST |
| `/api/plugins` | Installer / activer / désactiver | GET/POST/DELETE |
| `/api/push/*` | Abonnement Web Push (VAPID) | GET/POST/DELETE |
---
## 4. Exemples `curl`
```bash
BASE=http://localhost:2020
TOKEN=$(curl -s -X POST $BASE/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"secret"}' | jq -r .access_token)
# Santé
curl -s $BASE/api/health
# Lister les vaults
curl -s $BASE/api/vaults -H "Authorization: Bearer $TOKEN"
# Naviguer
curl -s "$BASE/api/browse/Recettes?path=" -H "Authorization: Bearer $TOKEN"
# Lire un fichier (rendu Markdown)
curl -s "$BASE/api/file/Recettes?path=pizza.md" -H "Authorization: Bearer $TOKEN"
# Lire en brut
curl -s "$BASE/api/file/Recettes/raw?path=pizza.md" -H "Authorization: Bearer $TOKEN"
# Sauvegarder
curl -s -X PUT "$BASE/api/file/Recettes/save?path=pizza.md" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"content":"# Pizza\n\nNouvelle recette."}'
# Recherche avancée
curl -s "$BASE/api/search/advanced?q=tag:cuisine%20pizza&vault=all&limit=20&offset=0&sort=relevance" \
-H "Authorization: Bearer $TOKEN"
# Autocomplétion
curl -s "$BASE/api/suggest?q=piz&vault=all" -H "Authorization: Bearer $TOKEN"
# Forcer une réindexation
curl -s $BASE/api/index/reload -H "Authorization: Bearer $TOKEN"
```
> Le mot de passe peut aussi être fourni par une clé API dans `Authorization`.
> Quand l'auth est désactivée, omettez l'en-tête.
---
## 5. Temps réel
### 5.1 SSE — `/api/events`
Flux d'événements de changement d'index (fichiers créés/supprimés/modifiés), avec
reconnexion automatique côté client.
```bash
curl -N "$BASE/api/events"
```
### 5.2 WebSocket — collaboration
`ws(s)://<hôte>/ws/collab/{vault}/{path}` transporte les mises à jour
Yjs/CRDT et la présence (curseurs distants). Authentification par cookie
`access_token` ou paramètre `?token=`, avec contrôle d'accès par vault.
Voir [Édition & collaboration](./COLLABORATION.md).
---
## 6. Limites et bonnes pratiques
- **Rate limiting** : les endpoints de login et les outils IA sont limités ;
respectez `retry_after` en cas de `429`.
- **Permissions** : chaque endpoint fichier vérifie l'accès au vault et rejette
les chemins hors vault (path traversal).
- **Clés API** : préférez-les aux mots de passe pour les scripts ; révoquez-les
dès qu'elles ne servent plus.
- **Gros volumes** : utilisez la pagination (`limit`/`offset`) et le streaming
HTTP Range pour les PDF.
- **Exports** : `md-bundle` et `epub` renvoient un fichier binaire — utilisez
`-o` avec `curl`.
---
## 7. Dépannage
| Code | Cause probable |
|---|---|
| `401` | Jeton absent, expiré ou révoqué |
| `403` | Compte sans accès à cette vault / réservé admin |
| `404` | Vault, fichier ou chemin inexistant |
| `409` | Conflit (fichier déjà existant, etc.) |
| `422` | Corps de requête invalide (schéma Pydantic) |
| `429` | Rate limit dépassé — voir `retry_after` |
| `501` | Export PDF indisponible (WeasyPrint/GTK absent) |
+217
View File
@@ -0,0 +1,217 @@
# 🤖 Guide Assistant IA & Forge
ObsiGate intègre un **assistant IA** capable de lire, rechercher et modifier vos
notes, ainsi qu'un **éditeur IA** (CodeMirror + toolbar) et une console
contextuelle par répertoire (**BooksLM**). Ce guide explique comment les
configurer et les utiliser.
> **Fiches techniques :** [`ai-tools-mcp.md`](../features/ai-tools-mcp.md) ·
> [`ai-assistant-commands.md`](../features/ai-assistant-commands.md) ·
> [`ai-quick-actions.md`](../features/ai-quick-actions.md) ·
> [`forge-assistant.md`](../features/forge-assistant.md) ·
> [`bookslm.md`](../features/bookslm.md) ·
> [`ai-tools-roadmap.md`](../features/ai-tools-roadmap.md)
> **Voir aussi :** [Serveur MCP](./MCP.md) · [API REST](./API_REST.md)
---
## 1. Vue d'ensemble
L'IA d'ObsiGate se compose de plusieurs surfaces complémentaires :
| Surface | Rôle |
|---|---|
| **Éditeur IA** | Toolbar d'actions sur le document ouvert (CodeMirror) |
| **Assistant IA** | Panneau de discussion avec *function calling* sur vos vaults |
| **BooksLM** | Console IA contextuelle sur un **répertoire** (style NotebookLM) |
| **Forge** | Éditeur avancé avec assistant IA intégré |
| **Outils (tools)** | Lecture, recherche, écriture, opérations destructives (two-step) |
| **MCP** | Exposition des mêmes outils à Claude Desktop, Cursor, Cline… |
---
## 2. Configurer un fournisseur
### 2.1 Fournisseurs supportés
ObsiGate est **multi-fournisseur** :
- **DeepSeek**
- **OpenRouter**
- **Google Gemini**
Chaque fournisseur se configure au choix :
1. **Depuis l'interface** — menu → Configurations → **Clés API IA**. La clé saisie
est stockée dans `data/api_keys.json` et **prime** sur la variable
d'environnement.
2. **Par variable d'environnement** — voir `.env.example`.
### 2.2 Modèle et capacités
L'interface affiche les **capacités** de chaque modèle (8 indicateurs : vision,
tool calling, contexte long, etc.), via
`GET /api/ai/model-capabilities?provider=&model=`. Le picker de l'assistant
propose une recherche de modèle et une bulle d'information ⓘ.
Vous pouvez définir un **modèle par défaut** et un fournisseur par défaut dans la
configuration. Le fournisseur/modèle est **partagé** entre l'assistant et Forge.
### 2.3 Tester la configuration
`POST /api/config/ai-keys/test` vérifie qu'une clé fonctionne. En cas d'échec,
un message explicite s'affiche.
---
## 3. Éditeur IA (toolbar)
Quand un document Markdown est ouvert dans l'éditeur, une **toolbar IA** propose
des actions qui remplacent ou insèrent du contenu. Actions principales :
| Action | Effet |
|---|---|
| **Améliorer** | Relecture et amélioration générale |
| **Corriger** | Correction orthographique et grammaticale |
| **Raccourcir / Allonger** | Ajuste la longueur du texte |
| **Simplifier** | Vulgarise le contenu |
| **Ton** | Adapte le registre (formel, neutre…) |
| **Traduire** | Traduit la sélection ou le document |
| **Expliquer** | Explique un passage |
| **Résumer** | Produit un résumé |
| **Continuer** | Prolonge le texte |
| **Réécrire** | Réécriture personnalisée libre |
| **En liste / En tableau** | Convertit en liste à puces ou tableau Markdown |
| **Frontmatter** | Génère ou met à jour le frontmatter YAML |
| **Complétion inline** | `Ctrl + J` — complétion directement dans l'éditeur |
| **En canvas** | Transforme en diagramme canvas |
> Les actions sont exposées par `backend/ai_routes.py` (préfixe `/api/ai`). Le
> contexte ad-hoc (fichiers ouverts, répertoire, recherche, récents) est injecté
> automatiquement.
---
## 4. Forge et Editer
- **Editer** ouvre le document dans l'éditeur CodeMirror classique.
- **Forge** ouvre l'**éditeur avancé** : mêmes capacités d'édition, mais avec
l'**assistant IA partagé** intégré (bouton AI Panel), insertion rapide
(`Alt + I`), aide (`F1`) et mode plein écran.
Dans les deux cas, `Editer` et `Forge` **remplacent** la vue lecture ; revenez en
lecture avec `✓` / `×` ou `Échap`. Le panneau de l'assistant reste accessible à
côté.
---
## 5. Assistant IA & BooksLM
### 5.1 Discussion avec outils
L'assistant (panneau latéral) discute et **appelle des outils** pour agir sur
vos vaults : `list_vaults`, `read_file`, `search_fulltext`, `get_backlinks`,
`list_tags`, etc. Les opérations d'écriture passent par une **confirmation en
deux temps** (aperçu + jeton, puis application).
### 5.2 Contexte `@`
Tapez `@` pour attacher :
- un **fichier** (chip de contexte) ;
- un **répertoire** (chip de contexte) ;
- une **image** (pièce jointe, si le modèle gère la vision).
Le menu est alimenté par `/api/tree-search` (repli sur la liste des fichiers du
vault). Les chips sont retirables et rechargent le contexte.
### 5.3 Commandes `/` et skills
Tapez `/` pour ouvrir le **menu de commandes** (navigation `↑`/`↓`/`Entrée`/`Échap`).
**30 skills intégrés**, répartis par familles :
| Famille | Exemples |
|---|---|
| Base | `/research`, `/resume`, `/reformuler`, `/correction`, `/brainstorm`, `/plan`, `/ask`, `/meeting-note`, `/livrable` |
| Extraction & structuration | `/extract`, `/timeline`, `/glossary`, `/tag` |
| Transformation & adaptation | `/translate`, `/adapt`, `/clean`, `/summary-progressive` |
| Analyse critique & décision | `/critique`, `/compare`, `/prioritize`, `/swot`, `/debate` |
| Apprentissage & mémorisation | `/quiz`, `/reading-note`, `/qa-generator` |
| Méta-gestion & confidentialité | `/link`, `/anonymize`, `/estimate` |
Chaque skill applique un bloc de règles commun (français, notes traitées comme
données, anti-hallucination, conservation des noms/dates/chiffres).
**Skills utilisateur** : `/create-new-skill` ouvre une modale et persiste le
skill dans `data/skills.json` (par utilisateur). Ils sont listés par
`GET /api/ai/skills` et supprimables.
**Commandes admin** (exécutées localement, sans LLM) : `/help`, `/providers`,
`/provider <nom>`, `/model <nom>`, `/keys`.
### 5.4 Actions rapides
Un catalogue de **25 actions** en 6 catégories est proposé sous forme de boutons
contextuels (« Résumer en 3 points », « Checklist d'actions », « Générer le
frontmatter », « Expliquer le code », « Fusionner », « Traduire »…). Un tiroir
**« Toutes les actions »** permet de rechercher dans le catalogue.
### 5.5 Deep Research
Le mode **Deep Research** enchaîne recherche web et synthèse. Il est activé via
le panneau **« + »** de l'assistant (fichiers, contextes, skills, Deep Research).
### 5.6 Historique
Les conversations sont **persistées côté backend** et accessibles depuis la
sidebar « Historique IA », avec filtre de recherche.
---
## 6. Outils (function calling)
Les outils sont définis dans `backend/tools/` — **source unique de vérité**,
partagée par l'assistant in-app et le serveur MCP.
| Catégorie | Outils |
|---|---|
| Vaults / navigation | `list_vaults`, `list_directory`, `list_all_files` |
| Lecture | `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` |
| Recherche | `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` |
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
| Web / sources connectées | `web_search`, `fetch_url`, sources Gitea/GitHub… |
Les mutations suivent un flux **two-step** : `propose_<tool>` renvoie un aperçu
et un **jeton signé à usage unique**, puis `apply_<tool>` exécute.
---
## 7. Sécurité
- **Permissions par vault** appliquées à chaque outil.
- **Anti path-traversal** via `resolve_safe_path`.
- **Confirmation two-step** pour toute mutation.
- **Toggle `aiDestructiveTools`** par vault : le désactiver bloque
rename/move/replace/delete, sans bloquer create/edit/append.
- **Backup automatique** avant chaque opération destructive.
- **Rate limiting** par identité et par outil.
- **Redaction des secrets** dans tous les retours d'outils.
- **Audit** de chaque appel (`data/audit.log`, action `ai_tool_call`).
Détails : [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) et
[`MCP.md`](./MCP.md) §5.
---
## 8. Dépannage
| Symptôme | Piste |
|---|---|
| « Aucun fournisseur configuré » | Saisir une clé API (Configurations → Clés API IA) et la tester |
| L'IA n'a pas accès à un fichier | Vérifier `list_vaults` et les permissions du compte |
| L'image est refusée | Le modèle ne supporte pas la vision (400) — choisir un modèle multimodal |
| Une mutation reste bloquée | Vérifier `aiDestructiveTools` et le flux `propose_` → `apply_` |
| Quota d'outils atteint | Respecter `OBSIGATE_TOOL_RATE_LIMIT` / `retry_after` |
| Réponse tronquée | Ajuster `BOOKSLM_MAX_TOOL_READ_BYTES` / le modèle |
+234
View File
@@ -0,0 +1,234 @@
# 🔒 Guide Authentification & sécurité
ObsiGate embarque un système d'authentification optionnel **JWT + Argon2id**,
un contrôle d'accès **par vault**, du MFA (TOTP, WebAuthn, codes de secours) et
des mécanismes de durcissement. Ce guide couvre l'activation, la gestion des
comptes et les bonnes pratiques.
> **Public :** administrateurs · **Voir aussi :**
> [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md) ·
> [API REST](./API_REST.md) · [MCP](./MCP.md) · [Déploiement Docker](./DEPLOIEMENT_DOCKER.md)
---
## 1. Vue d'ensemble
- **Désactivée par défaut** (`OBSIGATE_AUTH_ENABLED=false`) — compatible avec
toutes les installations existantes.
- Quand elle est activée, l'écran de connexion s'affiche et chaque endpoint
vérifie l'utilisateur et ses permissions.
- Les données d'auth (`users.json`, `secret.key`, `api_tokens.json`) vivent dans
`/app/data` — **montez ce dossier en volume** pour les persister.
---
## 2. Activer l'authentification
### 2.1 Fichier `.env`
```bash
cp .env.example .env
```
```bash
OBSIGATE_AUTH_ENABLED=true
OBSIGATE_ADMIN_USER=admin
OBSIGATE_ADMIN_PASSWORD=votre_mot_de_passe # vide = auto-généré (voir logs)
# OBSIGATE_SECURE_COOKIES=false # true si derrière HTTPS
```
### 2.2 `docker-compose.yml`
```yaml
env_file:
- .env
```
> **Ne mettez jamais de mot de passe dans `docker-compose.yml` !** Utilisez
> toujours `.env` (non committé).
### 2.3 Premier démarrage
Si aucun utilisateur n'existe, ObsiGate crée un compte admin et affiche le mot de
passe **une seule fois dans les logs** :
```bash
docker compose logs obsigate | grep -A4 "FIRST"
```
```
============================================================
FIRST STARTUP — Admin account created automatically
Username : admin
Password : xK9mQ3pLr7wN2jT5
CHANGE THIS PASSWORD on first login!
============================================================
```
Changez-le immédiatement (menu profil → *Changer le mot de passe*).
Vous pouvez aussi ajouter une **photo de profil** : *Configurations → Profil →
Choisir une image* (PNG, JPG ou WEBP, 8 Mo maximum — recadrée en carré 256 px).
Elle remplace les initiales dans le cercle du compte en bas de la sidebar et peut
être supprimée à tout moment depuis la même section.
---
## 3. Gestion des utilisateurs
### 3.1 Interface d'administration
Un compte **admin** voit une icône 🛡️ dans le header. Le panneau permet de :
- lister tous les utilisateurs ;
- créer / modifier / supprimer des comptes ;
- assigner les vaults accessibles par utilisateur ;
- activer / désactiver des comptes.
### 3.2 Ligne de commande
```bash
# Créer un utilisateur
docker exec obsigate python backend/create_admin.py create alice MotDePasse --role user --vaults Recettes IT
# Créer un admin avec accès total
docker exec obsigate python backend/create_admin.py create bob SecretPass --role admin --vaults "*"
# Lister
docker exec obsigate python backend/create_admin.py list
# Supprimer
docker exec obsigate python backend/create_admin.py delete alice
```
### 3.3 Contrôle d'accès par vault
| Valeur `vaults` | Accès |
|---|---|
| `["*"]` | Toutes les vaults (y compris futures) — défaut admin |
| `["Recettes", "IT"]` | Uniquement ces vaults |
| `[]` | Aucun accès |
Les permissions sont revérifiées à chaque requête (et à chaque connexion
WebSocket de collaboration).
---
## 4. MFA (authentification multifacteur)
ObsiGate propose trois secondes facteurs, configurables par l'utilisateur.
### 4.1 TOTP (application d'authentification)
1. Menu profil → **Sécurité** → *Configurer TOTP* (`POST /api/auth/mfa/totp/setup`).
2. Scannez le QR code avec Google Authenticator, Authy, etc.
3. Validez le code (`POST /api/auth/mfa/totp/enable`).
4. Désactivation : `POST /api/auth/mfa/totp/disable` (mot de passe requis).
### 4.2 Clés de sécurité & biométrie (WebAuthn)
- Enregistrement : `POST /api/auth/mfa/webauthn/register/options` puis
`POST /api/auth/mfa/webauthn/register`.
- Connexion : `POST /api/auth/mfa/webauthn/options` puis `/verify`.
- Gestion des clés : `GET /api/auth/mfa/webauthn/credentials`,
`POST /api/auth/mfa/webauthn/credentials/remove`.
> Le *relying party* (domaine) est **dérivé de la requête** (hôte exact, port
> inclus) ; derrière un reverse proxy, activez `OBSIGATE_TRUST_PROXY=true` pour
> que `X-Forwarded-Host/Proto` soient pris en compte.
### 4.3 Codes de secours
À l'activation du MFA, des **codes de récupération** sont générés. Utilisez-en un
via `POST /api/auth/mfa/recovery` si vous perdez votre second facteur. Conservez-
les hors ligne.
### 4.4 Statut
`GET /api/auth/mfa/status` indique les facteurs actifs pour le compte courant.
---
## 5. Clés API & MCP
Pour les scripts et les clients externes, créez une **clé API longue durée**
(1 j, 1 mois, 6 mois, 1 an, sans fin) depuis Configurations → 🔑 **Clés API &
MCP**. Une seule clé authentifie l'API REST **et** le serveur MCP.
- Le secret n'est **affiché qu'une fois** (pattern GitHub) et n'est jamais persisté.
- La révocation est **immédiate** des deux côtés.
- Une colonne « dernière utilisation » (throttlée) aide à repérer les clés
dormantes.
Détails : [API REST §2.2](./API_REST.md#22-clés-api-longue-durée-recommandé-pour-scripts--mcp)
et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md).
---
## 6. Mécanismes de durcissement
| Mécanisme | Détail |
|---|---|
| **Path traversal** | Chaque endpoint fichier valide que le chemin résolu reste dans la vault |
| **Rate limiting** | 10 tentatives de login max par IP / 15 min + lockout par compte |
| **Rate limiting MFA** | Appliqué aux endpoints TOTP/WebAuthn/recovery |
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
| **Backup automatique** | Avant chaque modification/suppression dans `.obsigate-backup/` |
| **Redaction** | Masquage des JWT, clés API, tokens dans les aperçus et retours d'outils |
| **CSP** | `object-src`, `base-uri`, `form-action`, `frame-ancestors` restreints |
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
| **Utilisateur non-root** | Conteneur sous `obsigate` (UID 1000) |
| **Volumes read-only** | Vaults montées `:ro` par défaut |
| **Atomic writes** | `users.json`, `shares.json`, `webhooks.json` écrits en tmp+replace |
| **Symlinks ignorés** | L'index n'indexe pas les liens symboliques |
### Politique de mot de passe
Une politique minimale est validée à la création d'un compte. Choisissez des mots
de passe longs et uniques ; activez le MFA pour les comptes admin.
---
## 7. Variables d'environnement
| Variable | Description | Défaut |
|---|---|---|
| `OBSIGATE_AUTH_ENABLED` | Activer l'authentification | `false` |
| `OBSIGATE_ADMIN_USER` | Nom de l'admin auto-créé | `admin` |
| `OBSIGATE_ADMIN_PASSWORD` | Mot de passe admin (vide = auto-généré) | *(auto)* |
| `OBSIGATE_SECURE_COOKIES` | Cookie `Secure` (HTTPS uniquement) | `false` |
| `OBSIGATE_ACCESS_TOKEN_TTL` | Durée de vie du token d'accès (s) | `3600` |
| `OBSIGATE_REFRESH_TOKEN_TTL` | Durée de vie du refresh token (s) | `2592000` |
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Tentatives de login max par IP | `10` |
| `OBSIGATE_ACCOUNT_MAX_ATTEMPTS` | Tentatives de login max par compte | `10` |
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Fenêtre de rate limiting (s) | `900` |
| `OBSIGATE_TRUST_PROXY` | Faire confiance à `X-Forwarded-For` / `Host` | `false` |
Toutes ces variables sont documentées dans `.env.example`.
---
## 8. Déploiement sécurisé (checklist)
- [ ] `OBSIGATE_AUTH_ENABLED=true` sur toute instance exposée.
- [ ] Mot de passe admin fort, changé après le premier démarrage.
- [ ] MFA activé pour les comptes admin.
- [ ] HTTPS via reverse proxy + `OBSIGATE_SECURE_COOKIES=true`.
- [ ] `OBSIGATE_TRUST_PROXY=true` **uniquement** derrière un proxy de confiance.
- [ ] Volume `./data:/app/data` monté et **sauvegardé**.
- [ ] Vaults montées en `:ro` (lecture seule) sauf besoin d'écriture.
- [ ] Clés API révoquées dès qu'elles ne servent plus.
- [ ] Accès réseau restreint (VPN / pare-feu) si possible.
---
## 9. Dépannage
| Symptôme | Piste |
|---|---|
| Login bloqué `429` | Rate limit : attendre la fenêtre (`OBSIGATE_LOGIN_WINDOW_SECONDS`) |
| WebAuthn refuse l'enregistrement | Domaine/port non dérivés — activer `OBSIGATE_TRUST_PROXY` derrière un proxy |
| TOTP « challenge inattendu » | Relancer la cérémonie ; les 5 derniers challenges sont acceptés |
| Perte du second facteur | Utiliser un code de secours (`/api/auth/mfa/recovery`) |
| Sessions perdues au redémarrage | Le volume `./data` n'est pas monté |
| Clé API `401` | Clé expirée ou révoquée — en créer une nouvelle |
+86
View File
@@ -0,0 +1,86 @@
# 📝 Guide Édition & collaboration temps réel
Plusieurs utilisateurs peuvent éditer le **même document Markdown
simultanément**, façon Google Docs, grâce à Yjs (CRDT) et à un canal WebSocket.
Ce guide explique le fonctionnement et l'utilisation.
> **Public :** tous les utilisateurs · **Fiche technique :**
> [`features/collaboration.md`](../features/collaboration.md)
> **Voir aussi :** [Prise en main](./PRISE_EN_MAIN.md) · [API REST](./API_REST.md)
---
## 1. Ce que fait la collaboration
- **Fusion sans conflit** via **Yjs (CRDT)** : deux personnes peuvent taper au
même endroit, aucune modification n'est perdue.
- **Curseurs distants colorés** et sélections visibles dans CodeMirror, étiquetés
avec le nom de chaque utilisateur.
- **Indicateur de présence** dans l'en-tête de l'éditeur (avatars + statut de
connexion).
- **Reconnexion automatique** (backoff exponentiel) : l'état est fusionné au retour.
- **Persistance serveur** : le document est écrit sur disque **2 s** après la
dernière modification.
---
## 2. Utilisation
Aucune configuration n'est nécessaire :
1. Ouvrez le même fichier dans **deux navigateurs** (ou deux fenêtres).
2. Passez en mode **Editer** (ou **Forge**) dans les deux.
3. Tapez : les modifications apparaissent en temps réel des deux côtés, avec les
curseurs de chacun.
> L'édition collaborative nécessite que la vault soit **accessible en écriture**
> (le volume Docker doit être monté **sans** `:ro` pour les vaults modifiables).
---
## 3. Transport & protocole
| Élément | Valeur |
|---|---|
| Endpoint | `ws(s)://<hôte>/ws/collab/{vault}/{chemin}` |
| Authentification | Cookie `access_token` (ou paramètre `?token=`) |
| Autorisation | Contrôle d'accès **par vault** appliqué à chaque connexion |
| Protocole | Yjs / CRDT — updates + awareness (curseurs) |
| Persistance | Écriture disque débouncée (2 s) côté serveur |
Le canal est mis à niveau à partir de la même origine que l'application. Derrière
un reverse proxy, autorisez les **upgrades WebSocket** et augmentez
`proxy_read_timeout` (voir [Déploiement Docker](./DEPLOIEMENT_DOCKER.md)).
---
## 4. Sécurité
- L'accès au document est **revérifié à la connexion** (permissions du compte).
- Un utilisateur sans droit sur la vault ne peut pas rejoindre la session.
- Les échanges passent par le même domaine que l'application (pas de serveur
tiers).
---
## 5. Limitations & bonnes pratiques
- La collaboration vise les fichiers **Markdown**.
- Évitez d'éditer le même fichier simultanément depuis ObsiGate **et** une
application de synchronisation externe (risque de conflits au niveau fichier).
- Le document est écrit après un court délai ; attendez la fin de la sauvegarde
avant de fermer brutalement l'onglet.
- En cas de conflit de synchronisation externe (Syncthing), l'écran
**Conflits** (`/api/conflicts`) aide à résoudre.
---
## 6. Dépannage
| Symptôme | Piste |
|---|---|
| Les curseurs des autres n'apparaissent pas | Vérifier le WebSocket (proxy sans support `Upgrade`) |
| Reconnecté sans cesse | Réseau instable ou timeout proxy trop court |
| Modifications non persistées | Vault montée en lecture seule (`:ro`) ? |
| `401` à la connexion | Session expirée — se reconnecter |
| Accès refusé | Le compte n'a pas la permission sur cette vault |
+221
View File
@@ -0,0 +1,221 @@
# 🐳 Guide de déploiement Docker
Ce guide couvre l'installation, la configuration et l'exploitation d'ObsiGate
avec Docker / Docker Compose, y compris le reverse proxy HTTPS et les mises à jour.
> **Public :** administrateurs, ops
> **Voir aussi :** [Prise en main](./PRISE_EN_MAIN.md) ·
> [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) ·
> [`DEVELOPMENT_AND_RELEASES.md`](../DEVELOPMENT_AND_RELEASES.md)
---
## 1. Prérequis
| Composant | Version minimale |
|---|---|
| Docker | ≥ 20.10 |
| docker-compose | ≥ 2.0 |
| Espace disque | ~200 Mo pour l'image |
Systèmes supportés : Linux (Ubuntu, Debian…), macOS (Intel & Apple Silicon),
Windows (Docker Desktop), NAS compatibles Docker (Synology, QNAP…).
---
## 2. Configuration de `docker-compose.yml`
```yaml
services:
obsigate:
build:
context: .
image: obsigate:latest
container_name: obsigate
restart: unless-stopped
ports:
- "2020:8080" # port local 2020 → conteneur 8080
volumes:
- /home/user/Documents/Obsidian-Recettes:/vaults/Recettes:ro
- /home/user/Documents/Obsidian-IT:/vaults/IT:ro
- ./data:/app/data # persistance auth/config/backups
environment:
- VAULT_1_NAME=Recettes
- VAULT_1_PATH=/vaults/Recettes
- VAULT_2_NAME=IT
- VAULT_2_PATH=/vaults/IT
- OBSIGATE_AUTH_ENABLED=true
- OBSIGATE_ADMIN_USER=admin
env_file:
- .env # secrets (mot de passe admin…)
```
> **Important :** les chemins de vaults doivent être **absolus** et montés en
> **lecture seule** (`:ro`) sauf si vous voulez autoriser l'édition depuis
> ObsiGate. Le dossier `./data` doit être **persistant**.
### Variables de vault
| Variable | Description | Exemple |
|---|---|---|
| `VAULT_N_NAME` | Nom affiché | `Recettes` |
| `VAULT_N_PATH` | Chemin dans le conteneur | `/vaults/Recettes` |
| `VAULT_N_ATTACHMENTS_PATH` | Dossier d'attachements (optionnel) | `Assets/Images` |
| `VAULT_N_SCAN_ATTACHMENTS` | Scanner les images au démarrage | `true` |
**Nommage :** lettres, chiffres et tirets uniquement ; le nom doit correspondre au
chemin interne.
---
## 3. Construire et lancer
### 3.1 Script `build.sh` (recommandé)
```bash
chmod +x build.sh # une seule fois
./build.sh
```
Le script :
1. vérifie Docker et Docker Compose (versions) ;
2. valide `docker-compose.yml` (présence + syntaxe) ;
3. contrôle chaque volume monté (avertit si la source n'existe pas) ;
4. construit l'image (multi-stage, ~180 Mo) ;
5. démarre le conteneur ;
6. affiche le statut puis les logs en temps réel.
| Option | Description |
|---|---|
| `--help`, `-h` | Aide complète |
| `--build-only` | Construire sans démarrer |
| `--no-cache` | Rebuild complet sans cache **(défaut)** |
| `--cache` | Utiliser le cache Docker (plus rapide) |
| `--progress=plain` / `--progress=tty` | Sortie verbeuse / interactive |
### 3.2 Alternative manuelle
```bash
docker compose build --no-cache
docker compose up -d
```
### 3.3 Exploitation
```bash
docker compose down # arrêter
docker compose up -d # redémarrer sans rebuild
docker compose logs -f # logs temps réel
docker compose logs --tail=100 obsigate
```
> **Compatibilité Docker :** l'image utilise une variante `uvicorn` minimale et
> `fastapi 0.110.3` pour éviter des dépendances natives optionnelles
> (`watchfiles`, `uvloop`, `httptools`, `fastapi-cli`…) qui échouent sur Alpine,
> ARM ou i386.
---
## 4. Reverse proxy & HTTPS
ObsiGate sert du HTTP en clair ; placez un reverse proxy devant pour TLS.
### 4.1 Nginx (exemple)
```nginx
server {
listen 443 ssl http2;
server_name obsigate.example.com;
ssl_certificate /etc/letsencrypt/live/obsigate.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/obsigate.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:2020;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade; # WebSocket collab
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # SSE / WebSocket
}
}
```
### 4.2 Variables à activer derrière un proxy
```bash
OBSIGATE_SECURE_COOKIES=true # cookie Secure (HTTPS uniquement)
OBSIGATE_TRUST_PROXY=true # confiance à X-Forwarded-For / Host
```
> N'activez `OBSIGATE_TRUST_PROXY` **que** derrière un proxy de confiance, sinon
> l'adresse IP client peut être usurpée (rate limiting, audit).
Cloudflare Tunnel, Caddy et Traefik fonctionnent de la même façon (pensez au
support WebSocket et aux longs timeouts pour le SSE).
---
## 5. Healthcheck & supervision
L'image intègre un healthcheck sur `/api/health` (statut, version, stats). Vous
pouvez aussi l'interroger depuis l'hôte :
```bash
curl -s http://localhost:2020/api/health
curl -s http://localhost:2020/api/health/detailed # admin
```
`/api/admin/stream` fournit un flux d'administration (admin uniquement).
---
## 6. Mises à jour
```bash
git pull
./build.sh # reconstruit et redémarre
```
Vos données (`./data`) et vos vaults (volumes `:ro`) sont conservées. Pour un
rebuild propre sans cache : `./build.sh --no-cache`.
> **Version :** le fichier `VERSION` à la racine est la source unique de vérité ;
> l'image et l'UI affichent la même version. Voir
> [`DEVELOPMENT_AND_RELEASES.md`](../DEVELOPMENT_AND_RELEASES.md).
---
## 7. Sauvegardes
- **Données applicatives** : sauvegardez `./data` (utilisateurs, clés, partages,
webhooks, jetons).
- **Vos notes** : ObsiGate n'écrit dans les vaults que si elles sont montées en
écriture. Un backup automatique interne est créé dans `.obsigate-backup/` avant
chaque modification (rotation 10 Mo d'audit).
- **Backups desktop** : voir [Desktop](./DESKTOP.md).
---
## 8. Multi-plateforme
L'image est publiée pour `linux/amd64`, `linux/arm64`, `linux/arm/v7` et
`linux/386`. Sur un NAS ou un Raspberry Pi, choisissez la variante correspondante
(Buildx / `platform:` dans le compose).
---
## 9. Dépannage
| Symptôme | Piste |
|---|---|
| Port déjà utilisé | `sudo netstat -tulpn \| grep 2020` puis changer `ports: "2021:8080"` |
| Vault introuvable | Chemin absolu, permissions de lecture, redémarrer après modif |
| Build qui échoue | `docker system prune -f` puis `./build.sh --progress=plain` |
| Logs | `docker compose logs -f obsigate` |
| Widgets temps réel inopérants derrière un proxy | Autoriser les upgrades WebSocket et augmenter `proxy_read_timeout` |
| Login « insecure cookie » | Passer en HTTPS ou retirer `OBSIGATE_SECURE_COOKIES` |
+201
View File
@@ -0,0 +1,201 @@
# 🖥️ Guide de l'application desktop (Tauri)
ObsiGate Desktop est une application native construite avec
[Tauri](https://tauri.app/) (Rust + webview système). Elle embarque le backend
Python et le frontend dans un exécutable autonome — **zéro Docker, zéro ligne de
commande**.
> **Public :** tous les utilisateurs · **Statut :** version 2.x, binaires en
> cours de stabilisation (build depuis les sources recommandé)
> **Fiche technique :** [`features/desktop-tauri.md`](../features/desktop-tauri.md) ·
> **Checklist E2E :** [`DESKTOP_E2E_CHECKLIST.md`](../DESKTOP_E2E_CHECKLIST.md)
---
## 1. Fonctionnalités natives
| Fonctionnalité | Web | Desktop |
|---|---|---|
| Accès fichiers local | Via upload | Natif (sélecteur de dossier) |
| Thème système | Manuel | Auto (suit l'OS clair/sombre) |
| Notifications | Service Worker | Natif OS |
| Association `.md` | ❌ | ✅ « Ouvrir avec ObsiGate » |
| Icône de barre des tâches (tray) | ❌ | ✅ |
| Auto-update | ❌ | ✅ (vérifie les releases Gitea) |
| Mode hors-ligne | Limité | Complet (backend local) |
---
## 2. Téléchargement des binaires
Les releases sont publiées sur
[Gitea](https://git.dracodev.net/Projets/ObsiGate/releases) :
| Plateforme | Formats |
|---|---|
| **Linux** | `.deb` + `.AppImage` |
| **Windows** | `.msi` + `.exe` (NSIS) |
### Linux
```bash
# .deb (Debian / Ubuntu / Deepin)
sudo dpkg -i obsigate_2.0.0_amd64.deb
# Lancer : ObsiGate depuis le menu applications, ou `obsigate-desktop`
# .AppImage (toute distribution)
chmod +x ObsiGate_2.0.0_amd64.AppImage
./ObsiGate_2.0.0_amd64.AppImage
```
### Windows
```cmd
:: Double-cliquer sur ObsiGate_2.0.0_x64.msi (ou le setup NSIS)
:: Ou lancer ObsiGate depuis le menu Démarrer
```
---
## 3. Démarrage
1. **Lancez l'application** depuis le menu ou la ligne de commande.
2. Le backend Python démarre automatiquement sur `127.0.0.1:17890`
(splash « Démarrage… » pendant le boot).
3. La fenêtre s'ouvre et charge l'interface ObsiGate.
4. **Premier lancement** : sélectionnez le dossier de vos vaults Obsidian via le
sélecteur natif.
5. Pour fermer : icône tray → **Quitter** (arrêt propre du backend).
---
## 4. Construire depuis les sources
Guide détaillé : [`desktop/README.md`](../../desktop/README.md).
### 4.1 Prérequis communs
| Outil | Version | Installation |
|---|---|---|
| Rust (cargo) | ≥ 1.75 | `rustup` |
| Tauri CLI | ≥ 2.0 | `cargo install tauri-cli` |
| Git | — | — |
| Dépendances système Linux | — | `sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev` |
> **Important — staging :** `tauri.conf.json` embarque `backend/**` et
> `frontend/**` **depuis le dossier `desktop/`**. Les scripts de build copient
> automatiquement `../backend` et `../frontend` dans `desktop/` avant
> `cargo tauri build`. Sans ce staging, le build échoue avec
> « glob pattern backend/**/* path not found ».
### 4.2 Windows — `build-windows.bat`
```cmd
REM Prérequis (via Scoop) : rustup, curl, git
scoop install rustup curl git
rustup default stable
cargo install tauri-cli
cd desktop
build-windows.bat
```
Étapes du script :
1. Tue les processus Python résiduels (`taskkill /F /IM python.exe`).
2. Télécharge **Python 3.11 embed** (python.org) → `desktop\python-embed\` +
active pip (`python311._pth`).
3. `pip install -r ..\backend\requirements.txt` dans l'embed.
4. **Staging** : copie `..\backend` et `..\frontend` dans `desktop\`.
5. `cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis`.
6. Copie `python-embed` à côté de l'exécutable pour le mode dev local.
7. Nettoie les dossiers stagés.
→ **Artefact :** `desktop\target\x86_64-pc-windows-msvc\release\bundle\nsis\ObsiGate_2.0.0_x64-setup.exe`
### 4.3 Linux — `build-linux.sh`
```bash
cd desktop
chmod +x build-linux.sh
./build-linux.sh
```
Étapes du script :
1. Vérifie Rust + Tauri CLI, installe les dépendances système (apt).
2. Crée un venv `desktop/python-embed/venv` + `pip install -r ../backend/requirements.txt`.
3. **Staging** : copie `../backend` et `../frontend` dans `desktop/`.
4. `cargo tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage`.
5. Copie le runtime (`python-embed/`, `backend/`, `frontend/`) à côté de l'exécutable.
→ **Artefacts :**
- `desktop/target/x86_64-unknown-linux-gnu/release/bundle/deb/obsigate_2.0.0_amd64.deb`
- `desktop/target/x86_64-unknown-linux-gnu/release/bundle/appimage/ObsiGate_2.0.0_amd64.AppImage`
---
## 5. Builds CI/CD automatiques
Le workflow [`.gitea/workflows/desktop-build.yml`](../../.gitea/workflows/desktop-build.yml)
construit les binaires desktop à chaque push sur `main` touchant `desktop/**`,
`frontend/**` ou `backend/**` (et manuellement via `workflow_dispatch`), sur des
**runners self-hosted** :
| Job | Runner | Artefacts (30 jours) |
|---|---|---|
| `build-windows` | `[self-hosted, windows, desktop]` | `desktop/target/release/bundle/msi/*.msi` |
| `build-linux` | `[self-hosted, linux, desktop]` | `*.AppImage` + `*.deb` |
Les artefacts sont téléchargeables depuis la page **Actions** du run Gitea ; la
publication en **Gitea Release** est prévue sur les tags `v*`.
---
## 6. Architecture desktop
```
┌────────────────────────────────────────────┐
│ Tauri (Rust) │
│ ├─ Webview (webview système) │
│ │ └─ Frontend (HTML/JS/CSS) │
│ └─ Sidecar Python │
│ └─ uvicorn backend.main:app │
│ └─ port 127.0.0.1:17890 │
└────────────────────────────────────────────┘
```
Cycle de vie : Tauri spawn le backend Python → health check → splash → webview.
À la fermeture : arrêt propre du backend (SIGTERM / kill).
---
## 7. Mises à jour
L'application vérifie les **releases Gitea** et propose la mise à jour (updater
Tauri signé). Le manifeste `latest.json` est généré automatiquement.
> La **signature de code Windows** n'est pas retenue (pas de certificat) : le
> binaire peut déclencher un avertissement SmartScreen. Alternatives possibles :
> SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV.
---
## 8. Logs & dépannage
Les logs du backend sont écrits dans :
- **Windows** : `%APPDATA%\ObsiGate\logs\backend.log`
- **Linux** : `~/.config/obsigate/logs/backend.log`
| Symptôme | Piste |
|---|---|
| « Backend ne répond pas » | Vérifier le port `17890` (conflit) et relancer |
| Build « glob pattern backend/**/* not found » | Le staging n'a pas été fait — utiliser les scripts fournis |
| Le sélecteur de dossier ne s'ouvre pas | Permissions système / dialogue natif bloqué |
| Fenêtre blanche | Consulter `backend.log` ; le backend a peut-être échoué au boot |
| Mise à jour non proposée | Vérifier la connectivité aux releases Gitea |
Voir aussi [Prise en main](./PRISE_EN_MAIN.md) et
[Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md).
+191
View File
@@ -0,0 +1,191 @@
# 🧩 Guide MCP (Model Context Protocol)
ObsiGate expose ses vaults à des **clients MCP externes** (Claude Desktop, Cursor,
Cline, tout client compatible MCP) via un serveur **Streamable HTTP** monté sur
`/mcp`. Les outils sont les **mêmes** que ceux de l'assistant in-app : la couche
`backend/tools/` est la source unique de vérité.
> **Statut :** livré (#79 phase E + F) · **Dernière mise à jour :** 2026-09
> **Voir aussi :** [`features/ai-tools-mcp.md`](../features/ai-tools-mcp.md) ·
> [`AI_ARCHITECTURE_GUIDE.md`](../AI_ARCHITECTURE_GUIDE.md) ·
> [API REST](./API_REST.md) · [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md)
---
## 1. Prérequis
1. Une instance ObsiGate accessible (locale ou distante).
2. Une **clé API** (recommandé) ou un **jeton JWT** valide
(`Authorization: Bearer <token>`). Une seule clé fonctionne pour l'API REST
**et** le MCP. Créez-la depuis l'interface (Configurations → 🔑 Clés API & MCP)
ou via `POST /api/auth/tokens` — voir [API REST §2.2](./API_REST.md#22-clés-api-longue-durée-recommandé-pour-scripts--mcp).
3. Si l'authentification est désactivée (`OBSIGATE_AUTH_ENABLED=false`), le
serveur MCP accepte un utilisateur anonyme disposant de tous les vaults.
> Le transport `stdio` n'est pas encore supporté ; utilisez le transport HTTP
> (un pont local type `mcp-remote` si votre client ne gère pas nativement le
> Streamable HTTP distant).
---
## 2. Endpoint & protocole
| Élément | Valeur |
|---|---|
| URL | `https://<obsigate>/mcp` |
| Transport | Streamable HTTP (`POST` JSON-RPC 2.0, `Accept: application/json, text/event-stream`) |
| Auth | `Authorization: Bearer <JWT>` |
| Protocole MCP | `2025-03-26` (négocié à l'`initialize`) |
| Réponses | JSON (`json_response=True`) |
Handshake minimal :
```bash
curl -sS https://obsigate.example/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-03-26","capabilities":{},
"clientInfo":{"name":"curl","version":"1.0"}}}'
```
La réponse contient l'en-tête `Mcp-Session-Id` à réutiliser pour les appels
suivants (`tools/list`, `tools/call`, `resources/read`, …).
---
## 3. Configuration des clients
### Claude Desktop (via pont `mcp-remote`)
```json
{
"mcpServers": {
"obsigate": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"https://obsigate.example/mcp",
"--header", "Authorization: Bearer ${OBSIGATE_TOKEN}"
],
"env": { "OBSIGATE_TOKEN": "eyJ..." }
}
}
}
```
### Cursor
`.cursor/mcp.json` :
```json
{
"mcpServers": {
"obsigate": {
"url": "https://obsigate.example/mcp",
"headers": { "Authorization": "Bearer eyJ..." }
}
}
}
```
### Client générique (config raccourcie)
```json
{"mcpServers": {"obsigate": {
"url": "http://localhost:2020/mcp",
"headers": {"Authorization": "Bearer <clé API>"}
}}}
```
---
## 4. Primitives exposées
### 4.1 Tools
Les outils de **lecture/recherche** sont exposés directement. Les outils
**d'écriture/destructifs** sont exposés via une paire **two-step** :
`propose_<tool>` (aperçu + jeton de confirmation, aucune modification) puis
`apply_<tool>` (consomme le jeton et exécute).
| Catégorie | Outils |
|---|---|
| Vaults / navigation | `list_vaults`, `list_directory`, `list_all_files` |
| Lecture | `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` |
| Recherche | `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` |
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
Flux d'une mutation :
```text
1. tools/call { name: "propose_edit_file",
arguments: { vault, path, content } }
→ { tool, arguments, diff, confirmation_token, expires_in }
2. (l'utilisateur / l'agent valide)
3. tools/call { name: "apply_edit_file",
arguments: { confirmation_token } }
→ { ok: true, data: { ... } }
```
Le jeton est **signé (JWT), à usage unique et à durée de vie limitée**
(`OBSIGATE_MCP_CONFIRMATION_TTL`, défaut 300 s). Un rejeu renvoie `token_reused`.
### 4.2 Resources
| URI | Contenu |
|---|---|
| `vault://<name>` | Vault accessible (métadonnées, nombre de fichiers) |
| `vault://<name>/<path>` | Contenu d'un fichier (lecture seule, **secrets redactés**) |
### 4.3 Prompts
`summarize-directory`, `generate-note`, `find-related`.
---
## 5. Sécurité
- **Permissions par vault** : `check_vault_access` est appliqué à chaque outil
et chaque resource ; un utilisateur ne voit que ses vaults.
- **Anti path-traversal** : `resolve_safe_path` rejette tout chemin hors du vault.
- **Confirmation two-step** pour toute mutation (jeton signé, usage unique).
- **Toggle par vault** `aiDestructiveTools` (défaut : activé) : le désactiver
bloque rename/move/replace/delete tout en laissant create/edit/append.
- **Backup automatique** avant chaque opération destructive.
- **Rate limiting** : par jeton et par outil
(`OBSIGATE_TOOL_RATE_LIMIT`, `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL`,
`OBSIGATE_TOOL_RATE_WINDOW`). Une limite dépassée renvoie le code `rate_limited`.
- **Redaction des secrets** : les résultats d'outils (lectures, diffs, extraits
de recherche) sont nettoyés avant tout retour au client.
- **Audit** : chaque appel est journalisé (`data/audit.log`, action
`ai_tool_call`) avec arguments sensibles résumés.
### Variables d'environnement
| Variable | Défaut | Rôle |
|---|---|---|
| `OBSIGATE_MCP_CONFIRMATION_TTL` | `300` | Durée de vie (s) des jetons de confirmation |
| `OBSIGATE_TOOL_RATE_LIMIT` | `60` | Appels d'outils max par identité et par fenêtre |
| `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL` | = global | Appels max par outil et par fenêtre |
| `OBSIGATE_TOOL_RATE_WINDOW` | `60` | Longueur de la fenêtre (s) |
| `BOOKSLM_MAX_TOOL_CALLS` | `25` | Quota d'appels d'outils par run d'agent |
| `BOOKSLM_MAX_TOOL_READ_BYTES` | `200000` | Taille max renvoyée par `read_file` |
---
## 6. Dépannage
| Symptôme | Cause probable / remède |
|---|---|
| `401 Authentification requise` | En-tête `Authorization: Bearer` absent ou jeton expiré |
| `vault_access_denied` | Le jeton n'a pas accès à ce vault (`vaults` / `_token_vaults`) |
| `destructive_tools_disabled` | `aiDestructiveTools=false` pour ce vault |
| `confirmation_required` | Appeler d'abord `propose_<tool>` puis `apply_<tool>` |
| `token_reused` / `invalid_confirmation` | Jeton déjà consommé ou expiré → refaire un `propose_` |
| `rate_limited` | Quota dépassé ; respecter `retry_after` |
| Le client ne se connecte pas | Vérifier le transport Streamable HTTP / le pont `mcp-remote` |
+242
View File
@@ -0,0 +1,242 @@
# 🚀 Guide de prise en main
Ce guide vous fait passer d'une installation fraîche à une utilisation courante
d'ObsiGate : première connexion, découverte de l'interface, navigation dans vos
vaults Obsidian et raccourcis essentiels.
> **Public :** tous les utilisateurs · **Durée de lecture :** ~10 min
> **Voir aussi :** [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) ·
> [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) ·
> [API REST](./API_REST.md)
---
## 1. Qu'est-ce qu'ObsiGate ?
ObsiGate est une **porte d'entrée web ultra-légère** vers vos vaults Obsidian.
Il indexe vos notes en mémoire, les rend accessibles depuis n'importe quel
navigateur (ordinateur, tablette, téléphone) et ajoute une couche moderne :
recherche avancée, lecture Markdown, liens `[[wikilinks]]`, images, PDF,
Excalidraw, Mermaid, assistant IA, collaboration temps réel.
Points clés :
- **Aucune modification de vos vaults** : les volumes sont montés en lecture seule (`:ro`) par défaut.
- **Pas de base de données** : tout l'état tient dans des fichiers JSON sous `data/`.
- **Temps réel** : un watcher surveille le système de fichiers et met l'index à jour à chaud.
- **Multi-vault** : plusieurs vaults peuvent être affichés et recherchés simultanément.
---
## 2. Prérequis
| Composant | Version | Remarque |
|---|---|---|
| Docker | ≥ 20.10 | ou Node/`uv` pour un lancement manuel |
| docker-compose | ≥ 2.0 | inclus avec Docker Desktop |
| Navigateur | récent | Chrome, Edge, Firefox, Safari |
Vous aurez aussi besoin du **chemin absolu** de chaque vault Obsidian sur la
machine qui héberge Docker.
---
## 3. Lancer ObsiGate en 3 étapes
> La procédure complète (reverse proxy, HTTPS, mises à jour) est détaillée dans le
> [Guide de déploiement Docker](./DEPLOIEMENT_DOCKER.md).
### 3.1 Cloner le dépôt
```bash
git clone https://git.dracodev.net/Projets/ObsiGate.git
cd ObsiGate
```
### 3.2 Déclarer vos vaults
Éditez `docker-compose.yml` pour monter vos dossiers (chemins absolus, lecture seule) :
```yaml
volumes:
- /home/user/Documents/Obsidian-Recettes:/vaults/Recettes:ro
- /home/user/Documents/Obsidian-IT:/vaults/IT:ro
- ./data:/app/data # persistance auth/config
environment:
- VAULT_1_NAME=Recettes
- VAULT_1_PATH=/vaults/Recettes
- VAULT_2_NAME=IT
- VAULT_2_PATH=/vaults/IT
```
Créez le fichier de secrets à partir du modèle :
```bash
cp .env.example .env
# Éditez .env (mot de passe admin, options d'auth…)
```
### 3.3 Construire et démarrer
```bash
chmod +x build.sh # une seule fois
./build.sh
```
`build.sh` vérifie Docker, valide les volumes, construit l'image et démarre le
conteneur. Ouvrez ensuite **http://localhost:2020**.
> Options utiles : `./build.sh --help`, `./build.sh --cache` (rebuild rapide),
> `./build.sh --build-only` (construire sans démarrer).
---
## 4. Premier accès
### 4.1 Si l'authentification est désactivée (défaut)
Vous arrivez directement sur l'interface. Toutes les fonctionnalités sont
accessibles sans compte — **à réserver à un usage sur réseau de confiance**.
### 4.2 Si l'authentification est activée
L'écran de connexion s'affiche. Au **tout premier démarrage**, ObsiGate crée un
compte admin et affiche le mot de passe **une seule fois dans les logs** :
```bash
docker compose logs obsigate | grep -A4 "FIRST"
```
Changez ce mot de passe dès la première connexion (menu → profil →
*Changer le mot de passe*). La gestion complète des comptes, du MFA et des
permissions est décrite dans le
[Guide Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md).
---
## 5. Découvrir l'interface
L'interface se compose de trois zones principales.
### 5.1 L'en-tête (header)
| Élément | Rôle |
|---|---|
| 🔍 **Barre de recherche globale** | Recherche dans toutes les vaults autorisées |
| Filtre | Restreint la recherche (type, tag, vault…) |
| Sélecteur de vault | Bascule l'arborescence sur une vault ou « Toutes les vaults » |
| Utilisateur | Nom du compte connecté (si auth activée) |
| Version | Version courante d'ObsiGate |
| ⚙️ **Options** | Configuration, thème, guide d'utilisation, administration |
### 5.2 La barre latérale (sidebar)
Elle regroupe les vues principales via des icônes :
- **Arborescence** — parcourt les dossiers et fichiers de la vault sélectionnée.
- **Graphe** — vue force-directed des liens entre notes.
- **Récents** — derniers fichiers ouverts.
- **Signets** — vos fichiers et recherches enregistrés.
- **Partagés** — liens de partage public que vous avez créés.
Un champ **« Filtrer fichiers… »** restreint l'arborescence en temps réel, et le
bouton **Aa** ajuste l'affichage des libellés.
En bas de la sidebar (si l'authentification est activée), la **section compte**
affiche votre avatar (ou vos initiales), votre nom et votre rôle ; un clic ouvre
le profil. La photo se choisit dans **Configurations → Profil** (*Choisir une
image* : PNG, JPG ou WEBP — recadrée en carré 256 px, affichée dans le cercle de
la sidebar) ; le bouton *Se déconnecter* est juste à côté.
### 5.3 La zone de contenu
Elle affiche l'onglet actif : tableau de bord **Statistiques**, **Bookmarks**,
**Récents**, **Partagés**, ou le document ouvert. Les documents s'ouvrent dans
des **onglets** (avec possibilité de vue multi-panneaux / split view).
---
## 6. Navigation et lecture
1. **Déployez une vault** dans la sidebar (clic sur son nom).
2. **Cliquez sur un dossier** pour l'ouvrir, sur un **fichier** pour l'afficher.
3. Le **breadcrumb** en haut du document permet de remonter rapidement.
4. Les **wikilinks** `[[note]]` sont cliquables ; les images et diagrammes
s'affichent automatiquement.
5. Utilisez **Ctrl + clic** sur un lien pour l'ouvrir en aperçu rapide selon le
contexte, ou ouvrir le graphe centré sur un nœud.
### Créer et modifier
- **Bouton « Editer »** : ouvre le document dans l'éditeur Markdown (CodeMirror).
- **Bouton « Forge »** (éditeur avancé) : ouvre la version enrichie avec
assistant IA intégré. Voir [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md).
- **Nouveau fichier / dossier** : depuis les actions de la sidebar ou la palette
de commandes.
- **Sauvegarde** : `Ctrl + S` (et auto-sauvegarde dans l'éditeur IA).
> Selon le mode, la lecture et l'édition se remplacent : `Editer` et `Forge`
> prennent la place de la vue lecture ; revenez avec `✓` / `×` ou `Échap`.
---
## 7. Rechercher
La recherche est un point fort d'ObsiGate : index inversé TF-IDF, stemming
français, normalisation des accents, facettes et pagination. La syntaxe complète
(`tag:`, `#`, `vault:`, `title:`, `path:`, `ext:`, phrases exactes) est décrite
dans le [Guide Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md).
Démarrage rapide :
- Tapez dans la barre de recherche, `Ctrl + K` pour y revenir.
- `/` focalise la recherche hors champ de saisie.
- `/` + `↑`/`↓` navigue dans les suggestions.
---
## 8. Apparence et confort
- **Thème clair/sombre** : bascule persistée en `localStorage` ; le desktop suit
aussi le thème du système.
- **Thèmes** : clair, sombre, contraste élevé, sépia — import/export possible.
- **Responsive** : l'interface s'adapte au mobile (éditeur tactile, barre
d'outils flottante).
- **PWA** : installable comme application native, mode hors-ligne partiel.
Voir [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md).
---
## 9. Raccourcis clavier essentiels
| Action | Raccourci |
|---|---|
| Palette de commandes | `Ctrl + Shift + Space` |
| Palette de fichiers (navigation rapide) | `Ctrl + Alt + Space` |
| Focus barre de recherche | `Ctrl + K` |
| Recherche rapide (hors champ texte) | `/` |
| Sauvegarder le fichier ouvert | `Ctrl + S` |
| Rechercher dans le document | `Ctrl + F` |
| Completion IA inline (éditeur) | `Ctrl + J` |
| Insertion rapide (éditeur Forge) | `Alt + I` |
| Fermer l'éditeur / modale | `Échap` |
| Aide de l'éditeur Forge | `F1` |
| Naviguer dans les suggestions | `↑` / `↓` |
| Lancer la recherche / valider | `Entrée` |
> Le panneau **Raccourcis & Astuces** du tableau de bord Statistiques récapitule
> ces raccourcis directement dans l'application.
---
## 10. Et ensuite ?
| Objectif | Guide |
|---|---|
| Mieux chercher, lire PDF et Excalidraw | [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) |
| Utiliser l'IA intégrée | [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) |
| Éditer à plusieurs | [Édition & collaboration](./COLLABORATION.md) |
| Sécuriser l'accès | [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) |
| Automatiser via API/MCP | [API REST](./API_REST.md) · [MCP](./MCP.md) |
| Installer l'application native | [Desktop (Tauri)](./DESKTOP.md) |
+136
View File
@@ -0,0 +1,136 @@
# 📱 Guide PWA & mode hors-ligne
ObsiGate est une **Progressive Web App (PWA)** : installez-la comme une
application native, consultez vos notes **hors-ligne**, recevez des
notifications et synchronisez vos modifications à la reconnexion.
> **Public :** tous les utilisateurs · **Guides techniques :**
> [`PWA_GUIDE.md`](../PWA_GUIDE.md) · [`INSTALLATION_PWA.md`](../INSTALLATION_PWA.md)
> **Voir aussi :** [Prise en main](./PRISE_EN_MAIN.md) · [Édition & collaboration](./COLLABORATION.md)
---
## 1. Qu'est-ce que la PWA d'ObsiGate ?
Une PWA combine le meilleur du web et du natif :
- **Installation** sur l'écran d'accueil, sans store.
- **Mode hors-ligne** : interface et dernières données consultées mises en cache.
- **Notifications** : alertes de mise à jour et Web Push.
- **Performance** : chargement rapide via cache intelligent.
- **Multi-plateforme** : desktop, mobile, tablette.
---
## 2. Installer la PWA
### Desktop (Chrome, Edge, Brave)
1. Ouvrez ObsiGate dans le navigateur.
2. Cliquez sur l'icône d'installation dans la barre d'adresse (➕ / ⬇️).
3. Cliquez sur **Installer** dans la popup.
4. ObsiGate apparaît dans vos applications.
*Alternative :* menu ⋮ → **Installer ObsiGate…**
### Android (Chrome)
1. Ouvrez ObsiGate dans Chrome.
2. Menu ⋮ → **Ajouter à l'écran d'accueil**.
3. Confirmez.
### iOS / iPadOS (Safari)
1. Ouvrez ObsiGate dans Safari.
2. Bouton Partager 📤 → **Sur l'écran d'accueil**.
3. Nommez l'application puis **Ajouter**.
---
## 3. Mode hors-ligne
Le **Service Worker** (`frontend/sw.js`) met en cache :
- l'interface (HTML, CSS, JavaScript, manifeste) ;
- les ressources statiques (icônes, polices) ;
- les dernières données API consultées.
### Stratégies de cache
| Ressource | Stratégie |
|---|---|
| Code (HTML/JS/CSS/manifest) | **Network-first** (cache en secours hors-ligne) |
| API | **Network-first** (+ cache hors-ligne) |
| Autres assets (images, polices) | **Stale-while-revalidate** |
| Nettoyage | Purge des caches d'une version antérieure à l'activation |
> Le choix **network-first** est délibéré : les assets ne sont pas fingerprintés,
> un cache-first servirait indéfiniment un ancien build sur mobile.
### File de synchronisation & conflits
- Les modifications faites hors-ligne sont stockées (IndexedDB) et rejouées à la
reconnexion.
- Les conflits éventuels sont détectés et peuvent être résolus (écran
**Conflits**, `GET /api/conflicts`).
### Tester hors-ligne
1. DevTools (F12) → onglet **Network**.
2. Cochez **Offline**.
3. Rechargez : l'application doit fonctionner avec le cache.
---
## 4. Notifications (Web Push)
- Abonnement à partir de l'interface (permission navigateur requise).
- Endpoints : `GET /api/push/vapid-public-key`,
`POST /api/push/subscribe`, `DELETE /api/push/subscribe`,
`GET /api/push/subscriptions`.
- Les notifications sont signées **VAPID** et peuvent prévenir de changements
(collaboration, mises à jour).
---
## 5. Mises à jour
- Vérification régulière des mises à jour.
- Notification quand une nouvelle version est disponible.
- Mise à jour en un clic, **sans perte de données**.
- Le numéro `SW_VERSION` invalide l'ancien cache à chaque livraison.
### Forcer une mise à jour (console)
```javascript
navigator.serviceWorker.getRegistration().then(reg => reg.update());
```
---
## 6. Débogage
### Vérifier l'installation
Chrome DevTools → onglet **Application** :
- **Manifest** : métadonnées ;
- **Service Workers** : enregistrement ;
- **Cache Storage** : contenu du cache.
### Désinstaller le Service Worker
```javascript
navigator.serviceWorker.getRegistrations().then(regs => regs.forEach(r => r.unregister()));
```
---
## 7. Limites
- Le hors-ligne dépend des données déjà mises en cache.
- Les actions d'écriture hors-ligne s'appliquent à la reconnexion (pas en temps
réel).
- iOS applique des contraintes spécifiques (persistance, notifications).
Voir [Édition & collaboration](./COLLABORATION.md) pour le temps réel.
+54
View File
@@ -0,0 +1,54 @@
# 📚 Guides d'utilisation ObsiGate
Bienvenue dans le répertoire des **guides utilisateur** d'ObsiGate. Chaque guide est
autonome, écrit en français et illustré d'exemples concrets (commandes, configuration,
captures conceptuelles).
> **Vous découvrez ObsiGate ?** Commencez par le **[Guide de prise en main](./PRISE_EN_MAIN.md)**.
> Une aide rapide est aussi intégrée directement dans l'application (menu Options →
> **Guide d'utilisation**, FR/EN, téléchargeable en Markdown et PDF).
---
## 🗂️ Sommaire des guides
| Guide | Public | Contenu |
|---|---|---|
| 🚀 [Prise en main](./PRISE_EN_MAIN.md) | Tous | Premier lancement, interface, navigation, vaults, raccourcis |
| 🔍 [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
| 🤖 [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) | Tous | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
| 📝 [Édition & collaboration](./COLLABORATION.md) | Tous | Édition simultanée, curseurs distants, persistance |
| 📱 [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md) | Tous | Installation PWA, cache, file de synchronisation, notifications |
| 🔌 [API REST](./API_REST.md) | Développeurs | Authentification, clés API, endpoints, exemples `curl`, SSE |
| 🧩 [Serveur MCP](./MCP.md) | Développeurs / IA | Brancher Claude Desktop, Cursor, Cline… sur vos vaults |
| 🔒 [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) | Admin | Utilisateurs, MFA, permissions par vault, bonnes pratiques |
| 🐳 [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) | Admin / Ops | `docker-compose`, volumes, reverse proxy, mises à jour |
| 🖥️ [Application desktop (Tauri)](./DESKTOP.md) | Tous | Installation, premier lancement, build depuis les sources |
---
## 🧭 Par où commencer ?
- **Je veux juste utiliser l'application** → [Prise en main](./PRISE_EN_MAIN.md)
- **Je veux sécuriser mon instance** → [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md)
- **Je veux brancher une IA** → [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) puis [MCP](./MCP.md)
- **Je veux scripter/automatiser** → [API REST](./API_REST.md)
- **Je veux héberger sur un serveur** → [Déploiement Docker](./DEPLOIEMENT_DOCKER.md)
---
## 📖 Documentation associée
| Type | Où |
|---|---|
| Vue d'ensemble produit | [`README.fr.md`](../../README.fr.md) · [`README.md`](../../README.md) |
| Conception détaillée par fonctionnalité | [`docs/features/`](../features/) |
| Standards de code | [`docs/CONTRIBUTING.md`](../CONTRIBUTING.md) |
| Méthode de livraison (Definition of Done) | [`docs/DELIVERY_WORKFLOW.md`](../DELIVERY_WORKFLOW.md) |
| Roadmap / travail à venir | [`docs/ROADMAP.md`](../ROADMAP.md) |
| Historique des versions | [`CHANGELOG.md`](../../CHANGELOG.md) |
| API interactive (Swagger / ReDoc) | `/docs` · `/redoc` (instance ObsiGate) |
> **Convention :** ce répertoire est la **porte d'entrée utilisateur**. Le *comment*
> (utilisation) vit ici ; le *pourquoi* (conception technique) vit dans
> [`docs/features/`](../features/). Ne jamais dupliquer le détail technique des fiches.
+193
View File
@@ -0,0 +1,193 @@
# 🔍 Guide Recherche, PDF & Excalidraw
ObsiGate va au-delà de la simple lecture : recherche puissante, rendu des
documents riches (PDF, diagrammes) et indexation de leur contenu pour que tout
soit retrouvable.
> **Public :** tous les utilisateurs
> **Fiches techniques :** [`features/semantic-search.md`](../features/semantic-search.md) ·
> [`features/pdf.md`](../features/pdf.md) · [`features/excalidraw.md`](../features/excalidraw.md)
---
## 1. Recherche plein texte (TF-IDF)
Le moteur d'ObsiGate s'appuie sur un **index inversé** et un scoring **TF-IDF**
avec :
- **Boost titre** — une correspondance dans le titre pèse 3× plus.
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
- **Stemming français** — les variantes des mots sont rapprochées.
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
- **Facettes** — compteurs par vault et par tag sur les résultats.
- **Pagination** — 50 résultats par page.
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
- **Historique** — les 50 dernières recherches sont conservées en `localStorage`.
La recherche s'effectue **sans I/O disque** : le contenu est déjà en mémoire.
---
## 2. Syntaxe de requête
| Opérateur | Description | Exemple |
|---|---|---|
| `tag:<nom>` | Filtre par tag | `tag:recette docker` |
| `#<nom>` | Raccourci de tag | `#linux serveur` |
| `vault:<nom>` | Filtre par vault | `vault:IT kubernetes` |
| `title:<texte>` | Filtre par titre | `title:pizza` |
| `path:<texte>` | Filtre par chemin | `path:recettes/soupes` |
| `ext:<type>` | Filtre par type de fichier | `ext:md kubernetes` |
| `"phrase exacte"` | Recherche d'une phrase | `tag:"multi mots"` |
Les opérateurs sont **combinables** :
```text
tag:linux vault:IT ext:md serveur web
```
Cette requête cherche « serveur web » dans les fichiers Markdown de la vault
`IT` portant le tag `linux`.
### Filtres par extension
| Extension | Contenu |
|---|---|
| `ext:md` | Notes Markdown |
| `ext:py`, `ext:sh`, `ext:js` | Scripts et code |
| `ext:pdf` | Documents PDF (texte extrait) |
| `ext:excalidraw` | Diagrammes Excalidraw (texte extrait) |
---
## 3. Autocomplétion et suggestions
- **`/api/suggest`** — suggère des titres de fichiers.
- **`/api/tags/suggest`** — suggère des tags.
- Navigation clavier : `↑` / `↓` puis `Entrée` ; `Échap` ferme les suggestions.
### Raccourcis de recherche
| Raccourci | Action |
|---|---|
| `Ctrl + K` / `Cmd + K` | Focaliser la barre de recherche |
| `/` | Focaliser la recherche (hors champ texte) |
| `↑` / `↓` | Naviguer dans les suggestions |
| `Entrée` | Sélectionner la suggestion active ou lancer la recherche |
| `Échap` | Fermer les suggestions / quitter la recherche |
Recherches sauvegardées et signets sont disponibles via l'API
(`/api/saved-searches`, `/api/bookmarks`).
---
## 4. Recherche sémantique (optionnelle)
Au classement TF-IDF peut s'ajouter un classement **par embeddings**, fusionné
via la méthode **RRF** (Reciprocal Rank Fusion). Activation : touche `~`
(ou `Alt + S`) dans la recherche.
Deux modes :
1. **Sans dépendance** — un *embedder* par hachage fournit une base utilisable
immédiatement.
2. **Embeddings réels** — installez `backend/requirements-semantic.txt` et/ou
renseignez les variables `OBSIGATE_EMBEDDING_*` pour utiliser
`all-MiniLM-L6-v2`.
Détails et configuration :
[`features/semantic-search.md`](../features/semantic-search.md).
---
## 5. Support PDF
### Lecture
Les fichiers PDF de vos vaults s'affichent **en ligne** dans le navigateur via le
visualiseur PDF natif (iframe + `<embed>`). Le fichier est **streamé** en HTTP
Range (`206 Partial Content`) : les gros PDF se chargent progressivement.
### Recherche
Le texte est **extrait à l'indexation** (`pypdf` / `pymupdf`), donc le contenu
des PDF est recherchable via la recherche plein texte. Utilisez `ext:pdf` pour
limiter les résultats aux PDF.
### Métadonnées
`GET /api/file/{vault}/pdf/info` renvoie les métadonnées (pages, titre, auteur)
**sans transférer** le document.
```bash
curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
```
### Limites
- **Pas d'OCR** : les PDF scannés (images) ne sont pas recherchables.
- Pas d'annotation ni d'édition du PDF lui-même.
---
## 6. Diagrammes Excalidraw
Les fichiers `.excalidraw` et `.excalidraw.md` (dont le format compressé du
**plugin Obsidian Excalidraw**) s'ouvrent dans un **éditeur visuel Excalidraw
complet**, dans une iframe sandboxée.
- **Dessin et édition** sans quitter ObsiGate.
- **Sauvegarde automatique** (débounce 2 s) ou `Ctrl + S`.
- **Thème** clair/sombre suivi automatiquement.
- **Texte indexé** : le texte des éléments du diagramme est extrait à
l'indexation et donc recherchable (`ext:excalidraw`).
Fiche technique : [`features/excalidraw.md`](../features/excalidraw.md).
---
## 7. Autres contenus riches
### Mermaid
Les blocs de code ` ```mermaid ` sont rendus en diagrammes interactifs (live
preview, thèmes, zoom, plein écran, pré-processeur compatible syntaxe Obsidian).
### Images Obsidian
Toutes les syntaxes d'images sont supportées avec résolution intelligente en
7 stratégies :
1. chemin absolu ;
2. dossier d'attachements configuré (`VAULT_N_ATTACHMENTS_PATH`) ;
3. index de démarrage (correspondance unique) ;
4. même répertoire que la note ;
5. racine de la vault ;
6. index de démarrage (correspondance la plus proche) ;
7. repli : `[image not found: fichier.ext]`.
Rescan manuel des attachements :
```bash
curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes"
```
### Graphe et backlinks
- **Graphe** : vue force-directed (Barnes-Hut), filtres (tag, type), profondeur,
mode focus, export PNG, aperçu au survol (`Ctrl + clic`).
- **Backlinks** : `GET /api/file/{vault}/backlinks?path=…` liste les notes
pointant vers un document.
---
## 8. Dépannage
| Symptôme | Piste |
|---|---|
| Un PDF ne s'affiche pas | Vérifier la taille (`OBSIGATE_PDF_MAX_SIZE_MB`, défaut 50 Mo) |
| Le texte d'un PDF scanné n'est pas trouvé | Pas d'OCR : normal |
| Une image reste introuvable | Configurer `VAULT_N_ATTACHMENTS_PATH`, puis rescan |
| La recherche sémantique ne s'active pas | Vérifier le toggle `~` et `OBSIGATE_EMBEDDING_*` |
| Résultats obsolètes | Forcer une réindexation : `GET /api/index/reload` |
+39 -1
View File
@@ -14,7 +14,7 @@
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
- **Dernière mise à jour** : 2026-09-17
- **Dernière mise à jour** : 2026-09-24
---
@@ -169,7 +169,25 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-057* | [🟡 IMPORTANT] Assistant IA : le bouton « Ajouter » est inopérant dans l'éditeur Forge (fonctionne seulement dans « Editer ») | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js`, `frontend/editor-poc.html` | Ouvrir un document dans Forge, demander une réponse à l'assistant puis cliquer « Ajouter » | `_insertIntoEditor()` cible Forge (`#forge-iframe`) : `postMessage({ type: 'parent-insert', text })` ; `editor-poc.html` insère au curseur (`insertAtCursor`) et marque le tampon modifié. Repli textarea inclus. Tests : `tests/frontend/ai.test.mjs` (+3), `tests/frontend/editor-inline.test.mjs` (+1) | `state.editorView` (CodeMirror) est nul en Forge : le clic affichait « Aucun document ouvert dans l'éditeur » |
| *BUG-058* | [🔵 MINEUR] Éditeur « Editer » : la barre de numérotation de ligne ne suit pas la couleur du thème (gutter clair `#f5f5f5` en thème sombre) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` | Ouvrir un document → Editer en thème sombre : la colonne des numéros de ligne reste gris clair alors que le fond de l'éditeur est sombre | Thème du gutter CodeMirror via les variables CSS (`color-mix(var(--text-primary) …)` pour le fond, `--text-secondary` pour les numéros, `--border` pour la séparation, `--text-primary` pour la ligne active) au lieu des valeurs codées en dur de CodeMirror ; test de non-régression dans `tests/frontend/editor-inline.test.mjs`. Vérifié Playwright (instance de test) : sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de` | CodeMirror applique `background:#f5f5f5` par défaut, indépendamment du thème ObsiGate ; en mode sombre le fond de l'éditeur suit `--bg-secondary` mais pas le gutter |
| *BUG-060* | [🟡 IMPORTANT] Viewer PDF : l'affichage des pages ne fonctionne pas — seule la barre d'outils « PDF — N pages » s'affiche, le contenu reste vide | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs` (nouveau), `tests/e2e/pdf-viewer.spec.js` (nouveau) | Cliquer un fichier `.pdf` dans l'arborescence | `frontend/js/viewer.js` : le rendu PDF passe de `<embed type="application/pdf">` à `<iframe>` (autorisée par `frame-src 'self'`, le stream étant same-origin). Tests : `tests/frontend/pdf-viewer.test.mjs` (+6) et `tests/e2e/pdf-viewer.spec.js` (fixture `test_vault/sample-pdf.pdf`) | Cause : la CSP durcie en BUG-034 pose `object-src 'none'`, directive qui gouverne `<embed>`/`<object>` → le lecteur PDF natif était bloqué (barre d'outils rendue, corps vide). Le test E2E échoue bien avec l'ancien `<embed>`. `object-src 'none'` conservé (le correctif ne désarme pas la CSP) |
| *BUG-061* | [🟡 IMPORTANT] Assistant IA : le bouton « Plein écran » n'agrandit plus le panneau | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css`, `tests/frontend/ai.test.mjs` | Ouvrir l'assistant, redimensionner le panneau, puis cliquer « Plein écran » | La largeur du panneau est écrite en ligne par la poignée de redimensionnement / la largeur persistée (`localStorage`) ; l'inline l'emportait sur `.bookslm-panel.fullscreen { width: 100vw }`. Ajout de `!important` sur la règle plein écran. Tests : `ai.test.mjs` (+1 : classe basculée + règle CSS). Vérifié Playwright : 640 px → 1400 px (viewport) |
| *BUG-062* | [🟡 IMPORTANT] Viewer PDF : le document ne prend pas toute la largeur quand la navigation est masquée | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js` | Ouvrir un PDF puis masquer la barre de navigation gauche | La règle `.sidebar.hidden ~ .content-wrapper .content-area { max-width: 1200px }` (colonne de lecture centrée) s'appliquait aussi aux viewers plein cadre. Ajout de `.content-area:has(.pdf-viewer-container)` (et `.image-viewer-container`) avec `max-width: none; margin: 0`. Test E2E : `max-width` calculé = `none`, conteneur = largeur du contenu |
| *BUG-063* | [🟡 IMPORTANT] Viewer PDF : la table des matières s'affiche mais ne navigue pas | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js` (fixture `test_vault/sample-pdf-toc.pdf`) | Ouvrir un PDF avec signets, puis cliquer une entrée de la TOC | Deux causes : (1) `contentWindow.location.hash='page=N'` n'atteint pas le document (lecteur PDF natif dans une fenêtre `about:blank`) ; (2) un simple changement de fragment sur `iframe.src` est une navigation same-document **ignorée** par le lecteur natif. `navigatePdfToPage()` (liens `data-page` + listeners, plus d'`onclick` inline) recharge réellement l'iframe via un paramètre de query qui change (`&_pdfpage=<ts>#page=N`). Test E2E : `src` finit par `&_pdfpage=<n>#page=3`. Vérifié en Chrome *headful* : page 1 → page 8 → page 1 (captures identiques au retour) | Le fragment seul ne suffisait pas : Chrome applique `#page=N` au **chargement**, pas lors d'un changement de fragment |
| *BUG-064* | [🟡 IMPORTANT] Éditeur Excalidraw : le diagramme ne s'affiche jamais (canvas vide), pour tout fichier `.excalidraw` / `.excalidraw.md` | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/excalidraw-editor.html`, `backend/main.py`, `tests/frontend/excalidraw-viewer.test.mjs`, `tests/test_security_hardening.py`, `tests/e2e/excalidraw.spec.js`, `test_vault/diagram-app-export.excalidraw` | Ouvrir un `.excalidraw` (ou `.excalidraw.md`) dans ObsiGate | Deux causes : (1) la feuille de style d'Excalidraw n'était jamais chargée → éditeur non stylisé + `.excalidraw` sans hauteur fixe → boucle de resize jusqu'au plafond `2^25` (33 554 432 px) → scène blanche. Correctif : `<link>` CSS depuis esm.sh + `style-src` CSP autorisant `https://esm.sh`. (2) `appState.collaborators` objet JSON → `collaborators.forEach is not a function` ; `sanitizeAppState()` reconvertit en `Map` et écarte `width/height/offsetLeft/offsetTop`. | Vérifié navigateur : hauteur canvas 525 px (avant 33 554 432), dessin affiché, UI stylisée, 0 erreur. E2E + tests statiques CSP/CSS ajoutés. |
| *BUG-065* | [🟡 IMPORTANT] Éditeur Excalidraw : l'auto-save recharge la page en pleine édition | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs` | Ouvrir un `.excalidraw` puis modifier un élément : au bout de 2 s la vue se recharge | Chaque modification déclenchait un `PUT save` 2 s plus tard → SSE `index_updated` → `reloadExternalWrite` → `openFile` → **recréation de l'iframe** (refresh visible). Auto-save supprimée : sauvegarde explicite (bouton 💾 / Ctrl+S). `reloadExternalWrite` ignore le fichier si un iframe Excalidraw est ouvert (`iframe[data-excalidraw-vault/path]`). Le badge « Modified » ne réagit plus aux changements d'`appState` (resize/zoom) mais à la signature des éléments. | Vérifié Playwright : plus de refresh, badge stable après bascule plein écran. Test statique (absence de `requestSave`/`saveTimer`). |
| *BUG-066* | [🔵 MINEUR] Configuration : icônes manquantes dans la table des matières (« Fichiers cachés », « Partages publics ») | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/locales/{fr,en}.json` | Ouvrir Configuration → observer le sommaire : les entrées « Fichiers cachés » et « Partages publics » n'ont pas d'icône | `config.section_hidden` → « 🗂️ Fichiers cachés » / « 🗂️ Hidden files », `config.section_shares` → « 📤 Partages publics » (EN avait déjà l'icône). Test : `tests/frontend/unit.test.mjs` (+1 : toutes les entrées du sommaire portent une icône FR/EN) | Les libellés du sommaire utilisent des clés i18n distinctes des titres de section (`auto.f8ba6127`, `config.section_partages-publics`) qui, elles, avaient l'icône |
| *BUG-067* | [🔵 MINEUR] Guide d'utilisation : l'entrée « 📱 Mobile » du sommaire ne fait rien (section absente) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/index.html` | Ouvrir le Guide → cliquer « 📱 Mobile » dans le sommaire : rien ne se passe | L'ancre `#help-mobile-editor` était présente dans la TOC mais aucune section `id="help-mobile-editor"` n'existait (l'édition mobile n'était qu'un h3 de `help-edition`). Fix #105 : section dédiée créée avec ancre + entrée de nav cohérente. | Vérifié par test statique `tests/test_guide.py::test_nav_anchors_resolve` |
| *BUG-068* | Configuration — section « 🔒 Sécurité du compte » inachevée : boutons hors thème, QR code invisible, fiabilité des fonctions à valider | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/auth.js`, `frontend/style.css`, `backend/auth/router.py` | Configuration → 🔒 Sécurité du compte | `frontend/style.css` (+`config-btn-primary`/`danger` thème), `backend/auth/router.py` (`qr_data_url` segno local), `frontend/js/auth.js` (QR local + fallback, recovery WebAuthn, carte mot de passe, escapeHtml labels), locales FR/EN, `backend/requirements.txt` (+segno) ; tests `tests/test_mfa.py` (+1) + `tests/frontend/mfa-settings.test.mjs` (nouveau, 9) | pytest 1241 passed / 6 skipped, ruff 0, mypy 0, frontend unit + validate-imports verts |
| *BUG-069* | Login 2FA bloqué sans erreur : après user+pwd corrects, la page de login reste affichée et le challenge MFA n'apparaît jamais | 🟢 corrigé | P0 | 📱 frontend | IA | `frontend/js/auth.js`, `frontend/index.html` | Activer 2FA → logout → login (bon user+pwd) | `frontend/js/auth.js` (`showMfaChallenge` → `.login-card` + erreur `mfa.challenge_unavailable` si montage impossible), locales FR/EN ; tests `tests/frontend/mfa-settings.test.mjs` (+2) | Reproduit au navigateur avant correctif (challenge jamais affiché), vérifié après : challenge affiché, code erroné → erreur, code valide (200) → app ; frontend mfa-settings 11/11, unit + validate-imports verts |
| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 |
| *BUG-071* | Configuration « Configurations » inutilisable en mode mobile : sommaire masqué sans bouton d'accès, navigation par ancre sans JS, grilles 2 colonnes et rangées d'ajout qui débordent (≤768px) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json` | Mobile (≤768px) : ouvrir Configurations → aucun sommaire ni moyen d'atteindre une section ; champs « Clés IA » / jetons / webhooks débordent | `index.html` (+`#config-hamburger` `.help-hamburger`, `config.toc_toggle` FR/EN) ; `config.js` (toggle, scroll doux + actif + repli auto mobile, reset à l'ouverture) ; `i18n.js` (`data-i18n-attr` multi-paires `;`) ; `style.css` (bloc mobile `#config-modal` : sommaire haut 46vh, grilles 1fr, add-rows wrap + `!important`, items wrap, 44px) ; tests `tests/frontend/config-mobile.test.mjs` (nouveau, 11) + CI ; E2E `tests/e2e/config-mobile.spec.js` (nouveau, 3/3 projet chromium-mobile, ignoré en desktop) | pytest 1249 passed / 6 skipped, ruff 0, mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + sidebar 6/6 + mobile 35/35 + ai-keys 7/7 |
| *BUG-072* | Visionneuse d'images : le plein écran et le panneau « Métadonnées » ne sont pas conservés lors de la navigation ←/→, et le panneau s'affiche sous la pellicule au lieu d'une barre latérale | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/viewer.js`, `frontend/style.css` | Ouvrir une image, activer le plein écran (ou Métadonnées), puis naviguer avec les flèches précédent/suivant | État persistant `_imageViewerState { lightbox, meta }` + drapeau `_imageViewerNavPending` posé par `go()`/pellicule : `renderFile` ne réinitialise que hors navigation image→image. Panneau reconstruit dans `.image-viewer-body` (sidebar droite, `border-left`, `width:280px; max-width:40%`) ; la règle lightbox ne masque plus que la pellicule. Boutons `image-btn-lightbox`/`image-btn-metadata` (+ `aria-pressed`), `Escape` resynchronisé. Tests : `tests/frontend/image-viewer.test.mjs` (+2), E2E `tests/e2e/image-viewer.spec.js` (+1). | Navigation → `openFile` → `renderImageViewer` recréait le conteneur : les états `lightbox`/`metaPanel` étaient perdus. Le panneau était rendu en bas (colonne) au lieu d'une sidebar droite |
| *BUG-073* | Mobile : la barre de navigation fixe du bas masque le bas de tous les documents et pages affichés (les dernières lignes restent définitivement sous la barre) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/style.css` | Mobile (≤768px) : ouvrir un document long, défiler jusqu'au bas → la fin du contenu passe sous la barre `#mobile-toolbar` et n'est jamais atteignable | Clearance mobile retargetée de `.main-layout` (sélecteur mort, absent de `index.html`) vers `.main-body` (`calc(64px + env(safe-area-inset-bottom, 0))`) ; règle sœur morte `.editor-modal.active ~ .main-layout` supprimée ; reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture) ; `body.np-active .content-area` ramené à `76px` (dégagement dock seul, géométrie totale inchangée). Tests : `tests/frontend/mobile-toolbar.test.mjs` (7, au CI), E2E `tests/e2e/mobile-toolbar.spec.js` (2, `chromium-mobile`, skip desktop) | La règle de clearance du bloc mobile cible `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, aucun dégagement réservé |
| | | | | | | | | | | |
| *BUG-074* | [🟡 IMPORTANT] Assistant IA : le bloc d'étapes affiche « 1 step » sans titre alors que l'agent réalise plusieurs actions (compteur toujours à 1) | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/agent/loop.py` | Mode agent : demander une création multi-fichiers/dossiers → chaque message ne montre qu'« 1 étape ▶ » sans détail | `backend/agent/loop.py` + `frontend/js/bookslm.js` : compteur = actions (hors réflexions) + titre = 1re action dans le `<summary>` ; reprise de confirmation diffusée dans le **même** message (fusion des étapes). Tests : `tests/frontend/ai.test.mjs` (+3) | Le résumé `<summary>` ne porte aucun titre ; chaque reprise de confirmation crée un **nouveau** message assistant qui ne contient qu'une action ; les réflexions gonflent le compteur |
| *BUG-075* | [🟡 IMPORTANT] Assistant IA : chaque action mutatrice demande son propre « Appliquer » — aucun résumé des actions en attente ni approbation globale | 🟢 corrigé | P1 | ⚙️ backend + 📱 frontend | IA | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js` | Mode agent : demander une structure de répertoires multi-fichiers → valider une action après l'autre | `backend/agent/loop.py` (`pending.actions`, lot exécuté au resume) ; `backend/bookslm_routes.py` (`confirm_all` → `ctx.confirmed`) ; `frontend/js/bookslm.js` (carte multi-actions + « Tout approuver (N) »). Tests : `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs` | La pause de confirmation ne capture que le **premier** appel mutateur du lot (les suivants sont `deferred`) ; carte unique sans liste ; nouveau `confirm_all` à ajouter pour autoriser la suite de l'exécution en une approbation |
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -233,6 +251,26 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-17 | BUG-059 | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-059** : dans une conversation ouverte (post ancré en haut), **tout clic** dans la fenêtre de messages — lien de fichier, étapes, sélection de texte — faisait sauter toute la conversation au bas de la fenêtre. Cause : le gestionnaire `mousedown` de dépintage (prévu pour la molette/tactile/poignée de scroll) se déclenchait aussi sur un simple clic, et le retrait du padding d'ancre (`paddingBottom`) bornait le `scrollTop` à la nouvelle hauteur max → saut au bas. Correctif : helper pur `isScrollbarPress(target, clientX, container)` — un appui ne dépine que s'il vise la **poignée de scroll** (cible = conteneur + zone de gouttière droite) ; molette et tactile conservent leur comportement. Vérifié : `ai.test.mjs` 92/92 (+1), unit 9/9, validate-imports 38 modules, pytest / ruff / mypy inchangés côté backend. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-060 | Correction | `frontend/js/viewer.js`, `.gitea/workflows/ci.yml`, `tests/frontend/pdf-viewer.test.mjs` (nouveau), `tests/e2e/pdf-viewer.spec.js` (nouveau), `test_vault/sample-pdf.pdf` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-060** : l'ouverture d'un PDF n'affichait aucune page (barre d'outils « PDF — N pages » présente, corps vide). Cause : la CSP durcie en BUG-034 pose `object-src 'none'` — directive qui gouverne `<embed>`/`<object>` — alors que le viewer rendait le PDF via `<embed type="application/pdf">` : le lecteur natif était bloqué. Correctif : rendu dans une `<iframe>` (autorisée par `frame-src 'self'`, le stream `/api/file/{vault}/pdf/stream` étant same-origin) ; `object-src 'none'` conservé. Tests : `pdf-viewer.test.mjs` 6/6 (statique : pas d'`<embed>`, CSP `frame-src 'self'`, iframe pleine hauteur), `pdf-viewer.spec.js` (E2E : iframe + stream `application/pdf` 200/206 + zéro violation CSP ; échoue bien avec l'ancien `<embed>`). Vérifié : pytest 1184 passed / 6 skipped, frontend 14 suites JSDOM vertes, validate-imports 38 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-035, BUG-036, BUG-037, BUG-038, BUG-039, BUG-040 | Correction | `backend/secret_redactor.py`, `backend/collab.py`, `backend/auth/{middleware,password,router}.py`, `backend/indexer.py`, `backend/main.py`, `tests/test_api_main.py`, `tests/test_auth.py`, `tests/test_auth_api.py`, `tests/test_collab.py`, `tests/test_pdf.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot de 6 bugs mineurs (P2/P3)** : BUG-035 masquage hex conditionné au contexte (git/SHA épargnés) ; BUG-036 jeton WebSocket cookie-only (plus de `?token=`) + plafond de trame 16 Mio ; BUG-037 garde-fou au démarrage (refus d'un bind public sans auth sauf `OBSIGATE_ALLOW_INSECURE=true`) ; BUG-038 Argon2 recalibré 19 Mio/t=2/p=1 ; BUG-039 login uniforme 401 (fini 429/403 distinctifs) ; BUG-040 extraction PDF différée via `enrich_pdf_texts()`. Vérifié : pytest 1204 passed / 6 skipped, ruff 0, mypy 0 (77 fichiers), frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-061, BUG-062, BUG-063 | Correction | `frontend/style.css`, `frontend/js/viewer.js`, `tests/frontend/ai.test.mjs`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js`, `test_vault/sample-pdf-toc.pdf` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Viewer PDF & assistant IA** : BUG-061 le bouton plein écran du panneau assistant l'emportait mal sur la largeur inline (redimensionnement/persistée) → `width: 100vw !important` ; BUG-062 le plafond de lecture 1200 px s'appliquait au PDF quand la navigation était masquée → `:has(.pdf-viewer-container)` en `max-width:none` ; BUG-063 la TOC PDF ne naviguait pas (`contentWindow` = `about:blank`) → `navigatePdfToPage()` recharge l'iframe avec `#page=N`, liens `data-page` sans `onclick` inline. Vérifié : `ai.test.mjs` 93/93, `pdf-viewer.test.mjs` 8/8, validate-imports 38 modules (311 exports), unit 9/9, E2E `pdf-viewer.spec.js` 3/3, et Playwright sur l'instance de test (plein écran 640→1400 px, `src` → `#page=3`, `max-width:none`). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-063 (complément) | Correction | `frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-063 non résolu au premier correctif** : définir `iframe.src = base + '#page=N'` ne change que le fragment → navigation same-document que le lecteur PDF natif ignore. Diagnostic en Chrome *headful* (comparaison de captures) : fragment présent au chargement = OK ; changement de fragment après chargement = aucun effet ; changement de query + fragment = OK. `navigatePdfToPage()` ajoute donc un paramètre de query horodaté (`&_pdfpage=<ts>#page=N`) pour forcer un vrai rechargement. Vérifié via l'UI de l'app (Chrome headful) : page 1 → page 8 → retour page 1 (hash de capture identique au retour). Tests : `pdf-viewer.test.mjs` 8/8, E2E `pdf-viewer.spec.js` 3/3. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-064 | Correction | `frontend/excalidraw-editor.html`, `backend/main.py`, `tests/frontend/excalidraw-viewer.test.mjs`, `tests/test_security_hardening.py`, `tests/e2e/excalidraw.spec.js`, `test_vault/diagram-app-export.excalidraw` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-064** : aucun diagramme Excalidraw ne s'affichait (canvas vide). Diagnostic navigateur : `.excalidraw` sans hauteur fixe → boucle de redimensionnement 525 → 56 181 → **33 554 432 px** (`2^25`, plafond Excalidraw) ; canvas de 33 Mpx impossible à dessiner → scène blanche. **Cause 1** : la feuille de style `@excalidraw/excalidraw` n'était jamais chargée (seuls 18 règles CSS présentes, toutes ObsiGate) — l'éditeur était non stylisé. Correctif : `<link rel="stylesheet" href="https://esm.sh/@excalidraw/[email protected]/dist/prod/index.css">` + `https://esm.sh` ajouté à `style-src` de la CSP. **Cause 2** : `appState.collaborators` (Map sérialisée en objet JSON par l'app/plugin) faisait planter Excalidraw 0.18 (`collaborators.forEach is not a function`) ; `sanitizeAppState()` reconvertit en `Map` et écarte la géométrie de viewport importée (`width/height/offsetLeft/offsetTop`). Vérifié Playwright sur l'instance de test (port 2020) : hauteur canvas 525 px, rectangle + losange affichés, UI stylisée, 0 `pageerror`. Tests : `excalidraw-viewer.test.mjs` 8/8 (dont 3 nouveaux), `TestCspExcalidrawStylesheet` (pytest), E2E (hauteur de canvas bornée). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-064 (complément) | Correction | `frontend/style.css`, `tests/frontend/excalidraw-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-064 (complément)** : quand la barre de navigation gauche est masquée, le viewer Excalidraw restait borné à la colonne de lecture centrée de 1200 px. La règle `.sidebar.hidden ~ .content-wrapper .content-area { max-width: 1200px }` s'appliquait au viewer comme aux notes. Ajout de `.content-area:has(iframe[src*="excalidraw-editor.html"])` en `max-width: none; margin: 0` (même traitement que les viewers PDF/image, BUG-062). Vérifié Playwright (viewport 1400 px) : contenu 1115 → 1400 px, iframe 1035 → 1320 px, `max-width` calculé `none`. Test statique ajouté (`excalidraw-viewer.test.mjs` 9/9). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-065, #78 (complément) | Correction + feature | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs`, `docs/features/excalidraw.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-065** : l'auto-save Excalidraw (débounce 2 s) déclenchait `PUT save` → SSE `index_updated` → `reloadExternalWrite` → `openFile` → recréation de l'iframe = refresh visible pendant le dessin. Auto-save retirée (`excalidraw-viewer.js` : plus de `requestSave`/`saveTimer`), sauvegarde explicite (bouton 💾 / Ctrl+S) ; `reloadExternalWrite` (utils.js) court-circuite le re-rendu si un iframe Excalidraw est ouvert sur ce fichier (attributs `data-excalidraw-vault`/`data-excalidraw-path`) ; le badge « Modified » suit désormais une signature des éléments (`id:versionNonce`) au lieu de tout `onChange` — resize/zoom/plein écran ne marquent plus le fichier modifié. **#78 (complément)** : bouton **plein écran** `#btn-fullscreen` dans la barre d'outils de l'éditeur (`requestFullscreen` sur le document de l'iframe) + iframe créée avec `allow="fullscreen" allowfullscreen`. Vérifié Playwright : bascule plein écran OK (`document.fullscreenElement` true→false), badge non modifié après bascule ; tests statiques `excalidraw-viewer.test.mjs` 12/12, validate-imports 38 modules, unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | #78 (complément) | UI | `frontend/excalidraw-editor.html`, `docs/features/excalidraw.md`, `CHANGELOG.md` | **#78 (complément)** : la barre d'outils de l'éditeur Excalidraw passe en **colonne d'icônes** (34×34 px, SVG seuls), **collée au bord droit** (`right: 0` ; `top: 45%` ; empilement vertical), avec `title`/`aria-label`. L'icône du bouton Save est remplacée par une coche pendant 1,2 s après une sauvegarde réussie. Badge « Modifié » réduit à une pastille. Vérifié Playwright : bord droit au bord de l'iframe, haut 45 %, 4 boutons empilés ; bascule plein écran OK, cycle d'icône Save + `PUT save` observés. | 🟢 livré (en attente vérif utilisateur) |
| 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur)
| 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré
| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-070 | Correction | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py`, `.env.example`, `tests/test_webauthn.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-070** : activation WebAuthn rejetée en 400. (1) Défauts `localhost` sans port → `resolve_relying_party()` dérive rp_id/origines de la requête (config explicite prioritaire, forwarded sous TRUST_PROXY), appliqué aux endpoints register + login. (2) Challenge single-use → 5 derniers conservés, vérification contre le challenge de la cérémonie en cours. **Vérifié au navigateur** (authentificateur virtuel CDP, instance Docker) : register 200, clé listée, clé de test retirée. Tests : `test_webauthn.py` 19/19 (+8), suite complète 1249 passed / 6 skipped, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 | Correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071** : page « Configurations » inutilisable en mobile. (1) `#config-nav` masquée sous 768px sans toggle → hamburger `#config-hamburger` ajouté à l'en-tête (`.help-hamburger`, libellé `config.toc_toggle` FR/EN). (2) Ancres brutes sans JS → interception en `config.js` (scroll doux, lien actif, repli auto mobile, reset à l'ouverture). (3) Débordements 360px → bloc CSS mobile `#config-modal` (sommaire haut 46vh, grilles 1fr, add-rows wrap + largeurs inline neutralisées, items wrap, cibles 44px). `data-i18n-attr` multi-paires (`;`). Vérifié : `config-mobile.test.mjs` 11/11 (nouveau, au CI), pytest 1249 passed / 6 skipped, ruff/mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + ai-sidebar 6/6 + sidebar-filters 8/8 + mobile-editor 35/35 + config-ai-keys 7/7. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-072 | Correction | `frontend/js/viewer.js`, `frontend/style.css`, `tests/frontend/image-viewer.test.mjs`, `tests/e2e/image-viewer.spec.js`, `scripts/run-e2e-local.ps1` (nouveau), `package.json`, `AGENTS.md`, `README.md`, `README.fr.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-072** : dans la visionneuse d'images (#108-D), le plein écran (lightbox) et le panneau « Métadonnées » étaient perdus dès qu'on changeait d'image avec ←/→ (ou la pellicule), car `openFile` → `renderFile` recrée entièrement `renderImageViewer`. (1) **Persistance** : état module `_imageViewerState { lightbox, meta }` restauré à chaque rendu ; un drapeau `_imageViewerNavPending` posé par `go()` et le clic de vignette indique à `renderFile` que le rendu suivant est une navigation image→image (pas de réinitialisation) — toute autre ouverture repart à zéro. (2) **Panneau latéral** : `.image-meta-panel` déplacé dans un nouveau `.image-viewer-body` en flex row, à droite de `.image-stage` (`border-left`, `width:280px; max-width:40%`, défilement vertical) au lieu d'une bande sous la pellicule ; la règle lightbox ne masque plus que la pellicule. Boutons stables `image-btn-lightbox`/`image-btn-metadata` + `aria-pressed`, `Escape` resynchronise l'état. Tests statiques `image-viewer.test.mjs` (+2) et E2E Playwright (+1). **Diagnostic E2E** : `npm run test:e2e` bloquait car `bash` résout vers WSL (HS, Ubuntu `Stopped`, `HCS_E_CONNECTION_TIMEOUT`) et git-bash est bloqué par App Control → lanceur PowerShell ajouté. Vérifié : `image-viewer.spec.js` 4/4, **suite `chromium-desktop` complète 103 passed / 6 skipped (10,3 min)** via `scripts/run-e2e-local.ps1`, `image-viewer.test.mjs` 12/12, unit 10/10, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-073 | Correction | `frontend/style.css`, `tests/frontend/mobile-toolbar.test.mjs` (nouveau), `tests/e2e/mobile-toolbar.spec.js` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-073** : en mobile (≤768px), la barre fixe `#mobile-toolbar` (64px + safe-area) recouvrait le bas de tous les documents/pages — fin de contenu inaccessible. (1) **Cause** : la règle de clearance du bloc `@media (max-width: 768px)` ciblait `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, zéro dégagement ; règle sœur morte `.editor-modal.active ~ .main-layout { padding-bottom: 0 }` supprimée (overlay plein écran / nécessaire en édition inline). (2) **Correctif** : `.main-body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)) }`, reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture), `body.np-active .content-area` ramené de `calc(64px+safe+76px)` à `76px` (dégagement dock seul — géométrie totale identique, pas de double comptage avec `.main-body`). Tests : `mobile-toolbar.test.mjs` (7 statiques, ajouté au CI), E2E `mobile-toolbar.spec.js` (géométrie + scroll fin de `ANALYSE_REVIEW.md`, skip hors viewport ≤768). Vérifié : `mobile-toolbar` 7/7, JSDOM 14 suites 0 échec, **E2E `chromium-mobile` BUG-073 2/2 + régressions mobile-editor/config-mobile 6/6**, **suite `chromium-desktop` complète 106 passed / 9 skipped (11,4 min)**, pytest 1302 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | #114 | Feature | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`, `tests/e2e/config-mobile.spec.js`, `docs/features/settings-mobile-114.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **#114 — Configuration, refonte mobile-responsive (≤768px)**. (1) **Drawer sommaire** : `#config-nav` en panneau `position: fixed` (`min(320px, 88vw)`, z-index 40) sous backdrop `#config-modal.config-toc-open::before` (z-index 35) ; bouton `#config-toc-close` ; backdrop/Échap ferment le drawer d'abord puis la modale ; `_setConfigNav` bascule la classe + conserve le `display` inline de reset. (2) **Modale plein écran** : `100dvh` + `padding: 0`, `border-radius: 0`. (3) **Tactile** : boutons/liens ≥44px, inputs/selects `16px` + `min-height: 44px` (anti-zoom iOS, scopé `#config-modal`), rangée `.config-actions-row` sticky column + safe-area, formulaires 1 colonne, MFA 1 colonne + code full-width, wrap webhook/token/share/diag/avatar/webauthn. (4) **Dettes HTML/i18n** : `.config-actions-row` replacée dans `#cfg-backend-settings` (`</section>` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `<h2>`, `#plugins-settings-container` supprimé, doublons `.config-btn-add` + règle morte `.mfa-recovery-input` purgés, `#mt-explorer` → `data-i18n="settings.explorer"` ; i18n : clés mortes `settings.{backend,backend_hint,restart_badge,save,plugins}` supprimées, `settings.explorer` + `config.toc_close` ajoutées, `settings.tabs` FR = « Onglets ». Vérifié : `config-mobile.test.mjs` 27/27 (au CI), unit 11/11, validate-imports 40 modules, pytest 1302 passed / 6 skipped, ruff/mypy 0, E2E `chromium-mobile` 5/5. | ✅ livré (en attente vérif utilisateur) |
| 2026-09-24 | BUG-074 → BUG-077 | Correction | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot assistant IA (mode agent)** : (BUG-075) confirmation par lot — `pending.actions` regroupe toutes les mutations d'un tour LLM, un unique bouton « Tout approuver (N) » envoie `confirm_all` (`ToolContext.confirmed`) et n'interrompt plus à chaque action ; les lectures du lot s'exécutent aussitôt. (BUG-074) résumé du bloc d'étapes avec titre de la 1re action + compteur limité aux actions, reprise diffusée dans le même message (fini le « 1 étape » fragmenté). (BUG-076) refresh de l'arborescence débouncé sur les events `tool` mutateurs + `_notifyFileWritten` étendu aux documents (xlsx/docx/csv/pdf). (BUG-077) le bouton d'envoi devient « Stop » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »). Guide/i18n FR/EN + `ai.stop`/`ai.stopped`/`ai.confirm_actions`/`ai.action_apply_all` ; `SW_VERSION` v25. Vérifié : pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11, ai 100/100, editor-inline 44/44, mobile-editor 35/35, ai-sidebar 6/6, forge 32/32, pane-manager 9/9, sw 8/8. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
---
+7 -179
View File
@@ -1,182 +1,10 @@
# ObsiGate — Guide MCP (Model Context Protocol)
# Guide MCP — déplacé
> **Statut :** livré (#79 phase E + F) · **Dernière mise à jour :** 2026-09-11
> **Voir aussi :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) ·
> [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) · [ROADMAP.md](./ROADMAP.md)
> Ce guide a été déplacé dans le répertoire des guides utilisateur :
> **[docs/GUIDES/MCP.md](./GUIDES/MCP.md)**.
ObsiGate expose ses vaults à des **clients MCP externes** (Claude Desktop, Cursor,
tout client compatible MCP) via un serveur **Streamable HTTP** monté sur `/mcp`.
Les outils sont les **mêmes** que ceux de l'assistant in-app : la couche
`backend/tools/` est la source unique de vérité.
Le serveur MCP d'ObsiGate (`/mcp`) expose les mêmes outils que l'assistant IA à
Claude Desktop, Cursor, Cline et tout client compatible MCP. Configuration,
outils, resources/prompts, sécurité et dépannage s'y trouvent désormais.
---
## 1. Prérequis
1. Une instance ObsiGate accessible (locale ou distante).
2. Un **jeton JWT** valide (`Authorization: Bearer <token>`), obtenu via
`POST /api/auth/login` (ou une clé API). Le jeton porte les permissions par
vault de l'utilisateur — l'autorisation MCP réutilise `get_current_user`.
3. Si l'authentification est désactivée (`OBSIGATE_AUTH_ENABLED=false`), le
serveur MCP accepte un utilisateur anonyme disposant de tous les vaults.
> Le transport `stdio` n'est **pas** encore supporté ; utilisez le transport
> HTTP (un pont local type `mcp-remote` si votre client ne gère pas nativement
> le Streamable HTTP distant).
---
## 2. Endpoint & protocole
| Élément | Valeur |
|---|---|
| URL | `https://<obsigate>/mcp` |
| Transport | Streamable HTTP (`POST` JSON-RPC 2.0, `Accept: application/json, text/event-stream`) |
| Auth | `Authorization: Bearer <JWT>` |
| Protocole MCP | `2025-03-26` (négocié à l'`initialize`) |
| Réponses | JSON (`json_response=True`) |
Handshake minimal :
```bash
curl -sS https://obsigate.example/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-03-26","capabilities":{},
"clientInfo":{"name":"curl","version":"1.0"}}}'
```
La réponse contient l'en-tête `Mcp-Session-Id` à réutiliser pour les appels
suivants (`tools/list`, `tools/call`, `resources/read`, …).
---
## 3. Configuration des clients
### Claude Desktop (via pont `mcp-remote`)
```json
{
"mcpServers": {
"obsigate": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"https://obsigate.example/mcp",
"--header", "Authorization: Bearer ${OBSIGATE_TOKEN}"
],
"env": { "OBSIGATE_TOKEN": "eyJ..." }
}
}
}
```
### Cursor
`.cursor/mcp.json` :
```json
{
"mcpServers": {
"obsigate": {
"url": "https://obsigate.example/mcp",
"headers": { "Authorization": "Bearer eyJ..." }
}
}
}
```
---
## 4. Primitives exposées
### 4.1 Tools
Les outils de **lecture/recherche** sont exposés directement. Les outils
**d'écriture/destructifs** sont exposés via une paire **two-step** :
`propose_<tool>` (aperçu + jeton de confirmation, aucune modification) puis
`apply_<tool>` (consomme le jeton et exécute).
| Catégorie | Outils |
|---|---|
| Vaults / navigation | `list_vaults`, `list_directory`, `list_all_files` |
| Lecture | `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` |
| Recherche | `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` |
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
Flux d'une mutation :
```text
1. tools/call { name: "propose_edit_file",
arguments: { vault, path, content } }
→ { tool, arguments, diff, confirmation_token, expires_in }
2. (l'utilisateur / l'agent valide)
3. tools/call { name: "apply_edit_file",
arguments: { confirmation_token } }
→ { ok: true, data: { ... } }
```
Le jeton est **signé (JWT), à usage unique et à durée de vie limitée**
(`OBSIGATE_MCP_CONFIRMATION_TTL`, défaut 300 s). Un rejeu renvoie
`token_reused`.
### 4.2 Resources
| URI | Contenu |
|---|---|
| `vault://<name>` | Vault accessible (métadonnées, nombre de fichiers) |
| `vault://<name>/<path>` | Contenu d'un fichier (lecture seule, **secrets redactés**) |
### 4.3 Prompts
`summarize-directory`, `generate-note`, `find-related`.
---
## 5. Sécurité
- **Permissions par vault** : `check_vault_access` est appliqué à chaque outil
et chaque resource ; un utilisateur ne voit que ses vaults.
- **Anti path-traversal** : `resolve_safe_path` rejette tout chemin hors du vault.
- **Confirmation two-step** pour toute mutation (jeton signé, usage unique).
- **Toggle par vault** `aiDestructiveTools` (défaut : activé) : le désactiver
bloque rename/move/replace/delete tout en laissant create/edit/append.
- **Backup automatique** avant chaque opération destructive.
- **Rate limiting** : par jeton et par outil
(`OBSIGATE_TOOL_RATE_LIMIT`, `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL`,
`OBSIGATE_TOOL_RATE_WINDOW`). Une limite dépassée renvoie le code
`rate_limited`.
- **Redaction des secrets** : les résultats d'outils (lectures, diffs,
extraits de recherche) sont nettoyés avant tout retour au client.
- **Audit** : chaque appel est journalisé (`data/audit.log`, action
`ai_tool_call`) avec arguments sensibles résumés.
### Variables d'environnement
| Variable | Défaut | Rôle |
|---|---|---|
| `OBSIGATE_MCP_CONFIRMATION_TTL` | `300` | Durée de vie (s) des jetons de confirmation |
| `OBSIGATE_TOOL_RATE_LIMIT` | `60` | Appels d'outils max par identité et par fenêtre |
| `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL` | = global | Appels max par outil et par fenêtre |
| `OBSIGATE_TOOL_RATE_WINDOW` | `60` | Longueur de la fenêtre (s) |
| `BOOKSLM_MAX_TOOL_CALLS` | `25` | Quota d'appels d'outils par run d'agent |
| `BOOKSLM_MAX_TOOL_READ_BYTES` | `200000` | Taille max renvoyée par `read_file` |
---
## 6. Dépannage
| Symptôme | Cause probable / remède |
|---|---|
| `401 Authentification requise` | En-tête `Authorization: Bearer` absent ou jeton expiré |
| `vault_access_denied` | Le jeton n'a pas accès à ce vault (`vaults` / `_token_vaults`) |
| `destructive_tools_disabled` | `aiDestructiveTools=false` pour ce vault |
| `confirmation_required` | Appeler d'abord `propose_<tool>` puis `apply_<tool>` |
| `token_reused` / `invalid_confirmation` | Jeton déjà consommé ou expiré → refaire un `propose_` |
| `rate_limited` | Quota dépassé ; respecter `retry_after` |
| Le client ne se connecte pas | Vérifier le transport Streamable HTTP / le pont `mcp-remote` |
Sommaire des guides : [docs/GUIDES/README.md](./GUIDES/README.md).
+19 -13
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.11.3 | **Dernière mise à jour :** 2026-09-17
> **Version :** 2.25.0 | **Dernière mise à jour :** 2026-09-24
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -107,15 +107,6 @@
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite/Redis)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; service de partage public (expiration, révocation, quotas)
### 86. Optimisation globale des performances (phase 3)
- **Effort :** 4-6 jours | **Impact :** 🟡 | **Zone :** backend (`search.py`, `indexer.py`, `mutations.py`)
- **Description :** brancher l'inverted index (déjà construit) sur la recherche simple et le tool IA `search_fulltext`, indexation incrémentale, extraction PDF lazy.
- **Sous-tâches :**
- [ ] Recherche simple + tool IA via l'inverted index (suppression du balayage O(N) en mémoire)
- [ ] Indexation incrémentale + scan différentiel au démarrage (remplace le `rglob` complet)
- [ ] Extraction PDF/excalidraw différée (hors scan) ; caps CPU sur les opérations regex
### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4)
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
@@ -186,6 +177,21 @@
| 102 | Assistant IA — bouton « Ajouter la section » par bloc de code (insertion du bloc seul) | 2.9.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 92 | Assistant IA — Écosystème d'outils phase 2 (recherche à clé, cache/retry, Playwright, crawl, Gitea/GitHub, documents XLSX/DOCX/CSV/PDF) | 2.10.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) |
| 103 | Configuration — clés utilisateur des sources connectées & recherche à clé (page Configurations, `data/api_keys.json`, priorité sur l'env) | 2.11.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) |
| 104 | Configuration — Redesign UI de la section « Clés API IA » : recherche fournisseurs, carte défaut 2 colonnes + badges de capacités, cartes dépliables, footer d'actions sticky | 2.12.0 | [features/ai-keys-ui.md](./features/ai-keys-ui.md) |
| 105 | Guide d'utilisation — audit de couverture complet, téléchargement Markdown/PDF, guide desktop élargi, section Architecture (Mermaid) + BUG-067 | 2.13.0 | [features/guide-coverage-105.md](./features/guide-coverage-105.md) |
| 106 | Assistant IA — Actions instantanées contextuelles, catalogue « Toutes les actions » & frontmatter complet | 2.14.0 | [features/ai-quick-actions.md](./features/ai-quick-actions.md) |
| 107 | Configuration — Gestion des clés API & MCP : création/révocation de jetons longue durée (1 j, 1 mois, 6 mois, 1 an, sans fin), une seule clé pour l'API REST et le serveur MCP, « dernière utilisation », store `data/api_tokens.json` sans secret persisté | 2.15.0 | [features/api-mcp-tokens-107.md](./features/api-mcp-tokens-107.md) |
| 86 | Optimisation globale des performances (phase 3) — scan différentiel, excalidraw différé, garde-fou `replace` (inverted index / PDF lazy / caps regex déjà livrés via BUG-033/040/025) | 2.16.0 | [features/perf-phase3-86.md](./features/perf-phase3-86.md) |
| 108 | Support complet des images — arborescence, visionneuse (zoom/pan/navigation/miniatures), indexation nom+métadonnées, `media_types.py`, filtre `ext:`, SVG sandbox | 2.17.0 | [features/image-support.md](./features/image-support.md) |
| 109 | Support audio & vidéo — lecteurs HTML5 intégrés, streaming HTTP Range (`/api/media`), fallback codec/taille | 2.18.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
| 110 | Lecteur média persistant « Now Playing » — élément partagé téléporté (inline ⇄ dock), Media Session, mini-vidéo PiP, mobile, reprise | 2.19.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
| 111 | Visionneuse d'images — navigation fluide : image ajustée au cadre, navigation en place (cache annuaire + préchargement), pellicule persistante, flèches latérales au survol | 2.20.0 | [features/image-navigation-111.md](./features/image-navigation-111.md) |
| 112 | En-tête allégé & compte en sidebar — version dans le menu Options, retrait utilisateur/déconnexion du header, section compte en bas de la sidebar, pellicule d'images défilable (molette + flèches) | 2.21.0 | [features/header-user-sidebar-112.md](./features/header-user-sidebar-112.md) |
| 113 | Configuration — ordre naturel des sections (Profil 1er, À propos dernier, TOC = page) & avatar utilisateur (import PNG/JPG/WEBP, persistance serveur, cercle sidebar) | 2.22.0 | [features/settings-order-avatar-113.md](./features/settings-order-avatar-113.md) |
| 114 | Configuration — refonte mobile-responsive (modale plein écran 100dvh, sommaire en drawer coulissant, cibles tactiles ≥ 44 px, inputs 16 px anti-zoom, sauvegarde sticky, MFA 1 colonne, purge i18n/HTML) | 2.23.0 | [features/settings-mobile-114.md](./features/settings-mobile-114.md) |
| BUG-078 | Fichiers de code — coloration syntaxique restaurée (feuilles highlight.js basculées sur le mode de thème et non la clé) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
---
@@ -193,11 +199,11 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102, #92 | ~114 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–114, #92 | ~133 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (BUG-035 → BUG-040 corrigés) | ~15-23 jours |
| **Total restant** | **7 items + finitions** | **~27-42 jours** |
| ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
| **Total restant** | **6 items + finitions** | **~23-38 jours** |
---
+88
View File
@@ -0,0 +1,88 @@
# #104 — Configuration : Redesign UI de la section « Clés API Intelligence Artificielle »
> **Statut :** ✅ livré (v2.12.0) · **Zone :** `frontend/index.html`, `frontend/js/config.js`,
> `frontend/js/ai.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`
## Problème
La section « 🤖 Clés API IA » du panneau de configuration était une longue liste plate de
champs de saisie (DeepSeek, OpenRouter, Gemini, NVIDIA, QwenCloud, Xiaomi, Mistral) sans
structure ni hiérarchie visuelle : les sélecteurs défaut, les clés et les modèles se
cotoyaient au même niveau, les badges « Configuré » étaient collés à chaque champ, et les
boutons d'action se perdaient en milieu de section.
## Objectifs
Interface plus professionnelle, mieux structurée, moins fatigante visuellement (style
SaaS moderne, dark mode, cartes + accordéons), et qui reste utilisable quand la liste de
fournisseurs s'allonge.
## Structure livrée
### 1. En-tête de section
- Titre + sous-titre explicatif (i18n `config.ai_header_desc`).
- **Barre de recherche** (`#cfg-ai-search`) filtrant les cartes fournisseurs via
`filterAIProviders()` — normalisation insensible à la casse **et aux accents**
(`_sidebarNorm`), correspondance sur le nom affiché ou l'identifiant. État vide
« Aucun fournisseur ne correspond » (`#cfg-ai-providers-empty`).
### 2. Carte « Configuration par défaut »
- Carte visuellement distincte (`.ai-default-card`) : titre en petites capitales.
- Grille **2 colonnes** : « Fournisseur par défaut » / « Modèle par défaut »
(`#cfg-ai-default-provider`, `#cfg-ai-default-model`).
- **Capacités du modèle en badges colorés** : nouveau
`renderCapabilityBadges(caps)` (`frontend/js/ai.js`) qui n'affiche que les capacités
actives sous forme de tags (`.ai-cap-badge`), au lieu de la checklist ☑/□
(`renderCapabilityList`, conservée pour les pickers de l'assistant).
### 3. Fournisseurs d'API — cartes dépliables
Rendu dynamique par `_renderAIProviderCards()` depuis `AI_PROVIDER_NAMES` + nouveau
`AI_PROVIDER_META` (nom affiché, placeholder spécifique au fournisseur). Par carte :
- **Replié** : logo (initiale dans une pastille), nom, **badge de statut**
(« Configuré » vert / « Non configuré » gris, `.ai-provider-badge.configured`) et
**corbeille discrète** (`cfg-<provider>-delete`, visible uniquement si une clé existe ;
`stopPropagation` pour ne pas déplier la carte ; confirmation conservée).
- **Déplié** : libellés **au-dessus** des champs (`.ai-field-label`), **API key à 60 % /
modèle à 40 %** (grille `3fr 2fr`), placeholder par fournisseur.
- Accessibilité : en-tête en `role="button"` + `tabindex="0"` (clavier Entrée/Espace),
`aria-expanded` synchronisé, chevron animé, focus visible.
- Les boutons « Configuré / × Supprimer » redondants dans les champs sont supprimés —
le statut vit dans l'en-tête de la carte.
### 4. Barre d'actions
- Footer **sticky** en bas de section (`.ai-keys-footer`) : « Sauvegarder » (primaire
`.config-btn-save`) et « Tester » (outline `.config-btn-secondary`), plus le span de
statut du test. Toujours accessible pendant le défilement du panneau.
## Compatibilité
- Les ID `cfg-<provider>-key`, `cfg-<provider>-model`, `cfg-<provider>-badge`,
`cfg-<provider>-delete`, `cfg-ai-default-*`, `cfg-ai-status` sont conservés :
`saveAIKeys()`, `testAIKeys()`, `deleteAIKey()` et le câblage des événements de
`initConfigModal()` ne changent pas, ni la resynchronisation des pickers IA
(`refreshAIPickers()` après save/suppression, BUG-043).
- i18n : nouvelles clés `config.ai_header_desc`, `config.ai_search_placeholder`,
`config.ai_default_section`, `config.ai_providers_title`, `config.ai_providers_empty`,
`config.ai_status_configured`, `config.ai_status_not_configured`,
`config.ai_delete_key_title` (FR + EN).
## Styles
`.ai-keys-*`, `.ai-provider-*`, `.ai-cap-badge`, `.ai-field*` — uniquement des variables
CSS existantes (`--surface`, `--bg-secondary`, `--border`, `--accent`, `--success`,
`--danger`, `--accent-bg`…), focus visibles, responsive 1 colonne < 600 px.
## Tests
- `tests/frontend/config-ai-keys.test.mjs` (JSDOM, 7 tests) : rendu des 7 cartes,
badges de statut selon les clés masquées renvoyées par `GET /api/config/ai-keys`,
bascule replié/déplié (classe `open` + `aria-expanded`), filtre de recherche
(casse/accents + état vide), collecte et POST des clés saisies par `saveAIKeys()`,
suppression avec confirmation (DELETE sur l'env name), badges de capacités.
- Suites existantes (38 modules, validate-imports, ai.test.mjs, sidebar-filters…) : vertes.
- E2E Playwright (chromium-desktop) : 91 passed / 3 skipped, 100 % sans retries.
+89
View File
@@ -0,0 +1,89 @@
# #106 — Assistant IA : actions instantanées contextuelles & catalogue de prompts
> **Statut :** livré en 2.14.0 · **Domaine :** Assistant IA (frontend) ·
> **Fichiers :** `frontend/js/ai-quick-actions.js`, `frontend/js/bookslm.js`,
> `frontend/style.css`, `frontend/locales/{fr,en}.json`,
> `tests/frontend/ai-quick-actions.test.mjs`
## Problème
La zone d'accueil de l'assistant affichait 3 suggestions **statiques** par mode
(résumé / thèmes / contradictions), sans lien avec ce que l'utilisateur regarde
réellement (un fichier de code ? plusieurs documents ? une sélection dans
l'éditeur ?), et sans point d'accès au reste des prompts utiles.
## Solution
### Catalogue (`frontend/js/ai-quick-actions.js`, module pur sans DOM)
25 actions en 6 catégories, chacune = `{id, cat, icon (lucide), labelKey,
promptKey, agent?}` — libellés **et** prompts i18n FR/EN (clés `qa.*`) :
| Catégorie | Actions |
|---|---|
| 📝 Synthèse & Analyse | Résumer en 3 points clés · Frictions/contradictions · Vulgariser · FAQ |
| ✅ Productivité & Structuration | Checklist d'actions · Plan d'action · Mémo exécutif · **Générer le frontmatter YAML** · **Mettre à jour le frontmatter** · Liens/backlinks · Sections hiérarchiques |
| 💻 Code & Scripts | Expliquer · Bugs & failles · Docstrings/types · Tests unitaires |
| 🔗 Cross-documents | Comparer · Fusionner en note de synthèse · Chronologie |
| ✍️ Édition & Reformulation | Concis · Corriger le style · Reformuler · Traduire · Expliquer la sélection |
| ❔ Assistant (général) | Que sais-tu faire · Rechercher efficacement · Créer une note de réunion |
Les deux actions frontmatter sont marquées `agent: true` : un clic bascule
transparentement le panneau en **mode agent** (comme Deep Research) puis envoie
le prompt — l'assistant lit le document, propose la mutation via l'outil
`edit_file`/`append_to_file`, et la **carte de confirmation** (#79) applique le
nouveau bloc en tête du fichier.
Le prompt « Générer le frontmatter YAML » demande le format complet du vault de
Bruno (titre, auteur, creation_date/modification_date ISO-8601 avec fuseau,
catégorie, tags en liste inline, aliases, status, publish, favoris, template,
task, archive, draft, private, NomDeVoute, Description). « Mettre à jour le
frontmatter » **conserve les champs existants** : actualise
`modification_date`, recalcule titre/tags/aliases/catégorie/NomDeVoute/Description
d'après le contenu, complète les champs manquants.
### Sélection contextuelle (triage par défaut dans l'UI)
`detectContext({mode, docCount, currentPath, hasSelection})` — précédence :
**sélection > code > multi-docs > doc unique > répertoire > général**.
| Contexte | Boutons suggérés |
|---|---|
| 1 doc texte | Résumer 3 points · Checklist · Générer le frontmatter · Mettre à jour le frontmatter |
| 2+ docs | Fusionner · Comparer · Frictions/contradictions |
| Fichier de code (.py, .ts, .sh…) | Expliquer · Bugs & failles · Tests unitaires |
| Sélection dans l'éditeur | Concis · Corriger le style · Expliquer la sélection |
| Répertoire | Résumer le répertoire · Thèmes · Checklist |
| Général | Que sais-tu faire · Rechercher · Créer une note |
### Présentation (calquée sur le POC `ai_assistant_quick_actions_poc.html`)
- Badge de contexte dans l'en-tête (« 1 doc ouvert », « Fichier de code »,
« Sélection active », « 3 docs ouverts »…).
- Ligne « Actions suggérées » + bouton **« Toutes les actions »** ouvrant un
**tiroir bottom-sheet** : recherche instantanée (insensible aux accents/casse
via `normalizeSearch`) + catalogue groupé par catégorie, Échap/backdrop
pour fermer.
- Boutons icône + libellé + flèche au survol ; clic = **envoi immédiat** du
prompt dans le composer (chemin `_sendMessage()` : skills, images et mode
agent continuent de fonctionner).
- La rangée se masque dès le premier message du fil (comportement historique) ;
le rafraîchissement du contexte à la sélection éditeur est throttled 250 ms
(`selectionchange` + `mouseup`/`keyup`, CodeMirror n'émettant pas
`selectionchange` natif).
- CSS 100 % variables de thème (`--surface`, `--accent`, `--accent-bg`…) →
conforme aux 15 thèmes ; transitions désactivées en `prefers-reduced-motion`.
## Tests
`tests/frontend/ai-quick-actions.test.mjs` (13 tests, câblé au job lint CI) :
précédence des contextes, presets 3-4 actions résolvables, table de conception,
agent-flag des actions frontmatter, **complétude FR+EN** de chaque clé
catalogue/badge contre les vrais JSON de locales.
## Vérification live (obsigate-test :2020, Playwright)
badge « 1 doc ouvert », 4 suggestions dont les 2 frontmatter, tiroir à 6
catégories / 25 actions, recherche « frontmatter » → 2 résultats, clic →
`aria-pressed=true` sur le bouton agent + message envoyé avec le nouveau
prompt + réponse de l'assistant.
+2 -1
View File
@@ -20,6 +20,7 @@
- [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend **pour le chat classique uniquement** (BUG-053 : en mode agent, le prompt impose les outils natifs et interdit les blocs `obsigate-action`)
- [x] **B4.** SSE réellement streaming — `ai_chat.stream_completion` (`_openai_stream` + `_gemini_stream`) alimente `/api/ai/bookslm/chat` token par token ; le middleware GZip laisse passer les endpoints SSE BooksLM.
- [x] **B5.** Confirmations UI : toggle « mode agent » (front → `/agent`), événements `tool`/`confirmation`, carte Apply + aperçu diff (LCS) pour les mutations, reprise `confirm`/`confirm_messages` côté backend. *S'active dès que la phase D enregistre des outils `write`.*
- **Complément (BUG-074 → BUG-077)** : la pause de confirmation **regroupe toutes les mutations** d'un même tour LLM (`pending.actions`, chacune avec son libellé `step` et son diff) et la carte n'offre plus qu'un seul bouton « **Tout approuver (N)** » ; la reprise envoie `confirm_all` et le backend arme `ToolContext.confirmed` pour le reste du run (plus d'approbation action par action). Le bloc d'étapes affiche un **titre** (1re action) et ne compte que les **actions** (hors réflexions) ; la reprise diffuse dans le **même message** (« N étapes » cumulées). L'arborescence et le document affiché sont **rafraîchis** dès une action mutatrice (refresh débouncé + `obsigate:file-written`, documents xlsx/docx/csv/pdf inclus). Le bouton d'envoi devient « **Stop** » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »).
- [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md))
- [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`)
@@ -67,7 +68,7 @@
`call_tool` (couvre les diffs, extraits de recherche et lectures non pré-redactées).
- [x] **F3.** Documentation OpenAPI + guide MCP — `backend/openapi_docs.py` : tag `MCP`,
règle `/mcp`, injection du path `/mcp` (Streamable HTTP, JSON-RPC) dans le schéma ;
nouveau [`docs/MCP_GUIDE.md`](../MCP_GUIDE.md) (endpoint, auth, config Claude Desktop /
nouveau [`docs/GUIDES/MCP.md`](../GUIDES/MCP.md) (endpoint, auth, config Claude Desktop /
Cursor, tools/resources/prompts, sécurité, variables, dépannage).
- [x] **F4.** Tests E2E de bout en bout — `tests/test_ai_e2e.py` : agent in-app
read→confirmation→write, quota d'outils, rate limiting, redaction, et flux MCP complet
+95
View File
@@ -0,0 +1,95 @@
# #107 — Clés API & MCP (panneau de configuration)
**Version : 2.15.0 — statut : complété (septembre 2026)**
## Problème
Pour brancher un client MCP externe (Claude Desktop, Cursor…) ou scripter
l'API REST, il fallait soit se connecter et voler le JWT de session de 1 h
dans le navigateur, soit générer un token à la main via `docker exec`
(`generer_access_token.sh`) — sans expiration maîtrisable ni révocation.
## Décision : une seule clé pour l'API ET le MCP
Le serveur MCP (`/mcp`, `backend/mcp/server.py::_authenticate`) résout
l'appelant via la même dépendance `get_current_user()` que l'API REST.
Un jeton HS256 `type=access` authentifie donc **les deux surfaces** — il
n'y a pas de famille de clés séparée à exposer dans l'UI. C'est dit
explicitement dans la section (« la même clé fonctionne pour les deux »)
et verrouillé par tests (REST 200 + MCP initialize 200 avec la même clé ;
révocation → 401 des deux côtés).
## Conception
### Store — `data/api_tokens.json`
```json
{"version": 1, "tokens": {"<jti>": {
"name": "Claude Desktop", "username": "admin",
"created_at": 1790000000, "expires_at": 1792592000,
"expiry_key": "30d", "last_used_at": null
}}}
```
Le JWT brut n'est **jamais persisté** : affiché une seule fois à la
création, sinon perdu (pattern GitHub). La révocation fonctionne par
`jti` : le JWT présenté est rejeté par `is_token_revoked` même s'il est
encore valide dans sa signature.
### Expirations (choix UI)
| Clé | Durée | `exp` dans le JWT |
|---|---|---|
| `1d` | 1 jour | iat + 86 400 |
| `30d` | 1 mois | iat + 2 592 000 |
| `180d` | 6 mois | iat + 15 552 000 |
| `365d` | 1 an | iat + 31 536 000 |
| `never` | sans fin | **aucun claim exp** |
Plafond : 50 tokens actifs par utilisateur (`API_TOKEN_MAX_PER_USER`).
### Correction induite — révocation longue durée
L'ancien `revoked_tokens.json` bornait toute entrée à 7 jours ; une clé
1 an révoquée aurait « repris vie » au nettoyage suivant. Le store devient
un dict `{jti: valid_until}` calé sur l'expiration réelle du jeton
(« sans fin » → 100 ans). Session tokens inchangés (7 j).
### « Dernière utilisation »
`get_current_user` appelle `maybe_touch_api_token(jti)` pour les jetons
`api: true` — écriture disque throttlée à 1 h par jti, silencieuse si le
dossier est en lecture seule ; ne fait jamais échouer une requête.
## Endpoints (`/api/auth`, auth requise, périmètre = l'appelant)
- `GET /api/auth/tokens` → `{tokens: [...], expiry_choices: [...]}`
- `POST /api/auth/tokens` `{name, expiry}` → `{token, ...record}` (secret unique)
- `DELETE /api/auth/tokens/{jti}` → révocation immédiate API + MCP
- Audit : `config_change` / `api_token_create|revoke`.
## UI — panneau Configuration
Nouvelle section `#cfg-tokens` « 🔑 Clés API & MCP » (après Sécurité) :
liste (badge Active/Expirée, créée/expire/dernière utilisation), champ
nom + sélecteur d'expiration + Créer, zone secrète en tirets avec Copier,
bloc « Utilisation » : header `Authorization: Bearer <clé>` + exemple de
config MCP avec headers sur `<base>/mcp`. 28 clés i18n FR/EN, SW v24.
## Tests — `tests/test_api_tokens.py` (13)
Création/liste (secret jamais restitué), les 5 durées dont l'absence
d'`exp` pour `never`, expiration invalide → 400, clé identique acceptée
par REST **et** `/mcp`, refus MCP anonyme, isolation par utilisateur,
révocation → 401 immédiat des deux côtés, survie de la révocation
longue durée après reload disque, drapeau `expired`, migration de format
`revoked_tokens.json`. Suite auth + MCP complète verte (77).
## Config MCP externe (exemple)
```json
{"mcpServers": {"obsigate": {
"url": "http://localhost:2020/mcp",
"headers": {"Authorization": "***"}
}}}
```
+10 -7
View File
@@ -1,6 +1,6 @@
# #78 — Éditeur Excalidraw — Ouverture et édition de fichiers .excalidraw
> **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é)
> **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 et BUG-064 corrigés. 2026-09 : A9 bouton **plein écran** ajouté, auto-save retirée au profit d'une sauvegarde explicite (BUG-065))
> **Effort :** 3-4 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog — 2.2.0](../../CHANGELOG.md)
@@ -56,17 +56,18 @@
</script>
```
- [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)
- [x] **A6. Barre d'outils minimaliste** (dans l'iframe ; depuis 2026-09 : **colonne d'icônes** collée au bord droit (`right: 0`), début à `45%` de la hauteur, empilement vertical) :
- Bouton « Sauvegarder » (icône disquette → coche après sauvegarde) → envoie les données au parent
- Boutons « Export PNG » (icône image) et « Export SVG » (icône vectorielle) — infobulles au survol
- Bouton plein écran (A9)
- Badge « Modifié » réduit à une pastille au-dessus des boutons
- [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.
- [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.
- [x] **A9. Bouton plein écran** (ajouté 2026-09) : bouton `#btn-fullscreen` dans la barre d'outils de l'iframe → `document.documentElement.requestFullscreen()` (l'iframe parent est créée avec `allow="fullscreen" allowfullscreen`) ; l'icône bascule entrer/sortir via `fullscreenchange`. La feuille de style Excalidraw étant chargée, le canvas suit le redimensionnement. Test statique : `tests/frontend/excalidraw-viewer.test.mjs`.
## B. Backend — Détection et API (0.5 jour)
- [x] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés.
@@ -100,7 +101,7 @@
- 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`
- [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] **C4. Sauvegarde explicite uniquement** (modifié 2026-09 : l'auto-save a été **retirée**, BUG-065) : l'iframe émet `modified` → le badge « Modified » s'affiche, mais **aucune sauvegarde automatique** n'est déclenchée. La sauvegarde se fait par le bouton « 💾 Save » de l'iframe ou `Ctrl+S`. Raison : chaque écriture déclenche l'événement SSE `index_updated`, qui re-rendait la vue et **rechargeait l'iframe** (refresh visible en pleine édition).
- [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()` :
@@ -141,6 +142,8 @@
- **Taille du bundle** : React + ReactDOM + Excalidraw ≈ 2.5 Mo minifié. Chargé depuis `esm.sh` (CDN global, cache HTTP). L'impact n'est perceptible qu'à la première ouverture d'un `.excalidraw`. Solution : précharger l'iframe en arrière-plan (`<link rel="prefetch">`) après le chargement de l'app.
- **Performance React dans iframe** : React dans une iframe fonctionne parfaitement — c'est un contexte JavaScript indépendant. Testé sur Chrome, Firefox, Safari, Edge.
- **CORS et esm.sh** : Les modules ESM depuis `esm.sh` sont servis avec les headers CORS appropriés. L'iframe est same-origin (`/frontend/excalidraw-editor.html`) donc pas de problème.
- **Compatibilité des exports de l'app Excalidraw** (BUG-064) : `appState.collaborators` est une `Map` qu'Excalidraw sérialise en objet JSON (`{}`) ; elle doit être reconvertie en `Map` (`sanitizeAppState()` dans `frontend/excalidraw-editor.html`) avant `initialData`, sinon Excalidraw 0.18 plante (`collaborators.forEach is not a function`). La géométrie de viewport (`width`, `height`, `offsetLeft`, `offsetTop`) est également écartée : ce sont des valeurs mesurées côté fenêtre source, qu'Excalidraw recalcule. Couvert par un test E2E (`diagram-app-export.excalidraw`).
- **Feuille de style Excalidraw obligatoire** (BUG-064) : `@excalidraw/excalidraw` n'injecte pas son CSS automatiquement — il faut le charger explicitement (`<link>` vers `…/@excalidraw/[email protected]/dist/prod/index.css`). Sans lui, l'éditeur est non stylisé **et** `.excalidraw` n'a pas de hauteur fixe, ce qui déclenche une boucle de redimensionnement jusqu'au plafond `2^25` (33 554 432 px) : le canvas devient indessinable et la scène reste blanche. Le CDN `esm.sh` doit donc figurer dans `style-src` de la CSP (`backend/main.py`). Garde-fous : `tests/frontend/excalidraw-viewer.test.mjs` et `TestCspExcalidrawStylesheet`.
- **Mises à jour d'Excalidraw** : La version est épinglée (`@0.18.0`). Pour mettre à jour, changer le numéro dans le HTML + tester. Le format de données `.excalidraw` est stable (v2 depuis 2021).
- **Sécurité postMessage** : Vérifier `event.origin` dans les deux sens. L'iframe n'accepte que les messages de `window.parent`. Le parent n'accepte que les messages de l'iframe connue. Pas de `"*"` en production.
- **Tauri Desktop (#77)** : L'iframe se charge depuis le filesystem local (`tauri://localhost/frontend/excalidraw-editor.html`). Les imports ESM depuis `esm.sh` fonctionnent si le réseau est disponible. Pour le mode offline, bundler Excalidraw dans l'app desktop (à traiter dans #77, pas ici).
+139
View File
@@ -0,0 +1,139 @@
# #105 — Guide d'utilisation : couverture, téléchargement MD/PDF, Architecture
> **Statut :** livré | **Version :** 2.13.0 | **Bugs liés :** BUG-067
> **Roadmap :** [docs/ROADMAP.md](../ROADMAP.md) · **Changelog :** [../../CHANGELOG.md](../../CHANGELOG.md)
## 1. Objectif
Après ~35 features livrées (#70→#104), le guide intégré (modale « Guide
d'utilisation », `#help-modal` de `frontend/index.html`) ne représentait plus
l'application : Mermaid, hors-ligne/PWA, collaboration Yjs, desktop Tauri,
exports HTML/ePub/ZIP, recherche sémantique, MFA/WebAuthn, push, split view,
API OpenAPI/MCP étaient absents. L'item couvre quatre livrables :
1. **Audit de couverture** — toutes les fonctionnalités visibles ET non visibles
(API, MCP, webhooks, endpoints de partage) sont documentées.
2. **Section Architecture** — diagramme Mermaid des grandes composantes.
3. **Téléchargement Markdown + PDF** du guide, dans la langue courante.
4. **Lecture desktop élargie** (mode Tauri + grands viewports web).
## 2. Source de vérité du contenu
Le contenu des nouvelles sections vit dans **`scripts/guide_content.py`** :
dictionnaire `CONTENT` (clé → FR, EN) + constructeurs HTML qui **ne peuvent
pas diverger** des locales (le FR inline est généré depuis `CONTENT`).
- `scripts/merge_guide_locales.py` → injecte les clés `guide105.*` dans
`frontend/locales/{fr,en}.json` (insertion textuelle, parité assertée).
- `scripts/insert_guide_sections.py` → insère sections/TOC/compléments dans
`index.html` (idempotent, préserve les fins de ligne, vérifie l'équilibre
`<section>` et l'absence d'ancre morte).
- **Règle :** ne jamais éditer les blocs `guide105.*` des JSON ni les sections
#105 de `index.html` à la main — modifier `guide_content.py` et relancer les
deux scripts (séquence figée, sinon HTML et locales divergent).
## 3. Rendu du guide dans l'app
Les textes portent `data-i18n="guide105.*"` ; `_applyDOM()` (i18n.js) les
remplit avec la locale courante. Les valeurs FR/EN contiennent du **HTML
minimal** (`<code>`, `<strong>`, `<a href="https://…">`) — sûre ici car ces
chaînes sont statiques dans le dépôt (jamais de contenu utilisateur ; même
convention que `help.desc_*` existant).
Le diagramme d'architecture est un bloc `<pre class="mermaid-code"><code
class="language-mermaid">` : à l'ouverture de la modale, `renderGuideMermaid()`
(config.js) appelle `renderMermaidBlocks()` (mermaid-viewer.js) sur la modale —
le viewer ne rend que les vues document, la modale est donc enrichie ici. Si le
CDN Mermaid n'est pas prêt, le bloc reste du code lisible et le rendu est
retenté à l'ouverture suivante (`data-mermaid-rendered` ne se pose qu'après
succès). Le MD exporté embarque le diagramme en fenced block ` ```mermaid `
(copiable, rendu par GitHub/VS Code/Obsidian) ; le PDF affiche le PNG pré-rendu
(section 4 — WeasyPrint n'exécute pas Mermaid).
## 4. Téléchargement (MD + PDF)
`backend/guide_export.py` : parseur stdlib (html.parser → arbre minimal) ;
extraction de `#help-modal`→`.help-content`, résolution i18n **identique à
_applyDOM** (un élément `data-i18n` est remplacé par la valeur locale, sinon le
FR inline sert de repli), conversion Markdown (titres, listes imbriquées,
tables, fenced code, gras/italique/code/kbd/links http) et HTML propre pour
WeasyPrint. PDF : pipeline d'export existant (`build_pdf_html` + `generate_pdf`),
repli `_render_reportlab_pdf` (markdown simplifié) quand GTK manque (Windows
nu) — même stratégie que le bouton « PDF » des documents (#92).
`get_guide_document(fmt, lang)` met en cache (octets+signature mtime/size de
`index.html` et `fr.json`) pour éviter de re-générer à chaque requête.
Endpoint `GET /api/guide/download?format=md|pdf&lang=fr|en`
(`require_auth`, tag OpenAPI « Guide »), `Content-Disposition: attachment`.
Côté UI : boutons **icônes seules** 📄/⬇ dans l'en-tête de la modale
(`#help-download-md`/`#help-download-pdf`, tooltip i18n), handler `downloadGuide()` dans
`frontend/js/config.js` — fetch avec `AuthManager.getAuthHeaders()` +
credentials (identique à `viewer.downloadExport()`), blob → lien
téléchargeable, toasts i18n réutilisés (`viewer.export_*`).
Diagramme d'architecture dans le PDF : WeasyPrint n'exécute pas Mermaid → le code
Mermaid est **pré-rendu en PNG** (`scripts/build_guide_diagrams.py` +
`scripts/render_guide_diagram.mjs`, Chromium + mermaid v11 CDN, scale 2) et le PNG
est **commité** dans `backend/assets/guide_diagrams/<sha1>.png` ; `diagram_png_for()`
(hash sha1[:16] du code normalisé, même algorithme des deux côtés) remplace le
bloc par `<img src="file:///…">` dans le HTML d'export. Après modification du
diagramme dans `guide_content.py` + réinsertion : relancer
`python scripts/build_guide_diagrams.py` et committer le nouveau PNG.
Emoji : l'image Docker installe `fonts-noto-color-emoji` et la pile de polices
PDF finit par `"Noto Color Emoji"` — sans cela les emoji pleine chasse des titres
de section s'affichent en rectangles (la TOC n'était pas touchée car elle passe
par DejaVu/Sans).
## 5. Guide desktop élargi
`frontend/js/desktop.js::initDesktopIntegration()` ajoute `body.desktop-mode`
(une fois, après le garde Tauri). CSS (section « Help Modal: desktop » de
`style.css`) : conteneur 1760 px / 96 vw, contenu 1440 px, modale 94 vh ; le
même layout est accordé aux viewports ≥1400 px web via media-query (contenu
1280 px). Le mobile reste inchangé (≤768 px plein écran).
## 6. Couverture fonctionnelle du guide (après #105)
| Domaine app | Section du guide |
|---|---|
| Vaults, arborescence, filtres, breadcrumb | Interface, Navigation |
| Onglets, popout, split view | Onglets, Personnalisation |
| Recherche TF-IDF, opérateurs, facettes, sémantique, recherches sauvegardées, signets | Recherche, Bibliothèque |
| Tags, frontmatter | Tags |
| Fichiers, types supportés, CodeMirror, PDF, export HTML/ePub/ZIP, anti-doublons upload | Fichiers |
| Mermaid | Diagrammes |
| Excalidraw | Excalidraw |
| Éditeur, AI toolbar, BooksLM, commandes @//, skills, images, outils, historique | Édition, IA |
| Édition mobile | Mobile (section dédiée, BUG-067) |
| Graphe, backlinks | Bibliothèque, Graphe |
| Palette de commandes, raccourcis | Palette, Raccourcis |
| Partage public, PDF du partage, webhooks HMAC | Partage, Webhooks |
| Backups, diff, purge, audit log, gestionnaire | Sauvegardes et Audits |
| JWT/Argon2id, rate limit, MFA TOTP/WebAuthn, admin dashboard, secrets, CSP | Sécurité |
| PWA hors-ligne, IndexedDB queue, watcher, conflits Syncthing | Hors-ligne, Bibliothèque |
| Collaboration Yjs/CRDT, awareness, push VAPID | Collaboration, Interface |
| Tauri desktop (updater signé, wizard, fenêtrage) | Desktop |
| API REST OpenAPI (/docs, /redoc, /api, /openapi.json), MCP /mcp, automatisation | API & Intégrations |
| i18n FR/EN, export du guide multilingue | Multilingue |
| Architecture technique (couches, flux, données, déploiement) | **Architecture** (nouveau) |
| Plugins sandboxés | Plugins |
## 7. Tests & vérifications
- `tests/test_guide.py` (13) : ancre TOC→sections (garde-fou BUG-067),
sections #105 présentes, parité/présence des clés `guide105.*` FR=EN,
Markdown FR/EN (titres, mermaid, absence de balises résiduelles), PDF
(`%PDF`, taille), **diagramme Architecture rendu en `<img>` PNG dans le HTML
d'export**, MD garde le fenced mermaid, metadata `get_guide_document`,
endpoint MD/PDF/400 via TestClient, OpenAPI (path + tag « Guide »).
- Suite backend pytest : 1218 passed · ruff/mypy 0 erreur ·
`validate-imports` 38 modules · `unit.test.mjs` 10/10 · E2E complet CI.
- SW cache busting : `SW_VERSION` v21→v23 (guides + boutons icônes).
## 8. Limites assumées
- Le PDF est généré côté serveur avec les polices système ; en l'absence de
GTK (Windows de dev) c'est le repli reportlab (sans tableaux) — la voie
WeasyPrint est celle des conteneurs Docker/prod.
- Le sommaire et les sections sont en dur dans `index.html` : tout ajout futur
de section passe par `guide_content.py` + les deux scripts (jamais à la main).
+69
View File
@@ -0,0 +1,69 @@
# #112 — En-tête allégé & compte en sidebar
> **Version livrée :** 2.21.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`index.html`, `frontend/js/auth.js`, `frontend/js/viewer.js`,
> `frontend/style.css`, i18n FR/EN) + CI (`.gitea/workflows/ci.yml`).
## Contexte
Le header concentrait plusieurs éléments redondants ou peu lisibles :
- la **version** était affichée en permanence à côté du bouton Options ;
- un **bouton de déconnexion** et le **nom de l'utilisateur** occupaient la zone
droite du header, sans regroupement logique ;
- la pellicule de miniatures de la visionneuse d'images ne pouvait être
parcourue qu'au glisser (pas de molette horizontale ni de flèches dédiées).
## Ce qui a été livré
### A. Header allégé
- **Version déplacée** du header vers le menu **Options** : nouvelle ligne
non-interactive « Version » (`#version-badge` conservé pour `populateVersions`).
- **Suppression** du bloc `#user-menu` (nom + bouton « sign out ») et de la
ligne « Déconnexion » du menu Options. Le header droit ne garde que l'indicateur
hors-ligne, le contexte vault et le bouton Options.
### B. Section compte en bas de la sidebar
- Nouveau bloc `#sidebar-user` épinglé au bas de la sidebar (`flex-shrink: 0`,
bordure supérieure), à la manière d'un site web professionnel :
- initiales dans un **avatar** circulaire (dégradé d'accent) ;
- **nom** (`#sidebar-user-name`, classe `.user-display-name` conservée pour la
réutilisation par la collaboration) et **rôle** localisé
(Administrateur / Utilisateur) ;
- **bouton déconnexion** (`#sidebar-user-logout`) ;
- clic sur l'identité → **Profil** (ouvre la section `#cfg-profile`).
- `AuthManager.renderUserSection()` peuple le bloc ; il reste masqué quand
l'authentification est désactivée (`#sidebar-user[hidden]`). `renderUserMenu()`
est conservé comme alias rétro-compatible.
- La règle mobile qui masquait `.user-display-name` est limitée au header
(`.header-right .user-display-name`) pour que le nom reste visible dans la
sidebar mobile.
### C. Pellicule d'images défilable (#112, complément de #111)
- Nouveau conteneur `.image-nav` autour de la pellicule, avec deux **flèches
translucides** fixes (`.image-strip-arrow-prev/next`) révélées au survol.
- **Molette** au-dessus de la pellicule → défilement horizontal
(`strip.scrollLeft += deltaY`), conversion des deltas verticaux.
- Les flèches font défiler d'une « page » (`strip.scrollBy`, défilement doux).
## Tests
- `tests/frontend/unit.test.mjs` (+1) : header nettoyé (plus de `#user-menu`,
`#logout-btn`, ni version dans `.header-right`), version dans le menu Options,
présence de `#sidebar-user`, styles `.sidebar-user`/`.menu-list-version`, clés
i18n FR/EN.
- `tests/frontend/image-viewer.test.mjs` (+1) : wrapper `.image-nav`, flèches de
pellicule, molette et `scrollBy`.
- `tests/e2e/header-sidebar.spec.js` (nouveau) : test header/version exécuté
partout ; test compte en sidebar conditionné à l'auth (skip sinon).
- CI : `image-viewer.test.mjs` ajouté au job `lint` (il n'y était pas depuis #108).
## Limitations connues
- La section compte n'apparaît que si l'authentification est activée.
- `doLogoutFallback` (script inline supprimé avec la ligne du menu) reste appelé
par la section Profil uniquement en repli ; `window.handleLogout` est toujours
défini par `auth.js`, donc le repli n'est jamais atteint.
+80
View File
@@ -0,0 +1,80 @@
# #111 — Visionneuse d'images : navigation fluide
> **Version livrée :** 2.20.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/style.css`, i18n FR/EN).
> **Améliore :** [#108](./image-support.md) (visionneuse livrée en 2.17.0).
## Contexte
La visionneuse d'images de #108 fonctionnait mais restait perfectible sur l'usage
quotidien :
- certaines images s'affichaient **plus grandes que le cadre** de présentation ;
- changer d'image (←/→, flèches, vignette) appelait `openFile` → `renderFile` →
`renderImageViewer`, soit **un rechargement complet** de la vue **et** un
nouvel appel `/api/browse` à *chaque* image — sensible dès qu'un dossier en
contient beaucoup ;
- la **pellicule de miniatures disparaissait** le temps du re-rendu ;
- aucune zone de clic latérale ne permettait de changer d'image.
## Ce qui a été livré
### A. Ajustement au cadre
- La zone de contenu devient un conteneur **flex** dédié à la visionneuse
(`.content-area:has(> .image-viewer-container) { padding: 0; overflow: hidden }`) :
plus de défilement de page, la visionneuse occupe tout l'espace disponible.
- `.image-stage` gagne `min-height: 0`, un `padding` de 16 px (cadre) et
`box-sizing: border-box` ; `.image-main` conserve `max-width/height: 100%`
avec `width/height: auto`. L'image est donc **toujours redimensionnée dans
l'espace disponible**, y compris quand le panneau « Métadonnées » s'ouvre
(la scène se réduit, l'image suit).
### B. Navigation « en place » (performance)
- `renderImageViewer` ne se contente plus de rendre une image : il gère un état
mutable (`currentPath`, `currentTitle`, `imgUrl`, `currentMeta`) et expose
`showSibling(index)` qui **remplace le `src` du `<img>`** sans reconstruire le
DOM. Les flèches, le clavier ←/→ et les miniatures passent tous par là.
- Conséquences : plus de `openFile`, plus de re-rendu, **plus de refetch du
fichier ni de `/api/browse`** à chaque image.
- **Cache annuaire** `_imageDirCache` (`Map`, TTL 15 s) : la liste des images
d'un dossier n'est récupérée qu'une fois par courte fenêtre.
- **Préchargement** des images voisines (`new Image()`), avec un `Set` pour
éviter les doublons.
### C. Pellicule persistante
- La pellicule de miniatures n'est plus recréée à chaque navigation ; la
vignette active est simplement re-marquée (`.active`) et **amenée dans la vue
par défilement horizontal du film uniquement** (jamais la page).
- Miniatures en `loading="lazy"` + `decoding="async"`.
### D. Flèches latérales translucides
- Deux boutons superposés `.image-nav-arrow` (prev/next) longent les bords du
cadre, avec une icône `chevron` et une ombre portée pour rester lisibles sur
toute image.
- Opacité quasi nulle au repos, révélée au **survol du cadre** (`0.4`) puis du
bouton (`1`, avec dégradé sombre). Toujours visibles (opacité moyenne) sur les
appareils tactiles (`@media (hover: none)`).
- Le `pointerdown`/`dblclick` des flèches n'est pas propagé à la scène : le
pan (glisser) et le double-clic de réinitialisation du zoom restent intacts.
- Un **compteur** `n / total` est ajouté à la barre d'outils.
## Tests
- `tests/frontend/image-viewer.test.mjs` : helpers purs inchangés + vérifications
statiques de la navigation en place (`showSibling`, absence de
`_imageViewerNavPending`, cache annuaire), des flèches et du CSS
(`:has(> .image-viewer-container)`, `max-width/height`, `opacity`).
- `tests/e2e/image-viewer.spec.js` (+1) : image contenue dans le cadre, flèche
superposée révélée au survol, titre mis à jour **sans recréer le conteneur**
(marqueur `data-inplace`), pellicule toujours visible et compteur affiché.
## Limitations connues
- Le cache annuaire a un TTL court : une image ajoutée puis ouverte dans les
quelques secondes peut ne pas apparaître tout de suite dans la pellicule.
- Les flèches latérales n'apparaissent pas en mode lightbox plein écran
(navigation clavier ←/→ et pellicule masquée conservées).
+96
View File
@@ -0,0 +1,96 @@
# #108 — Support complet des images (arborescence, visionneuse, indexation)
> **Version livrée :** 2.17.0 · **Statut :** ✅ · **Impact :** 🟡
> **Zone :** backend (`indexer`, `main`, `media_types`, `media_thumbs`) + frontend
> (`viewer.js`, `utils.js`, `style.css`).
## Contexte
Seul l'affichage *inline* dans un document markdown (`![[image.png]]`) fonctionnait.
L'image isolée était **invisible dans l'arborescence** (filtrée par
`SUPPORTED_EXTENSIONS`) et son **affichage standalone était cassé** : le `<img>`
généré par `api_file_view()` pointait vers `/api/file/{vault}/raw`, un endpoint qui
renvoie du **JSON** (`FileRawResponse`) et non des octets d'image.
## Ce qui a été livré
### A. Arborescence & indexation
- **`backend/media_types.py`** (nouveau) : source unique des extensions
`IMAGE_EXTENSIONS`, `AUDIO_EXTENSIONS`, `VIDEO_EXTENSIONS` (+ helpers
`is_image`/`is_audio`/`is_video`/`is_media`/`media_mime_type`). Socle réutilisé
par #109. `attachment_indexer.py` et `api_file_view()` ne dupliquent plus la
liste.
- Les extensions image sont intégrées à `SUPPORTED_EXTENSIONS`
(`indexer.py`) **avec une branche binaire** : `_scan_vault` et
`_index_single_file_sync` indexent **nom / taille / mtime** et ne lisent
**jamais** les octets (`content: ""`, `content_preview: ""`). Le TF-IDF reste
donc propre et aucune `UnicodeDecodeError` ne pollue les logs.
- Le reindex **watchdog** suit automatiquement (même filtre d'extensions).
- Le filtre `ext:png` / `ext:jpg` de la recherche avancée est opérationnel dès
lors que les images entrent dans l'index.
- `/api/dashboard` expose `image_count` (par vault) et `total_images` (global),
séparés du `file_count` général.
### B. Affichage standalone (correctif)
- `api_file_view()` génère désormais
`src="/api/image/{vault}?path=…"` (chemin URL-encodé) au lieu de `/raw`.
- `viewer.js` utilise le même endpoint (plus de bouton « Plein écran » cassé).
- **Sécurité SVG** : `/api/image` (et le repli de `/api/media/.../thumb`) ajoute
`Content-Security-Policy: sandbox` pour les `.svg`, ce qui empêche
l'exécution du JavaScript embarqué quand le fichier est ouvert directement
dans un onglet (XSS same-origin). Dans une balise `<img>`, l'en-tête est sans
effet. Le middleware de sécurité ne remplace plus une politique stricte posée
par une route.
### C. Miniatures
- `GET /api/media/{vault}/thumb?path=…&size=…` : miniature **WebP** générée
avec `pillow>=10.0`, mise en cache sous
`<OBSIGATE_DATA_DIR>/.obsigate-cache/thumbs/{sha1}.webp`. La clé de cache
embarque **mtime + taille**, donc toute édition invalide naturellement la
vignette.
- Génération dans un thread (`run_in_executor`) avec **timeout 2 s** ; repli sur
l'original en cas d'échec. SVG : l'original est servi tel quel (Pillow ne
décode pas le SVG) ; GIF/WebP animés : première frame.
### D. Visionneuse
`renderImageViewer()` (`frontend/js/viewer.js`) remplace l'ancien rendu minimal :
- image centrée `object-fit: contain` ; **zoom molette 0,1×–8×**, **pan au
glisser** (Pointer Events), **double-clic = réinitialisation**, raccourcis
`+` / `-` / `0` ;
- boutons +/−/reset et **badge de zoom** ;
- **navigation ←/→** entre les images du même dossier (via `/api/browse`) et
**pellicule de miniatures** (`/api/media/.../thumb`, `loading="lazy"`) ;
- barre d'outils : « Ouvrir l'original » (nouvel onglet `/api/image`),
téléchargement, **panneau métadonnées** repliable (dimensions via
`naturalWidth/Height`, taille, type MIME, chemin, date), **lightbox** plein
écran (fond `rgba(0,0,0,.9)`, `Échap` pour quitter) ;
- `EXT_ICONS` : extensions image → icône Lucide `image` ;
- compatible Split View (#75) : rendu dans `getContentArea()` du panneau actif.
### E. Tests
- `tests/test_image_api.py` : octets + MIME sur `/api/image`, en-tête `sandbox`
des SVG, URL `/api/image` dans le HTML de `api_file_view`, encodage des
chemins accentués, miniatures WebP + repli SVG + refus non-image.
- `tests/test_image_indexing.py` : image présente dans `list_directory` et
`path_index`, indexée avec `content == ""`, pertinence watchdog, compteurs
dashboard, filtre `ext:png`.
- `tests/frontend/image-viewer.test.mjs` : helpers purs (`clampImageZoom`,
`isImagePath`, `buildImageUrl`) + vérifications statiques (zoom/pan/nav,
absence de `/raw` dans la visionneuse, CSS, icônes).
- `tests/e2e/image-viewer.spec.js` : ouverture d'une image depuis
l'arborescence, réponse `/api/image` en `image/png`, zoom molette, navigation
par la pellicule (fixtures `test_vault/sample-image.png` +
`sample-vector.svg`).
## Limitations connues
- **HEIC/HEIF** (iPhone) : non décodables par les navigateurs → hors scope ;
`pillow-heif` envisagé en v2.
- Le SVG passe par l'original (pas de rendu bitmap côté serveur) : les
miniatures de dossiers SVG ne sont pas générées.
+187
View File
@@ -0,0 +1,187 @@
# #109 — Support audio & vidéo (lecteurs HTML5 intégrés)
> **Version livrée :** 2.18.0 · **Statut :** ✅ · **Impact :** 🟡
> **Zone :** backend (`main`, `indexer`, `media_types`, `bookslm`) + frontend
> (`viewer.js`, `utils.js`, `style.css`, `sw.js`).
## Contexte
Le socle média de #108 (`backend/media_types.py`) exposait déjà
`AUDIO_EXTENSIONS` / `VIDEO_EXTENSIONS`, mais ces fichiers n'étaient ni indexés
ni affichables : ils tombaient dans le chemin binaire « Ce fichier est binaire
et ne peut pas être affiché » + bouton download.
## Ce qui a été livré
### A. Backend
- **Indexation** : `AUDIO_EXTENSIONS` et `VIDEO_EXTENSIONS` sont intégrées à
`SUPPORTED_EXTENSIONS` (`indexer.py`). La branche `is_media(ext)` existante
indexe **nom / taille / mtime** sans jamais lire les octets (`content: ""`),
donc le TF-IDF et les logs restent propres. Le watcher et le filtre `ext:`
suivent automatiquement.
- **Streaming** : le helper Range de `pdf/stream` a été extrait en
`_stream_file_with_range(file_path, request, media_type)` (206 +
`Content-Range` + `Accept-Ranges`, `416` sur plage invalide, `FileResponse`
simple sinon, lectures offloadées via `asyncio.to_thread`). `pdf/stream`
l'utilise désormais aussi (comportement inchangé, tests de régression).
- **Nouvel endpoint `GET /api/media/{vault}?path=…`** : sert audio/vidéo avec le
MIME `media_types.media_mime_type` (surcharges `.m4a→audio/mp4`,
`.opus/.oga→audio/ogg`, `.mov→video/quicktime`, `.m4v→video/mp4`).
- **Gardes-fous** : `_resolve_safe_path`, `check_vault_access`, et
`OBSIGATE_MEDIA_MAX_INLINE_MB` (défaut **500 Mo**) — au-delà, l'endpoint
renvoie `413` et la vue fichier bascule sur l'UI de téléchargement.
- **`api_file_view()`** renvoie, avant tout `read_text()`, `is_audio` /
`is_video` / `stream_url` / `media_mime` / `size_bytes`. Au-delà de la limite :
`unsupported: true` + `media_too_large: true`.
### B/C. Frontend — lecteurs
- `viewer.js` dispatche `data.is_audio` → `renderAudioViewer()` et
`data.is_video` → `renderVideoViewer()`.
- **Audio** : `<audio controls preload="metadata">` pleine largeur, artwork
placeholder (icône Lucide `audio-lines`), durée lue via `loadedmetadata`,
toolbar (titre, voûte + taille, badge durée, ouvrir l'original, télécharger).
- **Vidéo** : `<video controls playsinline preload="metadata">` centrée sur une
scène noire letterboxée (`max-height: calc(100vh - 180px)`), même toolbar
avec durée + résolution.
- `renderMediaFallback()` : sur l'événement `error` de l'élément média (codec
hors web-natif : `.mkv`, `.avi`, HEVC…) ou si le fichier est trop volumineux,
remplace le lecteur par l'UI binaire + message
`viewer.media_unsupported` / `viewer.media_too_large` + **Télécharger** /
**Ouvrir dans un nouvel onglet**.
- **Pause** au changement de vue : `_mediaViewerCleanup` met en pause et détache
la source quand `renderFile()` re-rend la zone (changement d'onglet, navigation)
— pas de lecture persistante en v1 (cohérence #75).
- `EXT_ICONS` (`utils.js`) : audio → `audio-lines`, vidéo → `video`.
- i18n FR/EN (`viewer.media_unsupported`, `viewer.media_too_large`).
### D. Recherche & intégrations
- Filtres `ext:mp3`, `ext:mp4`, etc. opérationnels (les médias entrent dans
l'index).
- Récents / dashboards : previews vides (aucun texte extrait) — comportement
naturel de la branche binaire.
- **BooksLM** : `_file_entry()` ignore désormais tout média (`is_media`) — les
octets ne sont jamais envoyés au modèle ; les images restent gérées à part via
`load_vault_image_data_url` (vision).
- **Hors scope v1** (porte notée) : transcription audio via Whisper.
### E. Mobile & PWA
- Le service worker ne met **jamais** en cache le flux média
(`/api/media/{vault}`), tout en conservant le cache des miniatures
(`/api/media/{vault}/thumb`) et la stratégie Network First pour le reste. Les
requêtes `Range` étaient déjà exclues.
### F. Tests
- `tests/test_media_stream.py` : 206 + `Content-Range` + 1024 octets, `416` hors
borne, `200` + `Accept-Ranges` sans Range, suffix range, `413` au-delà de la
limite, `403` path traversal, `403` vault sans accès, `400` non-média, MIME
`.mov`, régression `pdf/stream`.
- `tests/test_media_indexing.py` : `.mp3`/`.mp4`/`.flac` dans l'arborescence et
l'index, `content == ""`, watcher pertinent, filtre `ext:mp3`.
- `tests/frontend/media-viewer.test.mjs` : helpers purs (`formatMediaDuration`,
`buildMediaUrl`) + vérifications statiques (dispatch, `<audio>`/`<video>`,
fallback, CSS, icônes, i18n, service worker, endpoints backend).
- `tests/e2e/media-viewer.spec.js` : lecture `<audio>` et `<video>` via
`/api/media` + réponse `206` sur requête `Range`. Fixtures :
`test_vault/sample-audio.mp3` (sine 1 s) et `test_vault/sample-video.webm`
(VP8 64×64).
## Limitations connues
- Formats hors web-natifs (`.mkv`, `.avi`, HEVC, AC-4) : non lisibles sans
transcodage (ffmpeg hors scope) → repli téléchargement / lecteur de l'OS.
- HLS, sous-titres `<track src=".vtt">` et vignettes vidéo : hors scope v1.
- Gros médias (> 500 Mo par défaut) : pas de lecture intégrée (protège le worker
uvicorn unique) ; ajustable via `OBSIGATE_MEDIA_MAX_INLINE_MB`.
---
## #110 — Lecteur média persistant « Now Playing »
> **Version livrée :** 2.19.0 · **Statut :** ✅ · **Impact :** 🟡
> **Zone :** frontend (`now-playing.js`, `viewer.js`, `ui.js`, `pane-manager.js`,
> `app.js`, `style.css`, `index.html`, locales).
### Principe : un seul média, téléporté
`frontend/js/now-playing.js` est un contrôleur singleton qui possède **l'unique
élément `<audio>`/`<video>`** de l'application. Il est déplacé par `appendChild`
(sans recréation, donc sans couper la lecture) entre :
- la **vue inline** — l'onglet/panneau du média, via la surface
`NowPlaying.attachInline(area, data)` (appelée par `renderAudioViewer` /
`renderVideoViewer` de `viewer.js`) ; et
- le **dock global** — un enfant direct de `<body>` (`#now-playing-host`), monté
hors de `.content-wrapper` pour survivre à `renderFile()`, aux onglets, aux
panneaux et à la reconstruction de la grille split.
`renderFile()` appelle `NowPlaying.handleRender(area, data)` : si la zone qui va
être réécrite contient l'élément média, celui-ci est renvoyé au dock. Les autres
points qui vident le contenu (dashboard `_showDashboard`, `showWelcome`,
`PaneManager._buildGrid` / `_collapseToSingle`) appellent le même hook via le
global `window.NowPlaying`.
### Surfaces et ergonomie
- **Dock audio (desktop)** : pilule flottante verre dépoli centrée en bas
(`.np-dock--audio`) — artwork, titre, voûte, durée, play/pause,
précédent/suivant, barre de progression (seek), volume, **revenir au média**,
agrandir, fermer.
- **Panneau étendu** : carte centrale (bottom-sheet sur mobile) avec artwork,
scrub large, volume, vitesse 0,5–2×, précédent/suivant et actions
ouvrir/télécharger/fermer.
- **Mini-vidéo flottante** (`.np-dock--video`) : déplaçable **librement** depuis
n'importe quel point de la fenêtre (position absolue mémorisée, centre autorisé
— aucune aimantation aux bords) et redimensionnable, géométrie persistée ; sur
mobile elle se fixe au-dessus de la barre d'outils.
- **Mobile** : mini-player au-dessus de la barre 64 px
(`bottom: calc(64px + env(safe-area-inset-bottom))`), `viewport-fit=cover`
ajouté, et `body.np-active` ajoute le décalage du contenu. La barre audio passe
en grille (progression sur sa propre ligne) et masque les actions secondaires
pour éviter tout chevauchement.
### Comportements
- Naviguer (onglet, panneau, dashboard, split) **ne coupe pas** la lecture ; le
dock apparaît.
- **Revenir au média** : `NowPlaying.focus()` rouvre/focalise l'onglet
`vault::path` (`window.getActiveTabManager().open`).
- **Fermer** : `NowPlaying.stop()` met en pause, libère l'élément et masque le
dock.
- **Fermer l'onglet** du média en cours : la lecture continue et un toast
`player.continues` le signale.
- **Media Session** : métadonnées (`MediaMetadata`) + actions
play/pause/stop/seek/nexttrack/previoustrack → écran verrouillé, casque
Bluetooth, touches média, **Windows SMTC** (WebView2).
- **Picture-in-Picture** natif pour la vidéo (`requestPictureInPicture`), bouton
masqué si non supporté.
- **Reprise après rechargement** : état (fichier, position, pause, préférences
volume/vitesse) persisté en `localStorage` (`obsigate-now-playing`,
`obsigate-player-prefs`, `obsigate-player-pos`).
### Fichiers modifiés / ajoutés
- Nouveau : `frontend/js/now-playing.js` (contrôleur, dock, session, PiP,
persistance), `tests/e2e/media-viewer.spec.js` (dock/retour/fermeture/mini-vidéo).
- Modifiés : `viewer.js` (délégation inline + `handleRender`), `ui.js`
(dashboard + toast de fermeture d'onglet), `pane-manager.js` (grille/collapse),
`app.js` (`initNowPlaying`), `style.css` (dock/étendu/vidéo/mobile, et
correction des variables `--surface1`/`--text-dim` non définies),
`index.html` (`viewport-fit=cover`), locales FR/EN (`player.*`).
### Correctifs annexes
- Les variables CSS `--surface1` et `--text-dim`, utilisées mais **jamais
définies** depuis #108/#109, sont remplacées par `--surface` et
`--text-secondary` (+ `--text-dim` dans toute la feuille).
### Hors scope (porte notée)
- Fenêtre vidéo détachée **native** Tauri (`WebviewWindowBuilder` +
`always_on_top`) : non implémentée, à faire dans une itération dédiée (Rust,
capabilities, route `/player`).
+69
View File
@@ -0,0 +1,69 @@
# #86 — Optimisation globale des performances (phase 3)
> **Statut :** ✅ livré | **Zone :** backend (`indexer.py`, `mutations.py`)
> **Prérequis déjà livrés :** BUG-033 (recherche via inverted index), BUG-040 (PDF lazy),
> BUG-025 (caps regex).
## Contexte
La roadmap #86 demandait : recherche simple + tool IA via l'inverted index, indexation
incrémentale + scan différentiel au démarrage, extraction PDF/excalidraw différée et caps
CPU sur les opérations regex. Trois de ces cinq points étaient déjà couverts par des
correctifs antérieurs ; cette livraison ferme les deux points restants.
## Ce qui était déjà livré (rappel)
| Point #86 | Livré par | État |
|---|---|---|
| Recherche simple + `search_fulltext` via inverted index | BUG-033 | `search()` récupère ses candidats via l'inverted index (intersection + expansion de préfixes), repli scan pendant la construction |
| Extraction PDF différée | BUG-040 | `_scan_vault` ne lit que les métadonnées ; `enrich_pdf_texts()` extrait après index |
| Caps CPU regex | BUG-025 | `validate_regex` (longueur ≤ 500, rejet quantificateurs imbriqués), contenu tronqué à 200 kio, matchs plafonnés à 1 000 |
## Livré ici
### 1. Scan différentiel au démarrage (`backend/indexer.py`)
- `_scan_vault(..., previous_files)` : quand un snapshot `{relative_path: file_info}` est
fourni, toute entrée dont `size` **et** `modified` sont inchangés est réutilisée sans
lecture disque ni re-parse (copie du dict, tags recomptés, wikilinks ré-enregistrés
depuis le contenu caché pour reconstruire l'index de backlinks).
- `build_index()` capture le snapshot précédent avant le `clear()` et le transmet à
chaque scan de vault (via `functools.partial` pour l'executor) ; `reload_single_vault()`
fait de même pour son vault. Seuls le `os.walk` + `stat` (bon marché) tournent à
chaque passe ; le retour inclut `reused` (hits différentiels, loggé par vault).
- Premier démarrage (aucun snapshot) : comportement identique à avant.
### 2. Extraction excalidraw différée (`backend/indexer.py`)
- Le scan ne lit plus les `.excalidraw` / `.excalidraw.md` : titre dérivé du nom de
fichier, `content` vide, flag `excalidraw_text_pending` (plus de décompression
lz-string pendant le scan).
- `enrich_pdf_texts()` traite désormais les deux flags (`pdf` + `excalidraw`) via
`_read_excalidraw_indexable_text()` exécuté dans l'executor, avec notification du hook
d'index incrémental comme pour les PDF. Nom conservé pour compatibilité (tests,
`main.py`, `reload_index`, `reload_single_vault` inchangés côté appel).
- Le chemin incrémental fichier-à-fichier (`_index_single_file_sync`, watcher) reste
immédiat : un seul fichier ne justifie pas le différé.
### 3. Garde-fou taille sur `replace_in_files` (`backend/services/mutations.py`)
- Nouvelle constante `MAX_REPLACE_FILE_BYTES` (5 Mio) : tout fichier dépassant le plafond
est sauté (warning loggé) au lieu d'être lu intégralement puis balayé par le pattern
utilisateur. Complète les caps BUG-025 (qui couvrent la recherche, pas le remplacement
qui opère sur le contenu disque complet par nature).
## Tests
`tests/test_perf_phase3.py` (9 tests) : différé excalidraw au scan (`.excalidraw` +
`.excalidraw.md`), remplissage par l'enrichissement, `reused == 0` au premier scan,
réutilisation à l'identique, re-parse du fichier modifié, ajout/suppression, skip
`replace` sur fichier surdimensionné + cas passant nominal.
## Limites connues
- Pas de persistance disque de l'index : le différentiel joue sur les rebuilds dans le
même processus (`reload_index`, `reload_single_vault`), pas entre deux redémarrages
(persistance = #85, phase 2).
- La comparaison `size + mtime` ne détecte pas une modification qui conserverait taille
et mtime à la milliseconde près (cas pathologique, le watcher temps réel couvre les
modifications en cours d'exécution).
+172
View File
@@ -0,0 +1,172 @@
# #114 — Configuration — refonte mobile-responsive de la section Settings
> **Statut :** 🟢 · **Impact :** 🟡 · **Zone :** frontend (mobile, ≤ 768 px)
> **Fichiers :** `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`,
> `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`,
> `tests/e2e/config-mobile.spec.js`.
## Contexte
La page **Configurations** (`#config-modal`) était utilisable en mobile seulement
partiellement (correctifs BUG-071) : le sommaire s'ouvrait en bloc haut, la modale
n'était pas plein écran, les cibles tactiles étaient sous 44 px, le clavier virtuel
iOS zoomait les champs, la rangée « Sauvegarder » disparaissait au scroll et plusieurs
dettes HTML/i18n étaient restées en place.
## Ce qui a été livré
### A. Sommaire en drawer coulissant
- `#config-nav` devient un **panneau coulissant gauche** (`position: fixed`,
`width: min(320px, 88vw)`, `z-index: 40`) sous un fond assombri
(`#config-modal.config-toc-open::before`, `z-index: 35`).
- Ouverture/fermeture pilotée par la classe **`.config-toc-open`** sur `#config-modal`
(JS `_setConfigNav`) + un `display` inline conservé pour le contrat de reset.
- Bouton **`#config-toc-close`** (classe `.help-toc-close`, `aria-label`
`config.toc_close`) dans l'en-tête du drawer ; visible uniquement dans le drawer
de la config (masqué sur desktop où la nav est toujours visible).
- **Backdrop** : un tap hors du drawer ferme d'abord le drawer, pas la modale
(`e.target === modal` → `config-toc-open` présent → `_setConfigNav(false)`).
- **Échap** : ferme le drawer d'abord, puis la modale.
- Fermeture de la modale (`closeConfigModal`) nettoie toujours la classe
`config-toc-open` et le `display` inline (aucun fond ne subsiste).
- Animation `config-toc-slide-in` (translateX) à l'ouverture.
### B. Modale plein écran
- `#config-modal` : `padding: 0`, `.editor-container` en `100vw × 100dvh`
(`100dvh` = hauteur du viewport dynamique, tient compte de la barre du navigateur
mobile), `border-radius: 0`, `border: none`.
- Le contenu (`#config-scroll`) garde son scroll propre ; le sous-bloc
`.config-content` reçoit un `padding-bottom: 80 px` pour ne jamais passer sous la
rangée sticky.
### C. Cibles tactiles ≥ 44 px & anti-zoom iOS
- **Champs** : `.config-input`, `.config-select`, `.help-nav-search`,
`.profile-field .config-input/.config-select` → `min-height: 44px` +
`font-size: 16px` (**anti-zoom iOS** : un `font-size < 16px` déclenche le zoom
automatique au focus). La règle est **scoppée `#config-modal`** pour ne pas écraser
`.mfa-code-input` (qui a sa propre typographie).
- **Boutons** : `.config-btn-save`, `.config-btn-secondary`, `.config-btn-primary`,
`.config-btn-danger`, `.config-btn-add`, `.config-btn-sm`, `.mfa-link-btn`,
`.theme-action-btn`, `.profile-avatar-actions .config-btn-secondary`,
`.editor-btn` (fermer), `#config-hamburger`, `#config-toc-close`,
`.help-search-clear` → `min-height/min-width: 44px`.
- **Liens du sommaire** : `.help-nav-link` → `min-height: 44px` (ligne tactile
confortable).
- `.help-hamburger` passe de 36 px à **44 px** en mobile (règle partagée avec le
modal d'aide).
### D. Rangée « Sauvegarder » sticky
- `.config-actions-row` (dans `#cfg-backend-settings`) → `position: sticky;
bottom: 0`, empilée verticalement (`flex-direction: column`), boutons pleine
largeur 44 px, fond opaque + bordure, `padding-bottom` avec
`env(safe-area-inset-bottom)` (barre home iOS).
- La rangée reste visible pendant le scroll de la section backend ; le
`padding-bottom: 80px` de `.config-content` garantit qu'elle ne masque jamais les
derniers contrôles.
### E. Formulaires 1 colonne & grilles
- `.config-row` → 1 colonne (`grid-template-columns: 1fr`), `.config-input--num`
pleine largeur, `text-align: left`.
- `.ai-default-grid` / `.ai-provider-fields` → 1 colonne.
- Add-rows (`.config-add-row`, `.config-add-pattern`) → wrap + largeurs inline
(`180/140/100px`) neutralisées (`width: auto !important`).
- Items webhook/token/share → wrap ; URLs/méta sur leur propre ligne
(`overflow-wrap: anywhere`) ; boutons de suppression 44 px.
- `.hidden-files-add-row` → wrap, input pleine largeur.
- `.config-diag-row` → wrap.
- `.profile-avatar-row` → wrap ; `.profile-form` → `max-width: 100%`.
- `.webauthn-key-item` → wrap ; `.webauthn-key-label` → pleine largeur.
- `.theme-grid` reste en `auto-fill minmax(160px, 1fr)` (déjà responsive).
### F. MFA & sécurité
- `.mfa-recovery-list` → **1 colonne** en mobile (2 colonnes illisibles à 360 px).
- `.mfa-verify-section`, `.mfa-recovery-actions`, `.mfa-disable-actions` → wrap ;
champs/boutons enfants en pleine largeur.
- `.mfa-code-input` → `width: 100%`, `max-width: 320px`, `letter-spacing: 6px`
(au lieu de 12 px qui débordait), `min-height: 52px`.
- `.mfa-secret-code` → `word-break: break-all` (secret TOTP long).
- `.mfa-code-input-group` → wrap.
- Règle morte **`.mfa-recovery-input`** supprimée (auth.js utilise
`mfa-code-input recovery-input`).
### G. Dettes HTML corrigées
- `.config-actions-row` (Sauvegarder / Réindexer / Réinitialiser) déplacée
**dans** `#cfg-backend-settings` (elle était hors de toute section → le sticky
n'avait pas de conteneur de scroll fiable) ; `</section>` orphelin supprimé.
- Id dupliqué **`cfg-partages-publics`** retiré du `<h2>` (l'id reste sur la
`<section>`, cf. #113).
- Conteneur mort **`#plugins-settings-container`** supprimé (le rendu réel est
`#cfg-plugins` via `plugins.js`).
- Sections plugins/about ré-indentées.
- **`#mt-explorer`** : libellé brut `settings.search` remplacé par
`<span data-i18n="settings.explorer">` (i18n correct, FR « Explorateur » / EN
« Files »).
- Doublons CSS **`.config-btn-add`** (3 définitions) réduits à la définition de
référence.
### H. i18n FR/EN
- **Purge des clés mortes** (aucune référence HTML/JS/tests/backend) :
`settings.backend`, `settings.backend_hint`, `settings.restart_badge`,
`settings.save`, `settings.plugins`.
- **Ajouts** : `settings.explorer` (FR « Explorateur » / EN « Files »),
`config.toc_close` (FR « Fermer le sommaire » / EN « Close contents »).
- **Correctif** : `settings.tabs` en FR était le mot anglais « Tabs » →
**« Onglets »** (EN reste « Tabs »).
- Clés vivantes conservées : `settings.reindex`, `settings.no_restart_badge`,
`settings.backend_section`, `settings.security`, `settings.search`,
`settings.tabs`, `settings.search_placeholder`.
## Tests
### Statics — `tests/frontend/config-mobile.test.mjs` (27, au CI)
- BUG-071a–e (hamburger, toggle JS, grilles/wrap, ancres mortes,
`data-i18n-attr` multi-paires) — conservés et adaptés au drawer.
- **#114a** : `#config-toc-close` présent + i18n ; `_setConfigNav` bascule
`.config-toc-open` ; backdrop `::before` (z-index 35) ; `.help-toc-close`
masqué sur desktop / visible dans le drawer ; Échap et backdrop ferment le
drawer d'abord ; `closeConfigModal` nettoie la classe.
- **#114b** : modale `100dvh` + `padding: 0` ; inputs/selects `16px` +
`44px` ; boutons `44px` ; rangée sticky (`position: sticky` +
`flex-direction: column` + safe-area) ; MFA 1 colonne + wrap + code
full-width ; `.config-actions-row` bien dans `#cfg-backend-settings`.
- **#114i18n** : clés mortes purgées ; `settings.explorer` FR/EN ;
`settings.tabs` FR = « Onglets » ; `#mt-explorer` porte
`data-i18n="settings.explorer"` ; `#plugins-settings-container` absent.
### E2E — `tests/e2e/config-mobile.spec.js` (5, projet `chromium-mobile`)
1. Hamburger → drawer `position: fixed` + classe `config-toc-open` sur la modale.
2. Bouton `#config-toc-close` et tap backdrop ferment le drawer **sans** fermer la
modale.
3. Sélection d'une section → scroll doux + lien actif + repli du drawer.
4. Modale plein écran (largeur/hauteur ≈ viewport) + `#config-hamburger`,
`#config-close` et `#cfg-save-backend` ≥ 44 px.
5. Aucun débordement horizontal à 393 px (sections IA / tokens / webhooks /
partages).
## Détails d'implémentation notables
- **Spécificité CSS** : les règles `#config-modal #config-nav` (2 ids) priment sur
les règles génériques `.help-nav` / `body .help-nav` qui masquent la nav en
mobile — pas besoin de `!important`.
- **Backdrop = pseudo-élément** : les clics sur `::before` sont attribués à
l'élément or (`#config-modal`), donc le handler `e.target === modal` existant
fonctionne sans node supplémentaire.
- **Contrat de reset conservé** : `configNavOnOpen.style.display = ''` à
l'ouverture (test statique BUG-071b) — le CSS reprend la main (drawer masqué par
défaut sur mobile).
- **`100dvh` avec repli `100vh`** : les navigateurs sans support `dvh` gardent le
comportement précédent.
- La règle `.help-hamburger { display: inline-flex }` du bloc mobile du modal
d'aide est **partagée** (44 px) ; le drawer de la config double avec
`#config-modal .help-hamburger` pour rester robuste à un réordonnancement des
règles.
@@ -0,0 +1,94 @@
# #113 — Ordre naturel des sections Configurations & avatar utilisateur
> **Version livrée :** 2.22.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`index.html`, `frontend/js/config.js`, `frontend/js/auth.js`,
> `frontend/style.css`, i18n FR/EN) + backend (`backend/auth/router.py`,
> `backend/auth/user_store.py`) + CI (`.gitea/workflows/ci.yml`).
## Contexte
Deux irritants sur la page **Configurations** :
- l'ordre des sections était historique et peu naturel (Recherche en tête, Profil
noyé en position 11, À propos au milieu) ;
- la section **Profil** ne permettait pas de personnaliser l'image affichée dans le
cercle du compte en bas de la sidebar (initiales uniquement).
## Ce qui a été livré
### A. Ordre naturel des sections (TOC = page)
Nouvel ordre, appliqué **à la liste `<ul class="help-nav-list">` (TOC) et aux
`<section>` de la page**, dans le même ordre :
1. **Profil** (`#cfg-profile`) — en premier
2. Sécurité du compte (`#cfg-security`)
3. Thèmes (`#cfg-themes`)
4. Paramètres de recherche (`#cfg-search`)
5. Historique récent (`#cfg-recent`)
6. Filtrage de tags (`#cfg-tags`)
7. Fichiers cachés (`#cfg-hidden-files`)
8. Synchronisation (`#cfg-sync`)
9. Paramètres backend (`#cfg-backend-settings`)
10. Diagnostics (`#cfg-diags`)
11. Clés API IA (`#cfg-ai`)
12. Sources connectées (`#cfg-sources`)
13. Clés API & MCP (`#cfg-tokens`)
14. Notifications push (`#cfg-push`)
15. Webhooks (`#cfg-webhooks`)
16. Partages publics (`#cfg-partages-publics`)
17. Plugins (`#cfg-plugins`)
18. **À propos** (`#cfg-about`) — en dernier
Regroupement retenu : **Compte & apparence → Navigation & contenu → Système →
Intégrations & notifications → À propos**.
Les ancres `cfg-tags` et `cfg-partages-publics` étaient posées sur un `<h2>` à
l'intérieur d'une `<section>` sans id : elles sont désormais portées par la
`<section>` elle-même, pour que le scroll vise le haut de la section (comme toutes
les autres).
### B. Avatar utilisateur ( Profil )
- **Import d'image** : bouton « Choisir une image » + overlay caméra au survol de
l'aperçu circulaire (88 px) → `<input type="file" accept="image/png,image/jpeg,image/webp">`.
- **Traitement client** (`config.js`) : garde type (PNG/JPEG/WEBP) et taille brute
(8 Mo), **recadrage carré central** et redimensionnement à **256 px** via canvas,
export JPEG qualitée 0,85 (fond blanc pour les PNG transparents).
- **Persistance serveur** : `PATCH /api/auth/me` avec `{"avatar": "<data-url>"}` ;
`""` supprime. Validation stricte dans `backend/auth/router.py`
(`_validate_avatar`) : data-URL PNG/JPEG/WebP uniquement (**SVG refusé** — surface
XSS), plafond 400 000 caractères, base64 valide et **octets magiques** contrôlés.
Champ `avatar` ajouté à `data/users.json` (`create_user`), renvoyé par
`GET/PATCH /api/auth/me` et par le payload `user` de login.
- **Affichage sidebar** (`auth.js`) : `renderUserSection()` insère un
`<img class="sidebar-user-avatar-img">` dans `#sidebar-user-avatar` quand
`user.avatar` est défini, sinon les initiales (repli inchangé). Helpers
`AuthManager.updateCachedUser()` et `AuthManager.isAuthEnabled()`.
- **Suppression** : bouton « Supprimer la photo » (stylisté danger), revenu aux
initiales.
- **Auth désactivée** : le bloc avatar est masqué (pas de compte, sidebar masquée).
- Feedback : toasts `config.avatar_updated` / `config.avatar_removed`, erreurs en
ligne (`config.avatar_invalid_type`, `config.avatar_too_large`,
`config.avatar_upload_failed`).
## Tests
- `tests/test_auth_api.py` — classe `TestAvatar` (+8) : GET/PATCH exposent
l'avatar, data-URL PNG acceptée, `""` efface, SVG refusé, payload non-image refusé,
trop grand refusé, base64 invalide refusé, avatar présent dans le payload de login.
- `tests/frontend/settings-order-avatar.test.mjs` (nouveau, ajouté au job `lint`) —
9 tests : ordre TOC (Profil 1er, À propos dernier), ordre de page strictement
identique à la TOC, aucune ancre morte / section orpheline, présence de l'UI avatar
dans `#cfg-profile`, flux `config.js` (types, taille, resize, PATCH, rafraîchissement
sidebar), rendu `auth.js`, règles CSS, clés i18n FR/EN, validation backend.
- Vérifications locales : pytest 1302 passed, ruff/mypy 0 erreur, tests frontend
statiques + JSDOM verts.
## Limitations connues
- L'avatar est stocké en data-URL dans `data/users.json` (adéquat pour un usage
personnel ; un stockage fichier dédié restera possible si les comptes se multiplient).
- La conversion GIF/animé n'est pas prise en charge (types PNG/JPEG/WEBP uniquement).
- Le nom d'affichage du profil reste local (`localStorage`) et n'est pas poussé au
serveur (comportement antérieur conservé).
@@ -0,0 +1,122 @@
# #115 / BUG-078 / #117 — Barre d'outils épinglée, coloration syntaxique des fichiers de code & avatars prédéfinis
> **Statut :** 🟢 livré (en attente vérification utilisateur)
> **Impact :** 🟡 (#115, BUG-078) · 🟢 (#117)
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/js/themes.js`,
> `frontend/js/ui.js`, `frontend/js/config.js`, `frontend/index.html`,
> `frontend/style.css`, `frontend/popout.html`, `frontend/locales/fr.json`,
> `frontend/locales/en.json`, `frontend/icons/avatar/*`)
Trois demandes traitées dans la même livraison, toutes côté frontend.
## #115 — Barre d'outils de lecture toujours visible
### Problème
La barre d'actions d'un document (pop-out, bookmark, Editer, Source, Copier, PDF, Export,
Partager…) était rendue **dans** `.file-header`, un conteneur court placé en haut de la
zone de lecture. Or un élément `position: sticky` reste borné par son parent : dès que
`.file-header` sortait de l'écran au défilement d'un document long, la barre disparaissait.
### Correctif
- `frontend/js/viewer.js` et `frontend/popout.html` sortent la barre d'actions de
`.file-header` et la placent dans un nouveau conteneur `.file-toolbar`, **enfant direct
de `.content-area`** (le conteneur de défilement). La barre peut donc se coller au haut
de la zone de lecture pour toute la hauteur du document.
- `frontend/style.css` :
```css
.file-toolbar {
position: sticky;
top: 0;
z-index: 30;
margin: 0 0 16px;
padding: 8px 0;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
```
Le fond est opaque pour qu'aucun texte ne transparaisse dessous.
- `body.reading-mode .file-toolbar { display: none }` : la barre reste masquée en mode
lecture, comme les autres actions.
## BUG-078 — Coloration syntaxique des fichiers de code
### Problème
Les fichiers `.py`, `.sh`, `.ps1`, `.yml`, `.json`… (et les blocs de code Markdown)
s'affichaient en **texte brut**, sans couleurs. Le code était pourtant correctement
généré côté backend (`<pre><code class="language-python">…`) et `safeHighlight()` appelait
bien `hljs.highlightElement()`.
La cause était ailleurs : les deux feuilles de style de highlight.js (`#hljs-theme-dark`
et `#hljs-theme-light`, chargées depuis le CDN dans `index.html`) étaient basculées à
partir de la **clé de thème** persistée (`obsigate-theme` = `defaut-obsigate`, …) :
```js
darkSheet.disabled = theme !== "dark"; // "defaut-obsigate" !== "dark" → true
lightSheet.disabled = theme !== "light"; // "defaut-obsigate" !== "light" → true
```
Les deux feuilles finissaient donc désactivées, privant tous les tokens de leurs couleurs.
Le résultat dépendait de l'ordre de deux initialisations concurrentes — `UI.initTheme()`
(clé de thème) au démarrage et `themes.initThemes()` (mode) via `Sync.init()` — d'où un
comportement **non déterministe** (parfois coloré, le plus souvent non).
### Correctif
- `frontend/js/themes.js` — `applyTheme(themeKey, mode)` bascule désormais les feuilles
highlight.js selon le **mode** :
```js
var isDark = mode === 'dark';
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
```
(`sepia` et `high-contrast` réutilisent la palette claire.)
- `frontend/js/ui.js` — `initTheme()` lit le **mode** persisté (`obsigate-theme-mode`) et
`applyTheme()` résout un mode avant de fixer `data-theme` et de basculer les feuilles,
au lieu de comparer la clé de thème à `"dark"`/`"light"`.
Le basculement est ainsi **déterministe** dès le premier rendu et à chaque changement de
mode.
## #117 — Avatars prédéfinis dans le profil
### Ce qui a été livré
- **Galerie de 12 avatars** dans la section `Profil` (`#cfg-profile`), servis depuis
`frontend/icons/avatar/` (`/static/icons/avatar/<fichier>.jpg`) : Chat, chien, elephan,
hibou, koala, lapin, lion, ours, penda, pingouin, raton, tigre.
- Un clic charge l'image, la fait passer par le **même pipeline que l'import** (recadrage
carré central, redimensionnement 256 px, export JPEG 0,85) et l'enregistre via
`PATCH /api/auth/me` — aucune modification backend n'a été nécessaire, la validation
data-URL PNG/JPEG/WebP existante (BUG-113) s'applique telle quelle.
- L'avatar actif est **surligné** ; le choix est mémorisé dans `localStorage`
(`obsigate-avatar-preset`) et purgé dès qu'on importe une photo personnalisée ou qu'on
supprime l'avatar.
- L'import d'une photo et la suppression restent disponibles.
- i18n : nouvelle clé `config.avatar_presets_label` (FR/EN).
## Tests
- `tests/frontend/unit.test.mjs` — `syntax highlight theme` (BUG-078) : `themes.applyTheme`
bascule les feuilles selon le mode et `ui.js` ne compare plus la clé au mode.
- `tests/frontend/toolbar-order.test.mjs` — #115 : `.file-toolbar` dans `viewer.js` et
`popout.html`, règle CSS `position: sticky; top: 0`, masquage en mode lecture.
- `tests/frontend/settings-order-avatar.test.mjs` — #117 : 12 avatars présents dans
`#cfg-profile`, fichiers d'images existants, pipeline `config.js`, règles CSS, clé i18n
FR/EN.
- Vérification Playwright (instance locale, auth désactivée) : coloration déterministe sur
5 chargements successifs d'un `.py` ; `.file-toolbar` dont le `top` ne bouge plus après
défilement (épinglage effectif).
## Limitations
- L'avatar reste stocké en data-URL 256 px dans `data/users.json` (comportement #113
conservé).
- Le mode `high-contrast`/`sepia` utilise le thème highlight.js **clair** (pas de palette
dédiée).
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+112 -18
View File
@@ -7,34 +7,48 @@
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excalidraw Editor</title>
<!-- Excalidraw's stylesheet MUST be loaded: without it the editor is
unstyled AND `.excalidraw` has no fixed height, so Excalidraw's
ResizeObserver feedback loop grows the canvas to the 2^25 hard cap and
the scene renders blank. Loaded from esm.sh (same origin as the JS
modules, already allowed by `font-src` for the relative font URLs). -->
<link rel="stylesheet" href="https://esm.sh/@excalidraw/[email protected]/dist/prod/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
body { background: #ffffff; }
/* Toolbar overlay in top-right corner */
/* Icon-only toolbar, vertical, flush against the right edge:
right edge at 100% of the viewport width, group starts at 45% of the
viewport height from the top. */
#excalidraw-toolbar {
position: fixed;
top: 8px;
right: 12px;
right: 0;
top: 45%;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
}
#excalidraw-toolbar button {
padding: 5px 10px;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid #d0d0d0;
border-radius: 6px;
background: #ffffff;
cursor: pointer;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
align-items: center;
gap: 4px;
justify-content: center;
transition: background 0.15s;
}
#excalidraw-toolbar button svg {
width: 18px;
height: 18px;
display: block;
}
#excalidraw-toolbar button:hover { background: #f0f0f0; }
#excalidraw-toolbar button.primary {
background: #6965db;
@@ -43,11 +57,12 @@
}
#excalidraw-toolbar button.primary:hover { background: #5b57c4; }
/* Dirty indicator */
/* Dirty indicator (small dot above the buttons) */
#dirty-badge {
font-size: 11px;
font-size: 12px;
line-height: 1;
color: #e07b39;
font-weight: 500;
font-weight: 700;
display: none;
}
#dirty-badge.visible { display: inline; }
@@ -86,10 +101,11 @@
<div id="loading">Loading Excalidraw…</div>
<div id="root"></div>
<div id="excalidraw-toolbar">
<span id="dirty-badge">● Modified</span>
<button id="btn-save" class="primary" title="Save (Ctrl+S)">💾 Save</button>
<button id="btn-export-png" title="Export PNG">🖼 PNG</button>
<button id="btn-export-svg" title="Export SVG">📐 SVG</button>
<span id="dirty-badge" title="Modified">●</span>
<button id="btn-save" class="primary" title="Save (Ctrl+S)" aria-label="Save"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>
<button id="btn-export-png" title="Export PNG" aria-label="Export PNG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg></button>
<button id="btn-export-svg" title="Export SVG" aria-label="Export SVG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 7a24 24 0 0 1 0 10M4.5 7a24 24 0 0 0 0 10M7 19.5a24 24 0 0 1 10 0M7 4.5a24 24 0 0 0 10 0"/><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="17" y="2" width="5" height="5" rx="1"/><rect x="17" y="17" width="5" height="5" rx="1"/><rect x="2" y="17" width="5" height="5" rx="1"/></svg></button>
<button id="btn-fullscreen" title="Fullscreen" aria-label="Fullscreen"></button>
</div>
<!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query.
@@ -129,6 +145,11 @@
const btnSave = document.getElementById("btn-save");
const btnExportPng = document.getElementById("btn-export-png");
const btnExportSvg = document.getElementById("btn-export-svg");
const btnFullscreen = document.getElementById("btn-fullscreen");
// Icon swapped in on a successful save (floppy → checkmark → floppy).
const SAVE_ICON = btnSave.innerHTML;
const CHECK_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
// --- postMessage helpers ---
function sendToParent(msg) {
@@ -177,8 +198,8 @@
}
sendToParent({ type: "save", data });
markClean();
btnSave.textContent = "💾 Saved!";
setTimeout(() => { btnSave.textContent = "💾 Save"; }, 1200);
btnSave.innerHTML = CHECK_ICON;
setTimeout(() => { btnSave.innerHTML = SAVE_ICON; }, 1200);
}
function setTheme(theme) {
@@ -189,9 +210,45 @@
}
}
// Excalidraw serializes its Map-typed appState fields (notably
// `collaborators`) to plain JSON objects on save — that is what files
// exported by the Excalidraw app / Obsidian plugin contain. Feeding such a
// plain object back through `initialData.appState` makes Excalidraw 0.18
// call `.forEach()` on it and crash with
// "e.appState.collaborators.forEach is not a function", leaving the canvas
// blank. Restore the expected Map shape (and drop anything unusable).
function sanitizeAppState(appState) {
if (!appState || typeof appState !== "object") return {};
// Viewport geometry is computed by Excalidraw from the container size.
// Files exported by the Excalidraw app carry whatever the *source* window
// measured (Obsidian pane, browser tab, …) and can contain absurd values
// (e.g. `height: 22369622`). Importing them makes Excalidraw size its
// canvas beyond the browser limit, so the scene renders off-screen /
// blank. Drop them and let Excalidraw recompute.
for (const key of ["width", "height", "offsetLeft", "offsetTop"]) {
delete appState[key];
}
if (appState.collaborators && !(appState.collaborators instanceof Map)) {
try {
appState.collaborators = new Map(Object.entries(appState.collaborators));
} catch (e) {
appState.collaborators = new Map();
}
}
return appState;
}
// Signature of the drawn content only. Excalidraw's onChange also fires for
// appState-only changes (resize, fullscreen, zoom, scroll); those must not
// flag the diagram as modified.
function sceneSignature(elements) {
return (elements || []).map((el) => `${el.id}:${el.versionNonce}`).join("|");
}
// --- Excalidraw component ---
function App({ initialData, theme }) {
const [appState, setAppState] = React.useState(null);
const lastSigRef = React.useRef(null);
// Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI`
// prop, called with the API object once mounted (NOT the legacy
@@ -204,13 +261,20 @@
if (theme === "dark") {
api.updateScene({ appState: { theme: "dark" } });
}
// Stop ignoring changes once the initial mount settles.
setTimeout(() => { ignoreChanges = false; }, 800);
// Stop ignoring changes once the initial mount settles, and snapshot
// the loaded scene so a later appState-only change is not "dirty".
setTimeout(() => {
ignoreChanges = false;
lastSigRef.current = sceneSignature(api.getSceneElements());
}, 800);
}
}, [theme]);
const onChange = React.useCallback((elements, state, files) => {
if (ignoreChanges) return;
const sig = sceneSignature(elements);
if (sig === lastSigRef.current) return;
lastSigRef.current = sig;
markDirty();
}, []);
@@ -257,6 +321,7 @@
appState = msg.data.appState || {};
files = msg.data.files || {};
}
appState = sanitizeAppState(appState);
const initialData = { elements, appState, files };
currentTheme = msg.theme || "light";
setTheme(currentTheme);
@@ -340,6 +405,35 @@
console.error("SVG export failed:", err);
}
});
// --- Fullscreen ---
// The parent iframe is created with `allow="fullscreen" allowfullscreen`, so
// requesting fullscreen on this document makes the whole editor fill the
// screen. Excalidraw's ResizeObserver then grows the canvas to match.
const FS_ENTER = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
const FS_EXIT = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>';
function toggleFullscreen() {
if (!document.fullscreenElement) {
const req = document.documentElement.requestFullscreen
&& document.documentElement.requestFullscreen();
if (req && typeof req.catch === "function") {
req.catch((err) => console.warn("Fullscreen request failed:", err));
}
} else if (document.exitFullscreen) {
document.exitFullscreen();
}
}
document.addEventListener("fullscreenchange", () => {
const on = !!document.fullscreenElement;
btnFullscreen.innerHTML = on ? FS_EXIT : FS_ENTER;
btnFullscreen.title = on ? "Exit fullscreen" : "Fullscreen";
btnFullscreen.classList.toggle("active", on);
});
btnFullscreen.innerHTML = FS_ENTER;
btnFullscreen.addEventListener("click", toggleFullscreen);
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+784 -520
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
// ObsiGate — #106 : Actions instantanées de l'assistant IA.
//
// Two responsibilities, deliberately framework-free and DOM-free so the
// context rules stay unit-testable (tests/frontend/ai-quick-actions.test.mjs):
//
// 1. ACTION_CATALOG — every prompt action grouped by category. Each action
// carries an id, a lucide icon name, an i18n label key and an i18n
// prompt key; both strings are resolved through t() at render time so
// the FR/EN switch is live.
// 2. detectContext / suggestionsFor — a pure function of the assistant's
// live state (mode, open documents, current file, editor selection)
// returning the context key, then the 3 top action ids for that context
// (contextual triage table, see docs/features/ai-quick-actions.md).
//
// Context precedence (first match wins):
// selection — the user has a live text selection in the open editor
// code — the focused document is a source file (.py, .js, .sh…)
// multi_doc — two or more documents are open in tabs/panes
// single_doc — exactly one text document (.md, .txt, …) is open
// directory — a vault folder was opened from the tree context menu
// general — nothing open: app-help assistant
import { t } from './i18n.js';
/** Categories of the action catalogue (order = drawer display order). */
export const CATEGORIES = Object.freeze([
{ id: 'synthesis', icon: 'sparkles', labelKey: 'qa.cat_synthesis' },
{ id: 'structure', icon: 'list-checks', labelKey: 'qa.cat_structure' },
{ id: 'code', icon: 'code', labelKey: 'qa.cat_code' },
{ id: 'cross', icon: 'git-compare', labelKey: 'qa.cat_cross' },
{ id: 'edition', icon: 'pen-tool', labelKey: 'qa.cat_edition' },
{ id: 'general', icon: 'help-circle', labelKey: 'qa.cat_general' },
]);
/**
* The full catalogue. `labelKey` is the button text, `promptKey` the message
* actually sent to the assistant (kept richer than the label on purpose:
* labels stay scannable, prompts stay precise).
*/
export const ACTION_CATALOG = Object.freeze([
// ── Synthèse & Analyse ──────────────────────────────────────────────
{ id: 'summarize_3', cat: 'synthesis', icon: 'align-left', labelKey: 'qa.summarize_3', promptKey: 'qa.summarize_3.prompt' },
{ id: 'frictions', cat: 'synthesis', icon: 'alert-triangle', labelKey: 'qa.frictions', promptKey: 'qa.frictions.prompt' },
{ id: 'vulgarize', cat: 'synthesis', icon: 'lightbulb', labelKey: 'qa.vulgarize', promptKey: 'qa.vulgarize.prompt' },
{ id: 'faq', cat: 'synthesis', icon: 'help-circle', labelKey: 'qa.faq', promptKey: 'qa.faq.prompt' },
// ── Productivité & Structuration ────────────────────────────────────
{ id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' },
{ id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' },
{ id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' },
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true },
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true },
{ id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' },
{ id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' },
// ── Code & Scripts ───────────────────────────────────────────────────
{ id: 'explain_code', cat: 'code', icon: 'file-code', labelKey: 'qa.explain_code', promptKey: 'qa.explain_code.prompt' },
{ id: 'audit_code', cat: 'code', icon: 'bug', labelKey: 'qa.audit_code', promptKey: 'qa.audit_code.prompt' },
{ id: 'doc_code', cat: 'code', icon: 'braces', labelKey: 'qa.doc_code', promptKey: 'qa.doc_code.prompt' },
{ id: 'test_code', cat: 'code', icon: 'flask-conical', labelKey: 'qa.test_code', promptKey: 'qa.test_code.prompt' },
// ── Cross-documents ──────────────────────────────────────────────────
{ id: 'compare', cat: 'cross', icon: 'git-compare', labelKey: 'qa.compare', promptKey: 'qa.compare.prompt' },
{ id: 'merge', cat: 'cross', icon: 'layers', labelKey: 'qa.merge', promptKey: 'qa.merge.prompt' },
{ id: 'timeline', cat: 'cross', icon: 'history', labelKey: 'qa.timeline', promptKey: 'qa.timeline.prompt' },
// ── Édition & Reformulation (sélection active) ───────────────────────
{ id: 'concise', cat: 'edition', icon: 'scissors', labelKey: 'qa.concise', promptKey: 'qa.concise.prompt' },
{ id: 'fix_style', cat: 'edition', icon: 'spell-check', labelKey: 'qa.fix_style', promptKey: 'qa.fix_style.prompt' },
{ id: 'rephrase', cat: 'edition', icon: 'pen-tool', labelKey: 'qa.rephrase', promptKey: 'qa.rephrase.prompt' },
{ id: 'translate', cat: 'edition', icon: 'languages', labelKey: 'qa.translate', promptKey: 'qa.translate.prompt' },
// ── Général (aucun document ouvert) — reprises des anciennes suggestions
{ id: 'capabilities', cat: 'general', icon: 'sparkles', labelKey: 'qa.capabilities', promptKey: 'qa.capabilities.prompt' },
{ id: 'search_help', cat: 'general', icon: 'search', labelKey: 'qa.search_help', promptKey: 'qa.search_help.prompt' },
{ id: 'create_note', cat: 'general', icon: 'notebook-pen', labelKey: 'qa.create_note', promptKey: 'qa.create_note.prompt' },
]);
/** id → action record, for O(1) lookup by the preset tables. */
export const ACTIONS_BY_ID = Object.freeze(
ACTION_CATALOG.reduce((acc, a) => { acc[a.id] = a; return acc; }, {}),
);
/** File extensions treated as source code (drive the `code` context). */
export const CODE_EXT_RE = /\.(?:py|js|mjs|cjs|ts|tsx|jsx|vue|svelte|go|rs|java|kt|c|h|cpp|hpp|cc|cs|php|rb|swift|sh|bash|zsh|ps1|bat|sql|lua|pl|r|dart|scala)$/i;
/** Text-ish document extensions (everything else stays "single_doc"). */
export const TEXT_DOC_EXT_RE = /\.(?:md|markdown|mdx|txt|rst|org|adoc)$/i;
/**
* Map a set of live facts to one context key.
*
* @param {object} facts
* @param {string} facts.mode assistant mode ('directory'|'documents'|'general')
* @param {number} facts.docCount number of open documents (tabs/panes)
* @param {string|null} facts.currentPath path of the focused document, if any
* @param {boolean} facts.hasSelection true when the open editor holds a text selection
* @param {number} [facts.fileCount] indexed files of the current directory context
* @returns {'selection'|'code'|'multi_doc'|'single_doc'|'directory'|'general'}
*/
export function detectContext(facts) {
const { mode, docCount = 0, currentPath = null, hasSelection = false } = facts || {};
// A live editor selection is the strongest intent: act on the selection.
if (hasSelection) return 'selection';
if (mode === 'documents' || docCount > 0) {
if (currentPath && CODE_EXT_RE.test(currentPath)) return 'code';
if (docCount >= 2) return 'multi_doc';
return 'single_doc';
}
if (mode === 'directory') return 'directory';
return 'general';
}
/**
* Contextual triage: the 3 top action ids per context (the "boutons 1-3" of
* the design table). Everything not shown stays reachable via the drawer.
*/
export const CONTEXT_PRESETS = Object.freeze({
single_doc: ['summarize_3', 'checklist', 'frontmatter', 'frontmatter_update'],
multi_doc: ['merge', 'compare', 'frictions'],
code: ['explain_code', 'audit_code', 'test_code'],
selection: ['concise', 'fix_style', 'explain_selection'],
directory: ['summarize_dir', 'themes', 'checklist'],
general: ['capabilities', 'search_help', 'create_note'],
});
// Preset ids that are context phrasings rather than catalogue entries
// (their prompt needs the directory/list framing, so they are synthesized).
const EXTRA_ACTIONS = Object.freeze({
explain_selection: { id: 'explain_selection', cat: 'edition', icon: 'lightbulb', labelKey: 'qa.explain_selection', promptKey: 'qa.explain_selection.prompt' },
summarize_dir: { id: 'summarize_dir', cat: 'synthesis', icon: 'align-left', labelKey: 'bookslm.suggestion_summary', promptKey: 'bookslm.suggestion_summary' },
themes: { id: 'themes', cat: 'synthesis', icon: 'library', labelKey: 'bookslm.suggestion_themes', promptKey: 'bookslm.suggestion_themes' },
});
/** Resolve an action id (catalogue or preset extra) to its record. */
export function getAction(id) {
return ACTIONS_BY_ID[id] || EXTRA_ACTIONS[id] || null;
}
/**
* The ordered action records suggested for one context key.
* Unknown keys fall back to the general preset (never throws).
*/
export function suggestionsFor(contextKey) {
const ids = CONTEXT_PRESETS[contextKey] || CONTEXT_PRESETS.general;
return ids.map((id) => getAction(id)).filter(Boolean);
}
/** Translated label + prompt for an action record (empty string when absent). */
export function actionTexts(action) {
if (!action) return { label: '', prompt: '' };
return { label: t(action.labelKey), prompt: t(action.promptKey) };
}
/** Badge text for the contextual header chip. */
export function contextBadgeKey(contextKey, docCount) {
switch (contextKey) {
case 'selection': return 'qa.badge_selection';
case 'code': return 'qa.badge_code';
case 'multi_doc': return 'qa.badge_multi';
case 'single_doc': return 'qa.badge_single';
case 'directory': return 'qa.badge_directory';
default: return docCount > 0 ? 'qa.badge_single' : 'qa.badge_general';
}
}
+19
View File
@@ -82,6 +82,24 @@ function renderCapabilityList(caps) {
return box;
}
/**
* Render only the *enabled* capabilities as colored tags (#104). Used by the
* config panel where a compact read-only summary reads better than checkboxes.
*/
function renderCapabilityBadges(caps) {
const box = document.createElement('div');
box.className = 'ai-caps-badges';
if (!caps) return box;
AI_CAPABILITY_KEYS.forEach((key) => {
if (!caps[key]) return;
const item = document.createElement('span');
item.className = 'ai-cap-badge';
item.textContent = t(`ai.cap_${key}`);
box.appendChild(item);
});
return box;
}
/** Accent-insensitive, case-insensitive normalization for model search. */
function _normalizeText(value) {
return String(value || '')
@@ -1019,6 +1037,7 @@ export {
AI_CAPABILITY_KEYS,
getModelCapabilities,
renderCapabilityList,
renderCapabilityBadges,
_buildPickerUI as buildAIPickerUI,
refreshAIPickers,
PICKER_SLOT_CLASS,
+2
View File
@@ -6,6 +6,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';
import { initNowPlaying } from './now-playing.js';
// Wire up AI toolbar toast (avoids circular import in utils.js)
window._obsigateShowToast = UI.showToast;
@@ -113,6 +114,7 @@ async function init() {
setupFocusMode();
Utils.safeCreateIcons();
initAIFab();
initNowPlaying();
}
document.addEventListener("DOMContentLoaded", async () => {
+192 -24
View File
@@ -1,7 +1,7 @@
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
import { state } from './state.js';
import { safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu } from './ui.js';
import { safeCreateIcons, escapeHtml } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, getLocale, setLocale } from './i18n.js';
import { showWelcome } from './viewer.js';
@@ -13,6 +13,15 @@ window.handleLogout = () => {
AuthManager.logout();
};
/** Two-letter initials for the sidebar account avatar (#112). */
function userInitials(name) {
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
const first = parts[0][0] || "";
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (first + last).toUpperCase() || "?";
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
@@ -116,6 +125,18 @@ const AuthManager = {
return raw ? JSON.parse(raw) : null;
},
/** Merge fields into the cached user object (#113 — profile avatar). */
updateCachedUser(fields) {
const next = { ...(this.getUser() || {}), ...fields };
sessionStorage.setItem(this.USER_KEY, JSON.stringify(next));
return next;
},
/** Whether authentication is enabled on this instance (#113). */
isAuthEnabled() {
return !!this._authEnabled;
},
isTokenExpired() {
const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
if (!expiry) return true;
@@ -266,6 +287,13 @@ const AuthManager = {
});
},
async changePassword(currentPassword, newPassword) {
return await api("/api/auth/change-password", {
method: "POST",
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
},
async logout() {
try {
const token = this.getToken();
@@ -316,22 +344,58 @@ const AuthManager = {
const app = document.getElementById("app");
if (login) login.classList.add("hidden");
if (app) app.classList.remove("hidden");
this.renderUserMenu();
this.renderUserSection();
},
renderUserMenu() {
/**
* Render the account block pinned at the bottom of the sidebar (#112) —
* the user identity/logout no longer live in the header.
*/
renderUserSection() {
const user = this.getUser();
const userMenu = document.getElementById("user-menu");
if (!userMenu) return;
const section = document.getElementById("sidebar-user");
if (!user || !this._authEnabled) {
userMenu.innerHTML = "";
if (section) section.hidden = true;
return;
}
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '" onclick="window.handleLogout()"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
if (!section) return;
const name = user.display_name || user.username || "";
const nameEl = document.getElementById("sidebar-user-name");
if (nameEl) nameEl.textContent = name;
const roleEl = document.getElementById("sidebar-user-role");
if (roleEl) roleEl.textContent = user.role === "admin" ? t("sidebar.user_role_admin") : t("sidebar.user_role_user");
const avatarEl = document.getElementById("sidebar-user-avatar");
if (avatarEl) {
// #113: custom avatar image when set, initials as the fallback.
if (user.avatar) {
avatarEl.textContent = "";
const img = document.createElement("img");
img.src = user.avatar;
img.alt = "";
img.className = "sidebar-user-avatar-img";
avatarEl.appendChild(img);
} else {
avatarEl.textContent = userInitials(name);
}
}
section.hidden = false;
safeCreateIcons();
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout());
const logoutBtn = document.getElementById("sidebar-user-logout");
if (logoutBtn && !logoutBtn._obsigateBound) {
logoutBtn._obsigateBound = true;
logoutBtn.addEventListener("click", () => this.logout());
}
const profileBtn = document.getElementById("sidebar-user-profile");
if (profileBtn && !profileBtn._obsigateBound) {
profileBtn._obsigateBound = true;
profileBtn.addEventListener("click", () => {
closeMobileSidebar();
const trigger = document.getElementById("profile-open-btn");
if (trigger) trigger.click();
});
}
const adminRow = document.getElementById("admin-menu-row");
if (adminRow) {
@@ -348,6 +412,9 @@ const AuthManager = {
}
},
// Backwards-compatible alias (older callers/tests may use this name).
renderUserMenu() { this.renderUserSection(); },
// ── Initialization ──────────────────────────────────────────────
async checkAuthStatus() {
@@ -545,8 +612,22 @@ function _startWebauthnLogin(mfaSection, username, rememberMe) {
function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) {
const loginBox = document.querySelector(".login-box");
if (!loginBox) return;
// BUG-069: the challenge used to mount into `.login-box`, which does not
// exist in index.html (the login markup is `#login-screen > .login-card >
// #login-form`) — querySelector returned null and the function silently
// returned, leaving the user stuck on the login page with no error after
// entering correct credentials. Mount into the real card, and never fail
// silently: surface the problem in the login error box instead.
const loginBox = document.querySelector(".login-card")
|| document.getElementById("login-screen");
if (!loginBox) {
const fallback = loginErrorEl || document.getElementById("login-error");
if (fallback) {
fallback.textContent = t("mfa.challenge_unavailable");
fallback.classList.remove("hidden");
}
return;
}
// Hide the normal login form
const loginForm = document.getElementById("login-form");
@@ -1058,11 +1139,79 @@ async function initMfaSettings() {
});
}
// Password change (BUG-068: the "Sécurité du compte" section had no way to
// change the password although POST /api/auth/change-password exists).
_renderPasswordSection(area);
// WebAuthn security keys section (ROADMAP #64)
_renderWebauthnSection(area);
}
function _renderPasswordSection(container) {
if (!container || document.getElementById("password-settings")) return;
const section = document.createElement("div");
section.id = "password-settings";
section.className = "password-settings";
section.innerHTML = `
<h4 class="webauthn-title">${t("mfa.password_change_title")}</h4>
<p class="mfa-info-text">${t("mfa.password_change_desc")}</p>
<div class="form-group">
<label>${t("mfa.current_password_label")}</label>
<input type="password" id="pwd-current" class="config-input"
placeholder="${t('mfa.current_password_placeholder')}" autocomplete="current-password">
</div>
<div class="form-group">
<label>${t("mfa.new_password_label")}</label>
<input type="password" id="pwd-new" class="config-input"
placeholder="${t('mfa.new_password_placeholder')}" autocomplete="new-password">
</div>
<div class="form-group">
<label>${t("mfa.new_password_confirm_label")}</label>
<input type="password" id="pwd-confirm" class="config-input"
placeholder="${t('mfa.new_password_confirm_placeholder')}" autocomplete="new-password">
</div>
<div class="mfa-recovery-actions">
<button class="config-btn-primary" id="pwd-change-btn">${t("mfa.password_change_btn")}</button>
</div>
<p class="mfa-error hidden" id="pwd-change-error"></p>
`;
container.appendChild(section);
section.querySelector("#pwd-change-btn").addEventListener("click", async () => {
const errEl = section.querySelector("#pwd-change-error");
const current = section.querySelector("#pwd-current").value;
const next = section.querySelector("#pwd-new").value;
const confirm = section.querySelector("#pwd-confirm").value;
const btn = section.querySelector("#pwd-change-btn");
errEl.classList.add("hidden");
if (!current || !next || !confirm) {
errEl.textContent = t("mfa.fill_all_fields");
errEl.classList.remove("hidden");
return;
}
if (next !== confirm) {
errEl.textContent = t("mfa.password_mismatch");
errEl.classList.remove("hidden");
return;
}
btn.disabled = true;
try {
await AuthManager.changePassword(current, next);
showToast(t("mfa.password_changed"), "success");
section.querySelector("#pwd-current").value = "";
section.querySelector("#pwd-new").value = "";
section.querySelector("#pwd-confirm").value = "";
} catch (err) {
errEl.textContent = err.message || String(err);
errEl.classList.remove("hidden");
} finally {
btn.disabled = false;
}
});
}
async function _renderWebauthnSection(container) {
if (!container || !window.PublicKeyCredential) return;
@@ -1085,10 +1234,10 @@ async function _renderWebauthnSection(container) {
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>
<span class="webauthn-key-label">🔑 ${escapeHtml(k.label || "Security key")}</span>
<span class="webauthn-key-meta">${escapeHtml((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>
data-id="${escapeHtml(k.credential_id)}">${t("mfa.webauthn_remove")}</button>
</li>`).join("")}</ul>`
: `<p class="mfa-info-text">${t("mfa.webauthn_none")}</p>`;
@@ -1111,7 +1260,10 @@ async function _renderWebauthnSection(container) {
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);
// BUG-068: first-time WebAuthn enable issues recovery codes. There is
// no #mfa-setup-flow-area in the "already enabled" view, so render
// them into the WebAuthn flow area instead of losing them.
_showRecoveryCodes(result.recovery_codes, "webauthn-flow-area");
} else {
showToast(t("mfa.webauthn_added"), "success");
}
@@ -1145,16 +1297,28 @@ async function _startMfaSetup() {
try {
const data = await AuthManager.mfaSetup();
// BUG-068: the QR code comes from the backend as a local SVG data: URI
// (see POST /api/auth/mfa/totp/setup → qr_data_url). The previous
// third-party QR image was blocked by the CSP
// (img-src 'self' data: blob:) so it never displayed — and it leaked the
// otpauth URI (TOTP secret) to a third party. Fall back to the manual
// secret when the backend has no QR generator available.
const qrImg = data.qr_data_url
? `<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code-img"
src="${data.qr_data_url}"
onerror="this.style.display='none';document.getElementById('mfa-qr-fallback').style.display='block';">`
: "";
const fallbackStyle = data.qr_data_url ? "display:none" : "";
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)}">
${qrImg}
<p class="mfa-info-text" id="mfa-qr-fallback" style="${fallbackStyle}">${t("mfa.qr_unavailable")}</p>
</div>
<details class="mfa-secret-details">
<details class="mfa-secret-details" ${data.qr_data_url ? "" : "open"}>
<summary>${t("mfa.manual_entry")}</summary>
<code class="mfa-secret-code">${data.secret}</code>
<code class="mfa-secret-code">${escapeHtml(data.secret)}</code>
</details>
<div class="mfa-verify-section">
<label>${t("mfa.enter_code")}</label>
@@ -1201,12 +1365,16 @@ async function _startMfaSetup() {
}
function _showRecoveryCodes(codes) {
const flowArea = document.getElementById("mfa-setup-flow-area");
const area = document.getElementById("mfa-setup-area");
function _showRecoveryCodes(codes, targetId) {
// BUG-068: the recovery codes must be visible wherever the enable flow ran.
// The TOTP flow owns #mfa-setup-flow-area, but the WebAuthn first-enable
// path (#webauthn-flow-area) has none — previously those codes were lost.
const flowArea = document.getElementById(targetId || "mfa-setup-flow-area")
|| document.getElementById("webauthn-flow-area")
|| document.getElementById("mfa-setup-area");
if (!flowArea) return;
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${c}</code>`).join("\n");
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${escapeHtml(c)}</code>`).join("\n");
flowArea.innerHTML = `
<div class="mfa-recovery-card">
<h4>🔑 ${t("mfa.recovery_codes_title")}</h4>
+448 -75
View File
@@ -10,6 +10,15 @@ import { safeCreateIcons } from './utils.js';
import { api, AuthManager } from './auth.js';
import { showToast } from './ui.js';
import { state } from './state.js';
// #106 — contextual quick actions (catalogue + triage rules live in ai-quick-actions.js).
import {
detectContext,
suggestionsFor,
actionTexts,
contextBadgeKey,
ACTION_CATALOG,
CATEGORIES,
} from './ai-quick-actions.js';
export const MODE = Object.freeze({
DIRECTORY: 'directory',
@@ -43,6 +52,22 @@ const PANEL_MIN_WIDTH = 320;
const PANEL_MAX_WIDTH = 1000;
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
// BUG-076 — Tools that mutate the vault: their execution must refresh the
// sidebar tree immediately (the watcher SSE is delayed and directory-only
// changes may not produce an index event).
const MUTATING_TOOLS = new Set([
'create_file', 'create_directory', 'edit_file', 'append_to_file',
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
'delete_file', 'delete_directory', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
]);
// Subset carrying a concrete `vault` + `path`: the displayed document is
// reloaded from disk so an open viewer/editor reflects the agent's write.
const FILE_WRITE_TOOLS = new Set([
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
]);
/**
* BUG-059 — Is this pointer press a scrollbar drag (and only that)?
*
@@ -886,15 +911,14 @@ class BooksLM {
}
/**
* #93 — A write tool just modified a vault file. Notify the UI so the
* displayed document is reloaded from disk: otherwise the read view keeps the
* stale content, and an open editor buffer autosaves the old text back over
* the assistant's change (see utils.reloadExternalWrite).
* #93 / BUG-076 — A write tool just modified a vault file. Notify the UI so
* the displayed document is reloaded from disk: otherwise the read view keeps
* the stale content, and an open editor buffer autosaves the old text back
* over the assistant's change (see utils.reloadExternalWrite).
*/
_notifyFileWritten(data) {
if (!data || data.ok === false) return;
const WRITE_TOOLS = ['edit_file', 'append_to_file', 'create_file', 'restore_backup'];
if (WRITE_TOOLS.indexOf(data.name) === -1) return;
if (!FILE_WRITE_TOOLS.has(data.name)) return;
const args = data.arguments || {};
if (!args.vault || !args.path) return;
window.dispatchEvent(
@@ -904,6 +928,24 @@ class BooksLM {
);
}
/**
* BUG-076 — Refresh the sidebar tree right after an agent mutation, without
* waiting for the (debounced) watcher SSE. Debounced so a batch of tool
* events (e.g. a folder plus its files) triggers a single refresh.
*/
_scheduleTreeRefresh() {
if (this._treeRefreshTimer) clearTimeout(this._treeRefreshTimer);
this._treeRefreshTimer = setTimeout(async () => {
this._treeRefreshTimer = null;
try {
const m = await import('./sidebar.js');
if (m && typeof m.refreshSidebarTreePreservingState === 'function') {
await m.refreshSidebarTreePreservingState();
}
} catch { /* tree refresh is best-effort */ }
}, 250);
}
// ── Rendering ───────────────────────────────────────────────────────
_render() {
@@ -920,6 +962,7 @@ class BooksLM {
<span class="bookslm-title"></span>
<span class="bookslm-subtitle"></span>
</div>
<span class="bookslm-qa-badge hidden" data-qa-context=""></span>
<div class="bookslm-header-actions">
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
@@ -1031,7 +1074,11 @@ class BooksLM {
}
});
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => {
// BUG-077: the same button stops the run while a response streams.
if (this._isLoading) this._stopGeneration();
else this._sendMessage();
});
// Resizable panel (drag the left edge).
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
@@ -1071,6 +1118,25 @@ class BooksLM {
});
textarea.addEventListener('paste', (e) => this._onPaste(e));
// #106 — The suggested row flips to "selection" actions while the user
// selects text in the open editor; refresh (throttled) on selectionchange.
let qaSelTimer = null;
const onSelectionChange = () => {
if (qaSelTimer) return;
qaSelTimer = setTimeout(() => {
qaSelTimer = null;
if (!this._panel || this._panel.classList.contains('hidden')) return;
if (this._messages.length) return;
const ctx = this._quickContext();
if (ctx !== this._qaContext) this._showSuggestions();
}, 250);
};
document.addEventListener('selectionchange', onSelectionChange);
// CodeMirror owns its selection (no native selectionchange on drag), so
// the same throttled refresh is bound to pointer/keyboard release too.
document.addEventListener('mouseup', onSelectionChange);
document.addEventListener('keyup', onSelectionChange);
// Menu navigation is handled at the panel level (capture phase) so the
// arrow keys keep working even if focus is not exactly on the textarea
// (e.g. after interacting with the menu). It runs before the textarea
@@ -2015,26 +2081,52 @@ class BooksLM {
}
}
_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'),
];
// ── #106 — Actions instantanées contextuelles ───────────────────────
//
// The welcome zone above the composer shows the 3 top actions for the
// *detected* context (selection > code > multi-doc > single-doc >
// directory > general, rules in ai-quick-actions.js); the full catalogue
// stays reachable through the "Toutes les actions" drawer.
/** True when the open editor (CM6, or the mobile textarea fallback)
* holds a non-empty text selection. Best-effort, never throws. */
_hasEditorSelection() {
try {
const view = state.editorView;
if (view && view.state && view.state.selection) {
const sel = view.state.selection.main;
if (sel && sel.from !== sel.to) return true;
}
const ta = state.fallbackEditorEl;
if (ta && document.body.contains(ta)
&& ta.selectionStart != null && ta.selectionEnd > ta.selectionStart) {
return true;
}
} catch { /* editor not ready */ }
return false;
}
/** The live context key driving the suggested-actions row. */
_quickContext() {
const docs = this._mode === MODE.DOCUMENTS ? this._documents : [];
return detectContext({
mode: this._mode,
docCount: docs.length,
currentPath: docs.length ? (docs[0].path || null) : null,
hasSelection: this._hasEditorSelection(),
});
}
/** Header chip describing the detected context ("1 doc ouvert", …). */
_updateQaBadge() {
if (!this._panel) return;
const badge = this._panel.querySelector('.bookslm-qa-badge');
if (!badge) return;
const ctx = this._qaContext || this._quickContext();
const docCount = this._mode === MODE.DOCUMENTS ? this._documents.length : 0;
badge.textContent = t(contextBadgeKey(ctx, docCount), { count: docCount });
badge.dataset.qaContext = ctx;
badge.classList.remove('hidden');
}
_showSuggestions() {
@@ -2042,21 +2134,185 @@ class BooksLM {
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);
if (this._messages.length) {
sugEl.classList.add('hidden');
return;
}
sugEl.classList.remove('hidden');
const ctx = this._quickContext();
this._qaContext = ctx;
this._updateQaBadge();
// Welcome hint (mirrors the POC copy: actions or free question).
const hint = document.createElement('p');
hint.className = 'bookslm-qa-hint';
hint.textContent = t('qa.empty_hint');
sugEl.appendChild(hint);
// Row head: label + access to the full catalogue.
const head = document.createElement('div');
head.className = 'bookslm-qa-head';
const label = document.createElement('span');
label.className = 'bookslm-qa-label';
label.textContent = t('qa.header_suggested');
head.appendChild(label);
const more = document.createElement('button');
more.type = 'button';
more.className = 'bookslm-qa-more';
more.innerHTML = `<span>${t('qa.all_actions')}</span>`
+ '<i data-lucide="layout-grid" style="width:13px;height:13px"></i>';
more.addEventListener('click', (e) => {
e.stopPropagation();
this._toggleActionDrawer();
});
head.appendChild(more);
sugEl.appendChild(head);
const list = document.createElement('div');
list.className = 'bookslm-qa-list';
for (const action of suggestionsFor(ctx)) {
list.appendChild(this._renderQuickActionBtn(action));
}
sugEl.appendChild(list);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
/** One action button (icon + label + hover arrow), used in both the row
* and the drawer catalogue. */
_renderQuickActionBtn(action, opts = {}) {
const { label, prompt } = actionTexts(action);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bookslm-qa-btn' + (opts.compact ? ' compact' : '');
btn.dataset.qaAction = action.id;
btn.innerHTML = `
<i data-lucide="${action.icon}" class="bookslm-qa-icon" style="width:15px;height:15px"></i>
<span class="bookslm-qa-text">${label}</span>
<i data-lucide="arrow-right" class="bookslm-qa-go" style="width:14px;height:14px"></i>
`;
btn.title = prompt;
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._runQuickAction(action);
});
return btn;
}
/** Immediate-send the action prompt through the composer (same path as a
* typed message: skills, agent mode and images all keep working). Actions
* flagged `agent` (they mutate the document) transparently switch the
* assistant to agent mode first — same pattern as Deep Research. */
_runQuickAction(action) {
if (!this._panel || !action) return;
const { prompt } = actionTexts(action);
if (!prompt) return;
this._closeActionDrawer();
if (action.agent && !this._agentMode) this._toggleAgentMode();
const textarea = this._panel.querySelector('textarea');
if (!textarea) return;
textarea.value = prompt;
this._sendMessage();
}
// ── Drawer "Toutes les actions" (bottom sheet, searchable) ──────────
_toggleActionDrawer() {
const drawer = this._ensureActionDrawer();
if (!drawer) return;
if (drawer.classList.contains('open')) this._closeActionDrawer();
else this._openActionDrawer();
}
_ensureActionDrawer() {
if (!this._panel) return null;
let drawer = this._panel.querySelector('.bookslm-qa-drawer');
if (drawer) return drawer;
drawer = document.createElement('div');
drawer.className = 'bookslm-qa-drawer hidden';
drawer.innerHTML = `
<div class="bookslm-qa-sheet" role="dialog" aria-modal="true" aria-label="${t('qa.drawer_title')}">
<div class="bookslm-qa-sheet-head">
<span class="bookslm-qa-sheet-title">
<i data-lucide="layout-grid" style="width:15px;height:15px"></i>
${t('qa.drawer_title')}
</span>
<button type="button" class="bookslm-qa-sheet-close" aria-label="${t('ai.close')}">
<i data-lucide="x" style="width:15px;height:15px"></i>
</button>
</div>
<div class="bookslm-qa-search">
<i data-lucide="search" style="width:14px;height:14px"></i>
<input type="text" placeholder="${t('qa.search_placeholder')}" aria-label="${t('qa.search_placeholder')}">
</div>
<div class="bookslm-qa-sheet-list"></div>
</div>
`;
this._panel.appendChild(drawer);
drawer.querySelector('.bookslm-qa-sheet-close').addEventListener('click', () => this._closeActionDrawer());
// Clicking the backdrop (outside the sheet) closes the drawer.
drawer.addEventListener('click', (e) => {
if (e.target === drawer) this._closeActionDrawer();
});
const input = drawer.querySelector('.bookslm-qa-search input');
input.addEventListener('input', () => this._renderActionDrawerList(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
this._closeActionDrawer();
}
});
if (typeof safeCreateIcons === 'function') safeCreateIcons();
return drawer;
}
_openActionDrawer() {
const drawer = this._ensureActionDrawer();
if (!drawer) return;
drawer.classList.remove('hidden');
requestAnimationFrame(() => drawer.classList.add('open'));
this._renderActionDrawerList('');
const input = drawer.querySelector('.bookslm-qa-search input');
if (input) { input.value = ''; input.focus(); }
}
_closeActionDrawer() {
if (!this._panel) return;
const drawer = this._panel.querySelector('.bookslm-qa-drawer');
if (!drawer) return;
drawer.classList.remove('open');
// Let the slide-down transition finish before hiding.
setTimeout(() => drawer.classList.add('hidden'), 220);
}
_renderActionDrawerList(filter) {
const drawer = this._panel && this._panel.querySelector('.bookslm-qa-drawer');
if (!drawer) return;
const body = drawer.querySelector('.bookslm-qa-sheet-list');
if (!body) return;
body.innerHTML = '';
const q = normalizeSearch(filter || '');
let shown = 0;
for (const cat of CATEGORIES) {
const actions = ACTION_CATALOG.filter((a) => a.cat === cat.id
&& (!q || normalizeSearch(actionTexts(a).label).includes(q)));
if (!actions.length) continue;
const head = document.createElement('p');
head.className = 'bookslm-qa-cat';
head.innerHTML = `<i data-lucide="${cat.icon}" style="width:13px;height:13px"></i> ${t(cat.labelKey)}`;
body.appendChild(head);
for (const action of actions) {
body.appendChild(this._renderQuickActionBtn(action, { compact: true }));
shown++;
}
}
if (!shown) {
const empty = document.createElement('div');
empty.className = 'bookslm-qa-empty';
empty.textContent = t('qa.no_match');
body.appendChild(empty);
}
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
_renderMessages(opts = {}) {
@@ -2446,6 +2702,17 @@ class BooksLM {
return t('ai.tool_call', { name: call.name });
}
/**
* BUG-074 — number of *action* steps of a message (reasoning notes are not
* actions). Falls back to the total when the block holds only thoughts so a
* “0 étape” label can never appear.
*/
_actionStepCount(toolCalls) {
const list = toolCalls || [];
const actions = list.filter((c) => !(c.step && c.step.key === 'thought'));
return actions.length || list.length;
}
/** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */
_chevron() {
const c = document.createElement('span');
@@ -2479,13 +2746,32 @@ class BooksLM {
dots.classList.add('bookslm-steps-dots');
summary.appendChild(dots);
}
const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
// BUG-074: the counter reflects *actions*, not reasoning notes, and the
// collapsed header also previews the first action so an “N étapes ▶” line
// is no longer an opaque title.
const actionCalls = toolCalls.filter((c) => !(c.step && c.step.key === 'thought'));
const count = this._actionStepCount(toolCalls);
const countKey = count > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
const label = document.createElement('span');
label.className = 'bookslm-steps-label';
label.textContent = t(countKey, { count: toolCalls.length });
label.textContent = t(countKey, { count });
summary.appendChild(label);
const first = actionCalls[0];
let preview = '';
if (first) {
preview = this._stepText(first);
const title = document.createElement('span');
title.className = 'bookslm-steps-title';
title.textContent = `— ${preview}`;
summary.appendChild(title);
}
summary.appendChild(this._chevron());
if (running) summary.setAttribute('aria-label', `${label.textContent} — ${t('ai.steps_running')}`);
if (running) {
summary.setAttribute(
'aria-label',
`${label.textContent}${preview ? ` — ${preview}` : ''} — ${t('ai.steps_running')}`,
);
}
wrap.appendChild(summary);
const body = document.createElement('div');
@@ -2585,8 +2871,11 @@ class BooksLM {
const conf = msg.confirmation;
const pending = conf.pending || {};
const error = pending.error || pending;
const tool = error.tool || 'action';
const args = error.arguments || {};
// BUG-075: the run batches every mutating call of the LLM turn into
// `pending.actions`; fall back to the single legacy `error` shape.
const actions = (Array.isArray(pending.actions) && pending.actions.length)
? pending.actions
: [{ id: error.id, tool: error.tool, arguments: error.arguments || {}, step: null }];
const card = document.createElement('div');
card.className = 'bookslm-action bookslm-confirm';
@@ -2595,23 +2884,48 @@ class BooksLM {
meta.className = 'bookslm-action-meta';
meta.innerHTML = '<span class="bookslm-action-icon">🔒</span>';
const textEl = document.createElement('span');
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
if (actions.length > 1) {
textEl.textContent = t('ai.confirm_actions', { count: actions.length });
} else {
const tool = actions[0].tool || 'action';
const args = actions[0].arguments || {};
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
}
meta.appendChild(textEl);
card.appendChild(meta);
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (conf._diffHtml) {
diffHost.innerHTML = conf._diffHtml;
} else if (!conf._diffLoading) {
conf._diffLoading = true;
this._fillConfirmationDiff(args, conf).then(() => this._renderMessages());
// One row per action with a diff preview when it writes file content.
const hasContent = (a) => {
const args = a.arguments || {};
return typeof args.content === 'string' && args.vault && args.path;
};
if (actions.length > 1) {
const list = document.createElement('div');
list.className = 'bookslm-confirm-actions';
for (const action of actions) {
const args = action.arguments || {};
const row = document.createElement('details');
row.className = 'bookslm-confirm-action';
const summary = document.createElement('summary');
const text = document.createElement('span');
text.textContent = this._stepText({ step: action.step, name: action.tool })
+ (args.path ? ` — ${args.path}` : '');
summary.appendChild(text);
if (hasContent(action)) summary.appendChild(this._chevron());
row.appendChild(summary);
if (hasContent(action)) row.appendChild(this._diffHost(action, args));
list.appendChild(row);
}
card.appendChild(list);
} else if (hasContent(actions[0])) {
card.appendChild(this._diffHost(conf, actions[0].arguments || {}));
}
card.appendChild(diffHost);
const apply = document.createElement('button');
apply.className = 'bookslm-action-apply';
apply.textContent = t('ai.action_apply');
apply.textContent = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: t('ai.action_apply');
apply.addEventListener('click', async () => {
apply.disabled = true;
apply.textContent = t('ai.action_applying');
@@ -2620,7 +2934,9 @@ class BooksLM {
apply.textContent = t('ai.action_applied');
} catch (e) {
apply.disabled = false;
apply.textContent = t('ai.action_apply');
apply.textContent = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: t('ai.action_apply');
showToast(t('ai.action_failed', { error: e.message }), 'error');
}
});
@@ -2628,6 +2944,19 @@ class BooksLM {
return card;
}
/** Diff host for one confirmation action (lazy LCS diff, cached on the host). */
_diffHost(host, args) {
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (host._diffHtml) {
diffHost.innerHTML = host._diffHtml;
} else if (!host._diffLoading) {
host._diffLoading = true;
this._fillConfirmationDiff(args, host).then(() => this._renderMessages());
}
return diffHost;
}
async _fillConfirmationDiff(args, conf) {
const proposed = args.content;
if (typeof proposed !== 'string' || !args.vault || !args.path) return;
@@ -2716,22 +3045,20 @@ class BooksLM {
if (!conf || conf._applying) return;
conf._applying = true;
// BUG-075: one click applies the whole pending batch AND authorizes the
// remaining actions of the same run (no second confirmation card).
const payload = {
...(msg.payload || {}),
confirm: conf.pending,
confirm_messages: conf.messages,
confirm_all: true,
};
this._isLoading = true;
this._abortCtrl = new AbortController();
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._syncSendButton();
this._setActivity('working', t('ai.activity_streaming'));
// BUG-046: carry the original request payload over to the continuation.
// When an applied tool failed and the model re-proposed a confirmation,
// the continuation used to have `payload: null`: the second "Appliquer"
// then resumed without `message` → 422 → "[object Object]" toast.
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: msg.payload ? { ...msg.payload } : null };
try {
let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) {
@@ -2741,21 +3068,65 @@ class BooksLM {
if (!resp.ok) {
throw await this._responseError(resp);
}
// The confirmation is resolved: drop the card and show the continuation.
// The confirmation is resolved: drop the card and keep streaming into the
// SAME message, so the steps block accumulates the whole exchange
// instead of fragmenting into a new “1 étape” message per approval
// (BUG-074). BUG-046 payload carry-over is inherent: `msg.payload` stays.
msg.confirmation = null;
this._messages.push(continuation);
this._renderMessages({ anchor: true });
await this._streamResponse(resp, continuation, payload);
await this._streamResponse(resp, msg, payload);
} catch (e) {
if (e.name === 'AbortError') {
this._markStopped(msg);
} else {
throw e;
}
} finally {
conf._applying = false;
this._isLoading = false;
this._abortCtrl = null;
if (sendBtn) sendBtn.disabled = false;
this._syncSendButton();
this._renderMessages();
this._saveHistory();
}
}
/** BUG-077 — abort the in-flight agent/chat request. */
_stopGeneration() {
if (this._abortCtrl) {
try {
this._abortCtrl.abort();
} catch { /* already aborted */ }
}
}
/** Append a visible « stopped by the user » marker to an assistant message. */
_markStopped(msg) {
const marker = `⏹ ${t('ai.stopped')}`;
const content = String(msg.content || '');
if (!content.includes(marker)) {
msg.content = content ? `${content}\n\n${marker}` : marker;
}
this._setActivity('idle');
}
/**
* BUG-077 — the composer's round button doubles as a Stop control while a
* response streams: a new send is already blocked by `_isLoading`, so the
* button switches to a stop icon instead of being disabled.
*/
_syncSendButton() {
const btn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (!btn) return;
const loading = !!this._isLoading;
btn.classList.toggle('is-stopping', loading);
btn.title = loading ? t('ai.stop') : t('bookslm.send');
btn.setAttribute('aria-label', btn.title);
const icon = btn.querySelector('i');
if (icon) icon.setAttribute('data-lucide', loading ? 'square' : 'arrow-up');
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
// ── Messaging ───────────────────────────────────────────────────────
async _sendMessage() {
@@ -2834,10 +3205,8 @@ class BooksLM {
this._isLoading = true;
this._renderMessages({ anchor: true, instant: true });
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
this._syncSendButton();
this._setActivity('working', t('ai.activity_sending'));
try {
@@ -2866,17 +3235,17 @@ class BooksLM {
}
this._setActivity('done', t('ai.activity_done'));
} catch (e) {
if (e.name !== 'AbortError') {
if (e.name === 'AbortError') {
this._markStopped(assistantMsg);
} else {
assistantMsg.content = `⚠ Error: ${e.message}`;
console.warn('AI assistant chat error:', e);
this._setActivity('error', t('ai.activity_error'));
} else {
this._setActivity('idle');
}
}
this._isLoading = false;
if (sendBtn) sendBtn.disabled = false;
this._syncSendButton();
this._abortCtrl = null;
this._renderMessages();
this._saveHistory();
@@ -2936,6 +3305,10 @@ class BooksLM {
});
// #93 — a vault write must be reflected in the displayed document.
this._notifyFileWritten(data);
// BUG-076 — reflect tree changes (create/delete/rename…) right away.
if (data.ok !== false && MUTATING_TOOLS.has(data.name)) {
this._scheduleTreeRefresh();
}
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
return;
}
+612 -40
View File
@@ -6,7 +6,7 @@ import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext,
import { escapeHtml, safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, setLocale, getLocale } from './i18n.js';
import { getModelCapabilities, renderCapabilityList, refreshAIPickers } from './ai.js';
import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
let _recentTimestampTimer = null;
let _recentFilesCache = [];
@@ -353,6 +353,7 @@ function initHelpModal() {
initHelpNavigation();
helpNavInitialized = true;
}
renderGuideMermaid();
});
closeBtn.addEventListener("click", closeHelpModal);
@@ -367,6 +368,64 @@ function initHelpModal() {
closeHelpModal();
}
});
// Guide downloads (#105) — markdown / pdf, current language.
const dlMd = document.getElementById("help-download-md");
const dlPdf = document.getElementById("help-download-pdf");
[dlMd, dlPdf].forEach((btn) => {
if (!btn) return;
btn.addEventListener("click", () => {
downloadGuide(btn.id === "help-download-md" ? "md" : "pdf");
});
});
}
// Render any Mermaid blocks inside the guide modal (the architecture diagram,
// #105). The viewer's own pipeline only touches document views, so the help
// modal is enriched here — once per modal open, cheap on repeats.
function renderGuideMermaid() {
const modal = document.getElementById("help-modal");
if (!modal || modal.dataset.mermaidRendered === "1") return;
import("./mermaid-viewer.js")
.then((m) => m.renderMermaidBlocks(modal))
.then(() => {
// Only mark done when the source block actually became a rendered
// diagram — if the Mermaid CDN wasn't ready yet, retry on next open.
if (!modal.querySelector("code.language-mermaid")) {
modal.dataset.mermaidRendered = "1";
}
})
.catch(() => { /* CDN offline: keep the code block readable */ });
}
// Fetch the generated guide (auth headers + cookie) and trigger the browser
// download, mirroring viewer.downloadExport().
async function downloadGuide(format) {
showToast(t("viewer.export_start"), "info");
try {
const headers = AuthManager.getAuthHeaders ? AuthManager.getAuthHeaders() || {} : {};
const res = await fetch(
`/api/guide/download?format=${format}&lang=${encodeURIComponent(getLocale() || "fr")}`,
{ 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 = `ObsiGate-Guide-${getLocale() || "fr"}.${format}`;
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("Guide export error:", err);
showToast(t("viewer.export_error") + " " + err.message, "error");
}
}
function initEditorPocBtn() {
@@ -708,12 +767,22 @@ function initConfigModal() {
openBtn.addEventListener("click", async () => {
modal.classList.add("active");
closeHeaderMenu();
// BUG-071/#114: reset the TOC to the CSS default (mobile: hidden drawer,
// desktop: visible sidebar) like the help modal does on open. Clear the
// stale inline display and the drawer-open class so a previous mobile
// session cannot leave the nav stuck open.
var configNavOnOpen = document.getElementById("config-nav");
if (configNavOnOpen) configNavOnOpen.style.display = '';
modal.classList.remove("config-toc-open");
var configHamburgerOnOpen = document.getElementById("config-hamburger");
if (configHamburgerOnOpen) configHamburgerOnOpen.classList.remove("active");
renderConfigFilters();
loadConfigFields();
loadDiagnostics();
loadAbout();
await loadHiddenFilesSettings();
loadWebhooksUI();
loadTokensUI();
loadSharesUI();
loadToolKeys();
safeCreateIcons();
@@ -722,6 +791,12 @@ function initConfigModal() {
closeBtn.addEventListener("click", closeConfigModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) {
// #114: a tap on the backdrop (or outside the drawer) first closes the
// TOC drawer; only a second tap closes the whole modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
@@ -766,6 +841,11 @@ function initConfigModal() {
if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys);
const testAIKeysBtn = document.getElementById("cfg-test-ai-keys");
if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys);
// Provider search filter (#104)
const aiProviderSearch = document.getElementById("cfg-ai-search");
if (aiProviderSearch) {
aiProviderSearch.addEventListener("input", () => filterAIProviders(aiProviderSearch.value));
}
// Tool & connected-source keys (#103)
const saveToolKeysBtn = document.getElementById("cfg-save-tool-keys");
if (saveToolKeysBtn) saveToolKeysBtn.addEventListener("click", saveToolKeys);
@@ -821,8 +901,61 @@ function initConfigModal() {
});
}
// BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav
// rule that hides it below 768px, but — unlike the help modal — the config
// modal had no toggle to reveal it, leaving mobile users with no way to
// reach a section. The header hamburger opens it as a left slide-over
// drawer (backdrop via .config-toc-open on the modal); picking a section
// smooth-scrolls inside the modal and collapses it on mobile.
var configNav = document.getElementById("config-nav");
var configHamburger = document.getElementById("config-hamburger");
function _isConfigMobile() { return window.innerWidth <= 768; }
function _setConfigNav(open) {
if (!configNav) return;
configNav.style.display = open ? "flex" : "none";
modal.classList.toggle("config-toc-open", !!open && _isConfigMobile());
if (configHamburger) configHamburger.classList.toggle("active", !!open);
}
if (configHamburger) {
configHamburger.addEventListener("click", function(e) {
e.stopPropagation();
var hidden = !configNav || configNav.style.display === "none" || configNav.style.display === "";
_setConfigNav(hidden);
});
}
// #114: close button inside the drawer header.
var configTocClose = document.getElementById("config-toc-close");
if (configTocClose) {
configTocClose.addEventListener("click", function(e) {
e.stopPropagation();
_setConfigNav(false);
});
}
if (configNav) {
configNav.querySelectorAll(".help-nav-link").forEach(function(a) {
a.addEventListener("click", function(e) {
var hash = a.getAttribute("href");
if (!hash || hash.charAt(0) !== "#") return;
var target = document.getElementById(hash.slice(1));
if (!target) return;
e.preventDefault();
configNav.querySelectorAll(".help-nav-link").forEach(function(o) { o.classList.remove("active"); });
a.classList.add("active");
if (typeof target.scrollIntoView === "function") {
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
if (_isConfigMobile()) _setConfigNav(false);
});
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
// #114: Escape closes the TOC drawer first, then the modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
@@ -842,7 +975,13 @@ function initConfigModal() {
function closeConfigModal() {
const modal = document.getElementById("config-modal");
if (modal) modal.classList.remove("active");
if (modal) {
modal.classList.remove("active");
// #114: never leave the drawer-open class (backdrop) behind.
modal.classList.remove("config-toc-open");
const nav = document.getElementById("config-nav");
if (nav) nav.style.display = '';
}
}
// --- Config field helpers ---
@@ -1282,6 +1421,109 @@ document.addEventListener("click", function(e) {
}
});
// ── API / MCP tokens UI (#107) ──
let _tokensBound = false;
async function loadTokensUI() {
const list = document.getElementById("tokens-list");
if (!list) return;
try {
const data = await api("/api/auth/tokens");
renderTokensUI(data.tokens || []);
bindTokenEvents();
} catch (err) {
list.innerHTML = '<div class="config-description">' + escapeHtml(t("config.error_prefix") + ": " + (err.message || "")) + "</div>";
}
}
function _formatTokenDate(unixSec) {
if (!unixSec) return "";
return new Date(unixSec * 1000).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
}
function _tokenExpiryLabel(tok) {
if (!tok.expires_at) return t("config.token_expiry_never");
const map = { "1d": "config.token_expiry_1d", "30d": "config.token_expiry_30d", "180d": "config.token_expiry_180d", "365d": "config.token_expiry_365d" };
return map[tok.expiry_key] ? t(map[tok.expiry_key]) : _formatTokenDate(tok.expires_at);
}
function renderTokensUI(tokens) {
const list = document.getElementById("tokens-list");
if (!list) return;
if (!tokens.length) {
list.innerHTML = '<div class="config-description">' + escapeHtml(t("config.tokens_empty")) + "</div>";
return;
}
list.innerHTML = tokens.map(tok => {
const expired = tok.expired || (tok.expires_at && tok.expires_at * 1000 < Date.now());
const status = expired
? '<span class="token-badge token-badge-expired">' + escapeHtml(t("config.token_status_expired")) + "</span>"
: '<span class="token-badge token-badge-active">' + escapeHtml(t("config.token_status_active")) + "</span>";
const meta = [
t("config.token_created") + " " + _formatTokenDate(tok.created_at),
t("config.token_expires") + " " + _tokenExpiryLabel(tok),
tok.last_used_at ? t("config.token_last_used") + " " + _formatTokenDate(tok.last_used_at) : t("config.token_never_used")
].join(" · ");
return '<div class="token-item" data-jti="' + escapeHtml(tok.jti) + '">' +
'<span class="token-name">' + escapeHtml(tok.name) + "</span>" +
status +
'<span class="token-meta">' + escapeHtml(meta) + "</span>" +
'<button class="token-delete" data-jti="' + escapeHtml(tok.jti) + '" data-name="' + escapeHtml(tok.name) + '" title="' + escapeHtml(t("config.token_revoke")) + '">✕</button>' +
"</div>";
}).join("");
list.querySelectorAll(".token-delete").forEach(btn => btn.addEventListener("click", async () => {
const name = btn.dataset.name;
if (!confirm(t("config.token_revoke_confirm") + " \"" + name + "\" ?")) return;
try {
await api("/api/auth/tokens/" + btn.dataset.jti, { method: "DELETE" });
showToast(t("config.token_revoked_toast"), "success");
loadTokensUI();
} catch (err) {
showToast(err.message || t("config.error_unknown"), "error");
}
}));
}
function bindTokenEvents() {
if (_tokensBound) return;
_tokensBound = true;
const createBtn = document.getElementById("token-create-btn");
if (!createBtn) return;
createBtn.addEventListener("click", async () => {
const name = document.getElementById("token-name-input").value.trim();
const expiry = document.getElementById("token-expiry-select").value;
if (!name) { showToast(t("config.token_name_required"), "error"); return; }
createBtn.disabled = true;
try {
const res = await api("/api/auth/tokens", { method: "POST", body: JSON.stringify({ name, expiry }) });
const area = document.getElementById("token-secret-area");
const ta = document.getElementById("token-secret-value");
ta.value = res.token;
area.classList.remove("hidden");
ta.select();
document.getElementById("token-name-input").value = "";
showToast(t("config.token_created_toast"), "success");
loadTokensUI();
} catch (err) {
showToast(err.message || t("config.error_unknown"), "error");
} finally {
createBtn.disabled = false;
}
});
const copyBtn = document.getElementById("token-copy-btn");
if (copyBtn) copyBtn.addEventListener("click", async () => {
const ta = document.getElementById("token-secret-value");
try { await navigator.clipboard.writeText(ta.value); }
catch { ta.select(); document.execCommand("copy"); }
showToast(t("config.token_copied"), "success");
});
const dismissBtn = document.getElementById("token-dismiss-btn");
if (dismissBtn) dismissBtn.addEventListener("click", () => {
document.getElementById("token-secret-area").classList.add("hidden");
document.getElementById("token-secret-value").value = "";
});
}
// ── Shares UI ──
async function loadSharesUI() {
const list = document.getElementById("shares-list");
@@ -1510,7 +1752,7 @@ function updateRegexPreview() {
}
// ── AI Keys management ──
// ── AI Keys management (#104 — accordion redesign) ──
const AI_KEY_MAP = {
"cfg-deepseek-key": "DEEPSEEK_API_KEY",
"cfg-openrouter-key": "OPENROUTER_API_KEY",
@@ -1522,57 +1764,140 @@ const AI_KEY_MAP = {
};
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);
// Display metadata for the provider accordion cards (#104).
const AI_PROVIDER_META = {
deepseek: { name: "DeepSeek", placeholder: "sk-..." },
openrouter: { name: "OpenRouter", placeholder: "sk-or-..." },
gemini: { name: "Gemini", placeholder: "AIza..." },
nvidia: { name: "NVIDIA", placeholder: "nvapi-..." },
qwencloud: { name: "QwenCloud", placeholder: "sk-..." },
xiaomi: { name: "Xiaomi", placeholder: "xm-..." },
mistral: { name: "Mistral", placeholder: "sk-..." },
};
function _renderAIProviderCards() {
const host = document.getElementById("cfg-ai-providers");
if (!host) return;
host.innerHTML = "";
for (const p of AI_PROVIDER_NAMES) {
const meta = AI_PROVIDER_META[p] || { name: p, placeholder: "sk-..." };
const card = el("div", { class: "ai-provider-card", "data-provider": p });
// Header row (div + role=button so the per-provider delete button can
// live inside without nesting two interactive elements).
const head = el("div", {
class: "ai-provider-head",
role: "button",
tabindex: "0",
"aria-expanded": "false",
});
head.appendChild(el("span", { class: "ai-provider-logo", "aria-hidden": "true" }, [document.createTextNode(meta.name.charAt(0))]));
head.appendChild(el("span", { class: "ai-provider-name" }, [document.createTextNode(meta.name)]));
head.appendChild(el("span", { class: "ai-provider-badge", id: `cfg-${p}-badge` }));
const delBtn = el("button", {
type: "button",
class: "ai-provider-delete",
id: `cfg-${p}-delete`,
title: t("config.ai_delete_key_title"),
});
delBtn.style.display = "none";
delBtn.appendChild(icon("trash-2", 14));
delBtn.addEventListener("click", (e) => {
e.stopPropagation();
deleteAIKey(`cfg-${p}-key`);
});
head.appendChild(delBtn);
const chevron = icon("chevron-down", 16);
chevron.classList.add("ai-provider-chevron");
head.appendChild(chevron);
const toggle = () => _toggleAICard(card);
head.addEventListener("click", toggle);
head.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
});
// Collapsible body: API key (60%) + model (40%), labels above inputs.
const body = el("div", { class: "ai-provider-body hidden" });
const fields = el("div", { class: "ai-provider-fields" });
const keyField = el("div", { class: "ai-field ai-field-key" });
keyField.appendChild(el("label", { class: "ai-field-label", for: `cfg-${p}-key` }, [document.createTextNode(`${meta.name} API Key`)]));
const keyInput = document.createElement("input");
keyInput.type = "password";
keyInput.id = `cfg-${p}-key`;
keyInput.className = "config-input";
keyInput.placeholder = meta.placeholder;
keyInput.autocomplete = "off";
keyField.appendChild(keyInput);
const modelField = el("div", { class: "ai-field ai-field-model" });
modelField.appendChild(el("label", { class: "ai-field-label", for: `cfg-${p}-model` }, [document.createTextNode(t("config.ai_model"))]));
const modelSel = document.createElement("select");
modelSel.id = `cfg-${p}-model`;
modelSel.className = "config-select";
modelSel.innerHTML = '<option value="">-- Modele --</option>';
modelField.appendChild(modelSel);
fields.appendChild(keyField);
fields.appendChild(modelField);
body.appendChild(fields);
card.appendChild(head);
card.appendChild(body);
host.appendChild(card);
}
safeCreateIcons();
}
function _toggleAICard(card) {
const open = card.classList.toggle("open");
const body = card.querySelector(".ai-provider-body");
if (body) body.classList.toggle("hidden", !open);
const head = card.querySelector(".ai-provider-head");
if (head) head.setAttribute("aria-expanded", open ? "true" : "false");
}
/** Filter the provider accordion cards by the section search input (#104). */
export function filterAIProviders(query) {
const host = document.getElementById("cfg-ai-providers");
const emptyMsg = document.getElementById("cfg-ai-providers-empty");
if (!host) return;
const q = _sidebarNorm(query);
let visible = 0;
host.querySelectorAll(".ai-provider-card").forEach((card) => {
const p = card.dataset.provider;
const meta = AI_PROVIDER_META[p] || { name: p };
const match = !q || _sidebarNorm(meta.name).includes(q) || p.includes(q);
card.style.display = match ? "" : "none";
if (match) visible++;
});
if (emptyMsg) emptyMsg.classList.toggle("hidden", visible > 0);
}
function _setAIKeyBadge(inputId, hasKey) {
const badge = document.getElementById(inputId.replace("-key", "-badge"));
const delBtn = document.getElementById(inputId.replace("-key", "-delete"));
const provider = inputId.replace("-key", "");
const badge = document.getElementById(`${provider}-badge`);
const delBtn = document.getElementById(`${provider}-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)";
}
badge.textContent = hasKey
? "\u2713 " + t("config.ai_status_configured")
: t("config.ai_status_not_configured");
badge.classList.toggle("configured", !!hasKey);
}
if (delBtn) delBtn.style.display = hasKey ? "inline-block" : "none";
if (delBtn) delBtn.style.display = hasKey ? "inline-flex" : "none";
}
async function loadAIKeys() {
_ensureAIKeyUI();
_renderAIProviderCards();
try {
const data = await api("/api/config/ai-keys");
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-...");
const meta = AI_PROVIDER_META[inputId.replace("-key", "")] || {};
input.placeholder = val || meta.placeholder || "sk-...";
}
_setAIKeyBadge(inputId, !!val);
}
@@ -1626,7 +1951,7 @@ async function _renderConfigModelCaps(provider, model) {
if (!provider || !model) return;
const caps = await getModelCapabilities(provider, model);
if (!caps) return;
host.appendChild(renderCapabilityList(caps));
host.appendChild(renderCapabilityBadges(caps));
}
async function saveAIKeys() {
@@ -1830,6 +2155,8 @@ export {
initProfile,
switchSidebarTab,
populateVersions,
loadAIKeys,
saveAIKeys,
};
// Populate every version display (header badge, About modal, help guide footer)
@@ -2061,6 +2388,107 @@ function initAboutModal() {
}
// ── Profile ──────────────────────────────────────────────────────────
// #113 — profile picture: pick/import an image, center-crop it to a 256 px
// square JPEG, persist it on the account (PATCH /api/auth/me) and show it in
// the sidebar circle (#sidebar-user-avatar). Initials remain the fallback.
const _AVATAR_MAX_FILE_BYTES = 8 * 1024 * 1024;
const _AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
const _AVATAR_SIZE = 256;
function _profileInitials(name) {
var parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
var first = parts[0][0] || "";
var last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (first + last).toUpperCase() || "?";
}
/** Center-crop the picked image to a square and export it as a JPEG data-URL. */
function _resizeAvatarFile(file) {
return new Promise(function (resolve, reject) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
URL.revokeObjectURL(url);
try {
var canvas = document.createElement("canvas");
canvas.width = _AVATAR_SIZE;
canvas.height = _AVATAR_SIZE;
var ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, _AVATAR_SIZE, _AVATAR_SIZE);
var side = Math.min(img.naturalWidth, img.naturalHeight);
var sx = (img.naturalWidth - side) / 2;
var sy = (img.naturalHeight - side) / 2;
ctx.drawImage(img, sx, sy, side, side, 0, 0, _AVATAR_SIZE, _AVATAR_SIZE);
resolve(canvas.toDataURL("image/jpeg", 0.85));
} catch (err) {
reject(err);
}
};
img.onerror = function () {
URL.revokeObjectURL(url);
reject(new Error("image decode failed"));
};
img.src = url;
});
}
function _profileAvatarError(key) {
var err = document.getElementById("profile-avatar-error");
if (!err) return;
if (!key) {
err.textContent = "";
err.classList.add("hidden");
return;
}
err.textContent = t(key);
err.classList.remove("hidden");
}
/** Paint the settings preview (image or initials) for the given avatar. */
function _renderProfileAvatar(dataUrl) {
var img = document.getElementById("profile-avatar-img");
var initialsEl = document.getElementById("profile-avatar-initials");
var removeBtn = document.getElementById("profile-avatar-remove");
if (!img || !initialsEl) return;
if (dataUrl) {
img.src = dataUrl;
img.hidden = false;
initialsEl.textContent = "";
if (removeBtn) removeBtn.hidden = false;
} else {
img.removeAttribute("src");
img.hidden = true;
var user = AuthManager.getUser() || {};
var name = localStorage.getItem("obsigate-name") || user.display_name || user.username || "";
initialsEl.textContent = _profileInitials(name);
if (removeBtn) removeBtn.hidden = true;
}
}
/** PATCH /api/auth/me with an avatar value (data-URL, or "" to clear). */
async function _patchProfileAvatar(value) {
var headers = { "Content-Type": "application/json" };
var authHeaders = AuthManager.getAuthHeaders ? AuthManager.getAuthHeaders() : null;
if (authHeaders) headers = Object.assign(headers, authHeaders);
var res = await fetch("/api/auth/me", {
method: "PATCH",
headers: headers,
credentials: "include",
body: JSON.stringify({ avatar: value }),
});
if (!res.ok) {
var detail = "";
try {
detail = (await res.json()).detail || "";
} catch (e) { /* no json body */ }
throw new Error(detail || "HTTP " + res.status);
}
return res.json();
}
function initProfile() {
var langSel = document.getElementById('profile-lang');
var nameInp = document.getElementById('profile-name');
@@ -2098,4 +2526,148 @@ function initProfile() {
});
// Logout button — handled inline in index.html (onclick)
// ── Avatar (#113) ────────────────────────────────────────────────
var avatarField = document.getElementById('profile-avatar-field');
var avatarInput = document.getElementById('profile-avatar-input');
var chooseBtn = document.getElementById('profile-avatar-choose');
var overlayBtn = document.getElementById('profile-avatar-overlay');
var previewBox = document.getElementById('profile-avatar-preview');
var removeBtn = document.getElementById('profile-avatar-remove');
var presetsBox = document.getElementById('profile-avatar-presets');
if (!avatarField || !avatarInput || !chooseBtn) return;
// The avatar only exists on a real account — hide it when auth is off.
if (!AuthManager.isAuthEnabled || !AuthManager.isAuthEnabled()) {
avatarField.hidden = true;
return;
}
_renderProfileAvatar((AuthManager.getUser() || {}).avatar || null);
// Refresh from the server so the preview survives a page reload.
fetch('/api/auth/me', { credentials: 'include' })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (user) {
if (!user) return;
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || null);
})
.catch(function () { /* non-bloquant */ });
// ── Preset avatars (#117) ────────────────────────────────────────
var PRESET_STORAGE_KEY = 'obsigate-avatar-preset';
function markActivePreset(name) {
if (!presetsBox) return;
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
var on = !!name && btn.getAttribute('data-avatar') === name;
btn.classList.toggle('active', on);
btn.setAttribute('aria-checked', on ? 'true' : 'false');
});
}
function clearActivePreset() {
try { localStorage.removeItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(null);
}
function rememberActivePreset(name) {
try { localStorage.setItem(PRESET_STORAGE_KEY, name); } catch (e) { /* ignore */ }
markActivePreset(name);
}
var savedPreset = null;
try { savedPreset = localStorage.getItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(savedPreset);
if (presetsBox) {
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
btn.addEventListener('click', async function () {
var preset = btn.getAttribute('data-avatar');
if (!preset) return;
_profileAvatarError(null);
var all = presetsBox.querySelectorAll('.profile-avatar-preset');
all.forEach(function (b) { b.disabled = true; });
try {
// Load the bundled image, then reuse the upload pipeline (center-crop
// + 256 px JPEG) so it stores exactly like an imported picture.
var res = await fetch('/static/icons/avatar/' + encodeURIComponent(preset), { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
var blob = await res.blob();
var dataUrl = await _resizeAvatarFile(blob);
var user = await _patchProfileAvatar(dataUrl);
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
rememberActivePreset(preset);
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar preset failed:', err);
} finally {
all.forEach(function (b) { b.disabled = false; });
}
});
});
}
function openPicker() {
_profileAvatarError(null);
avatarInput.click();
}
chooseBtn.addEventListener('click', openPicker);
if (overlayBtn) overlayBtn.addEventListener('click', openPicker);
if (previewBox) {
previewBox.addEventListener('click', function (e) {
if (e.target.closest('button')) return;
openPicker();
});
}
avatarInput.addEventListener('change', async function () {
var file = avatarInput.files && avatarInput.files[0];
avatarInput.value = ""; // allow re-picking the same file
if (!file) return;
_profileAvatarError(null);
if (_AVATAR_TYPES.indexOf(file.type) === -1) {
_profileAvatarError('config.avatar_invalid_type');
return;
}
if (file.size > _AVATAR_MAX_FILE_BYTES) {
_profileAvatarError('config.avatar_too_large');
return;
}
chooseBtn.disabled = true;
try {
var dataUrl = await _resizeAvatarFile(file);
var user = await _patchProfileAvatar(dataUrl);
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
clearActivePreset(); // an imported photo is not a preset
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar upload failed:', err);
} finally {
chooseBtn.disabled = false;
}
});
if (removeBtn) {
removeBtn.addEventListener('click', async function () {
_profileAvatarError(null);
removeBtn.disabled = true;
try {
var user = await _patchProfileAvatar("");
AuthManager.updateCachedUser(user);
_renderProfileAvatar(null);
AuthManager.renderUserSection();
clearActivePreset();
showToast(t('config.avatar_removed'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar removal failed:', err);
} finally {
removeBtn.disabled = false;
}
});
}
}
+3
View File
@@ -231,6 +231,9 @@ export async function initDesktopIntegration() {
defineBackendCrashBanner();
if (!isTauriEnv()) return false;
// Desktop marker for CSS (wider reading layouts, e.g. the user guide #105).
try { document.body.classList.add('desktop-mode'); } catch (e) { /* ignore */ }
// Follow the OS theme on first run, before the theme engine renders.
await syncSystemTheme();
+17 -14
View File
@@ -5,8 +5,11 @@
* excalidraw-editor.html. Communication between parent and iframe
* via postMessage:
*
* Parent → Iframe: init {data, theme} | theme {theme} | requestSave
* Parent → Iframe: init {data, theme} | theme {theme}
* Iframe → Parent: ready | save {data} | modified {dirty}
*
* Saves are explicit only (in-editor Save button / Ctrl+S): no autosave, so
* writing the file never triggers an `index_updated` reload of the viewer.
*/
import { api } from './auth.js';
@@ -35,7 +38,15 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
iframe.src = '/static/excalidraw-editor.html?v=' + Date.now();
iframe.sandbox.add('allow-scripts');
iframe.sandbox.add('allow-same-origin');
// Let the editor's own Fullscreen button work (native Fullscreen API inside
// the sandboxed iframe).
iframe.setAttribute('allow', 'fullscreen');
iframe.setAttribute('allowfullscreen', '');
iframe.style.cssText = 'width:100%;height:100%;border:none;';
// Identify the owning document so sync.js can avoid re-rendering (and thus
// reloading) this iframe after its own save triggers an `index_updated`.
iframe.dataset.excalidrawVault = vaultName;
iframe.dataset.excalidrawPath = filePath;
// Clean container and insert iframe
container.innerHTML = '';
@@ -49,7 +60,6 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
path: filePath,
isDirty: false,
ready: false,
saveTimer: null,
};
_activeEditors.set(editorId, editorState);
@@ -94,16 +104,11 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
break;
case 'modified':
// No autosave: saving writes the file, which emits `index_updated` and
// reloads the viewer (visible page refresh) — and can interrupt the
// user mid-drawing. The scene is saved explicitly via the in-editor
// Save button or Ctrl+S. We only track the dirty state.
editorState.isDirty = msg.dirty === true;
// If dirty, start auto-save timer (2s debounce)
if (editorState.isDirty) {
if (editorState.saveTimer) clearTimeout(editorState.saveTimer);
editorState.saveTimer = setTimeout(() => {
if (editorState.isDirty && editorState.ready) {
iframe.contentWindow.postMessage({ type: 'requestSave' }, '*');
}
}, 2000);
}
break;
}
});
@@ -154,9 +159,7 @@ export function notifyExcalidrawThemeChange(theme) {
* Clean up an editor instance (e.g., when tab is closed).
*/
export function destroyExcalidrawEditor(editorId) {
const state = _activeEditors.get(editorId);
if (state) {
if (state.saveTimer) clearTimeout(state.saveTimer);
if (_activeEditors.has(editorId)) {
_activeEditors.delete(editorId);
}
}
+13 -6
View File
@@ -5,6 +5,7 @@
* Static DOM: data-i18n="key" → textContent
* data-i18n-placeholder="key" → placeholder
* data-i18n-attr:title="key" → title attribute
* data-i18n-attr="a:k1;b:k2" → several attributes (";"-separated)
* data-i18n-html="key" → innerHTML (use sparingly)
* Dynamic JS: import { t } from './i18n.js'; t('key', {param: 'val'})
* Live reload: setLocale('en') updates every data-i18n element instantly.
@@ -183,13 +184,19 @@ function _applyDOM() {
el.innerHTML = t(key);
});
// data-i18n-attr:TITLE → sets any attribute
// data-i18n-attr:ATTR:key[;ATTR:key…] → sets any attribute(s).
// Single-pair form (data-i18n-attr="title:key") is preserved; multiple
// pairs are separated with ";" (BUG-071: the config TOC toggle needs both
// title and aria-label translated).
document.querySelectorAll('[data-i18n-attr]').forEach(function (el) {
const raw = el.getAttribute('data-i18n-attr');
const colon = raw.indexOf(':');
if (colon === -1) return;
const attr = raw.substring(0, colon);
const key = raw.substring(colon + 1);
el.setAttribute(attr, t(key));
raw.split(';').forEach(function (pair) {
const colon = pair.indexOf(':');
if (colon === -1) return;
const attr = pair.substring(0, colon).trim();
const key = pair.substring(colon + 1).trim();
if (!attr || !key) return;
el.setAttribute(attr, t(key));
});
});
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -197,9 +197,13 @@ function createPaneTabManager(paneId) {
close(tabId) {
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
const closingTab = this._tabs[idx];
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (window.NowPlaying && closingTab) {
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
}
if (this._tabs.length === 0) {
this._activeTabId = null;
// If this pane has no more tabs and isn't the last pane, close it
@@ -826,6 +830,8 @@ const PaneManager = {
grid.className = 'pane-grid';
this._applyGridTemplate(grid, n);
// #110 — keep playing media alive across a pane-grid rebuild.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
wrapper.innerHTML = '';
wrapper.appendChild(grid);
this.panes = [];
@@ -1216,6 +1222,8 @@ const PaneManager = {
if (!grid) return;
const wrapper = grid.parentElement;
const pane0 = this.panes[0];
// #110 — keep playing media alive across a pane collapse.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
wrapper.innerHTML = '';
let tabBar = null, content = null;
if (pane0 && pane0.element) {
+11
View File
@@ -598,6 +598,17 @@ function applyTheme(themeKey, mode) {
// Notify other components of theme change
try {
root.setAttribute('data-theme', mode);
// Syntax highlighting follows the light/dark mode (BUG-078): the theme
// *key* is not a mode, so toggling the sheets by key used to disable both
// and strip every code block of its colours. Sepia/high-contrast reuse the
// light palette.
var darkSheet = document.getElementById('hljs-theme-dark');
var lightSheet = document.getElementById('hljs-theme-light');
if (darkSheet && lightSheet) {
var isDark = mode === 'dark';
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
}
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeKey, mode: mode } }));
} catch(e) {}
}
+29 -10
View File
@@ -137,14 +137,26 @@ export const RightSidebarManager = {
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
const _THEME_MODES = ["dark", "light", "high-contrast", "sepia"];
export function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
// The theme engine (themes.js) owns the CSS variables and the theme *key*;
// the <html data-theme> attribute must carry the *mode* (dark/light/…). Read
// the persisted mode here so the first paint and the highlight.js stylesheet
// are right before the async theme init runs (BUG-078).
let mode = "dark";
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { /* ignore */ }
applyTheme(mode);
}
export function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("obsigate-theme", theme);
let mode = theme;
if (_THEME_MODES.indexOf(mode) === -1) {
// Callers may pass a theme key (legacy): fall back to the persisted mode.
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { mode = "dark"; }
}
const isDark = mode === "dark";
document.documentElement.setAttribute("data-theme", mode);
// Update theme button icon and label
const themeBtn = document.getElementById("theme-toggle");
@@ -152,23 +164,23 @@ export function applyTheme(theme) {
if (themeBtn && themeLabel) {
const icon = themeBtn.querySelector("i");
if (icon) {
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
icon.setAttribute("data-lucide", isDark ? "moon" : "sun");
}
themeLabel.textContent = theme === "dark" ? t('theme.dark') : t('theme.light');
themeLabel.textContent = isDark ? t('theme.dark') : t('theme.light');
safeCreateIcons();
}
// Swap highlight.js theme
// Swap highlight.js theme — keyed on the mode, not the theme key (BUG-078).
const darkSheet = document.getElementById("hljs-theme-dark");
const lightSheet = document.getElementById("hljs-theme-light");
if (darkSheet && lightSheet) {
darkSheet.disabled = theme !== "dark";
lightSheet.disabled = theme !== "light";
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
}
// Update Mermaid theme for newly rendered diagrams
import('./mermaid-viewer.js').then(function(m) {
m.updateMermaidTheme(theme === 'dark');
m.updateMermaidTheme(isDark);
}).catch(function() {});
}
@@ -2080,11 +2092,16 @@ export const TabManager = {
}
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
const closingTab = this._tabs[idx];
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (window.NowPlaying && closingTab) {
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
}
if (this._tabs.length === 0) {
this._activeTabId = null;
this._showDashboard();
@@ -2207,6 +2224,8 @@ export const TabManager = {
_showDashboard() {
const area = document.getElementById("content-area");
// #110 — move any playing media to the persistent dock before wiping.
if (window.NowPlaying && area) window.NowPlaying.handleRender(area, null);
// Save dashboard DOM before clearing (it may have been removed from DOM by renderFile)
let dashboard = document.getElementById("dashboard-home");
if (!dashboard) {
+38 -29
View File
@@ -201,37 +201,39 @@ const EXT_ICONS = {
".tex": "file-text",
".latex": "file-text",
// Image files
".png": "file-image",
".jpg": "file-image",
".jpeg": "file-image",
".gif": "file-image",
".svg": "file-image",
".webp": "file-image",
".bmp": "file-image",
".ico": "file-image",
".tiff": "file-image",
".tif": "file-image",
// Image files (roadmap #108-D1)
".png": "image",
".jpg": "image",
".jpeg": "image",
".gif": "image",
".svg": "image",
".webp": "image",
".bmp": "image",
".ico": "image",
".tiff": "image",
".tif": "image",
// Audio files
".mp3": "file-music",
".wav": "file-music",
".flac": "file-music",
".aac": "file-music",
".ogg": "file-music",
".m4a": "file-music",
".wma": "file-music",
// Audio files (roadmap #109-B2)
".mp3": "audio-lines",
".wav": "audio-lines",
".flac": "audio-lines",
".aac": "audio-lines",
".ogg": "audio-lines",
".oga": "audio-lines",
".opus": "audio-lines",
".m4a": "audio-lines",
".wma": "audio-lines",
// Video files
".mp4": "play",
".avi": "play",
".mov": "play",
".wmv": "play",
".flv": "play",
".webm": "play",
".mkv": "play",
".m4v": "play",
".3gp": "play",
// Video files (roadmap #109-B2)
".mp4": "video",
".avi": "video",
".mov": "video",
".wmv": "video",
".flv": "video",
".webm": "video",
".mkv": "video",
".m4v": "video",
".3gp": "video",
// Archive files
".zip": "file-archive",
@@ -693,6 +695,13 @@ async function reloadExternalWrite(vault, path, force = false) {
}
// Not editing: refresh the read view when it shows the written document.
if (state.currentVault === vault && state.currentPath === path) {
// Excalidraw owns its iframe: re-rendering would recreate it (visible page
// refresh) and discard the in-editor scene. Its own save already persisted
// the file, so there is nothing to reload here.
const openExcalidraw = Array.from(
document.querySelectorAll("iframe[data-excalidraw-path]")
).some((f) => f.dataset.excalidrawVault === vault && f.dataset.excalidrawPath === path);
if (openExcalidraw) return;
_invalidateActiveTabCache(vault, path);
openFile(vault, path);
}
+519 -22
View File
@@ -14,6 +14,7 @@ import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
import { onFileRender } from './plugins.js';
import { NowPlaying } from './now-playing.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -518,13 +519,502 @@ function applyPrettyHighlight(codeEl, lang, text) {
}
}
/**
* Jump the inline PDF viewer to a specific page.
*
* The browser's built-in PDF viewer lives in an ``about:blank`` content window
* (so ``contentWindow.location.hash`` never reaches the document) **and** it
* ignores a same-document fragment navigation: changing only ``#page=N`` on the
* iframe ``src`` does not move the page. A changing query parameter forces a
* real reload, and the ``#page=N`` fragment is then honoured at load — the only
* reliable way to target a page with the native viewer.
*
* @param {HTMLElement} area - Content area containing the ``.pdf-iframe``.
* @param {string|number} page - 1-based page number from the PDF outline.
*/
export function navigatePdfToPage(area, page) {
const iframe = area && area.querySelector('.pdf-iframe');
if (!iframe || page === null || page === undefined || page === '') return;
const base = iframe.getAttribute('data-pdf-url') || iframe.src.split('#')[0];
iframe.setAttribute('data-pdf-url', base);
const sep = base.includes('?') ? '&' : '?';
iframe.src = `${base}${sep}_pdfpage=${Date.now()}#page=${page}`;
}
// ---------------------------------------------------------------------------
// Image viewer (roadmap #108-D)
// ---------------------------------------------------------------------------
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]);
const IMAGE_ZOOM_MIN = 0.1;
const IMAGE_ZOOM_MAX = 8;
let _imageViewerCleanup = null;
// BUG-072 — lightbox / metadata-panel state, reset as soon as a non-image file
// is rendered. #111 — image→image navigation now happens **in place**, so the
// viewer (and these states) is no longer destroyed between siblings.
const _imageViewerState = { lightbox: false, meta: false };
/** Clamp a zoom factor into the supported [0.1, 8] range. */
export function clampImageZoom(value) {
if (!Number.isFinite(value)) return 1;
return Math.min(IMAGE_ZOOM_MAX, Math.max(IMAGE_ZOOM_MIN, value));
}
/** True when *p* has a viewable image extension. */
export function isImagePath(p) {
const lower = (p || "").toLowerCase();
const dot = lower.lastIndexOf(".");
return dot !== -1 && IMAGE_EXTS.has(lower.slice(dot));
}
/** Build the byte-serving URL used by <img> / thumbnails. */
export function buildImageUrl(vault, path) {
return `/api/image/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function buildThumbUrl(vault, path, size) {
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
}
// #111 — directory listing cache. Opening / switching to another image must not
// hammer `/api/browse` (the filmstrip and the sibling list share one listing).
const _imageDirCache = new Map();
const IMAGE_DIR_CACHE_TTL = 15000;
/** List the viewable image siblings of *dir*, memoised for a short TTL. */
async function listImageSiblings(vault, dir) {
const key = `${vault}\u0000${dir}`;
const cached = _imageDirCache.get(key);
if (cached && Date.now() - cached.ts < IMAGE_DIR_CACHE_TTL) return cached.items;
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
const items = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
_imageDirCache.set(key, { items, ts: Date.now() });
return items;
}
const IMAGE_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".bmp": "image/bmp",
".ico": "image/x-icon",
};
/** Best-effort MIME type for an image path (metadata panel). */
function imageMimeFor(path) {
const lower = (path || "").toLowerCase();
const dot = lower.lastIndexOf(".");
return dot === -1 ? "" : IMAGE_MIME[lower.slice(dot)] || "";
}
function formatBytes(n) {
if (!n && n !== 0) return "";
if (n < 1024) return `${n} o`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} Ko`;
return `${(n / 1048576).toFixed(1)} Mo`;
}
/**
* Render a full image viewer: centered image, wheel zoom (0.1×–8×), drag pan,
* double-click reset, ←/→ navigation between siblings, thumbnail filmstrip,
* collapsible metadata panel and a full-viewport lightbox.
*/
export function renderImageViewer(area, data) {
const vault = data.vault;
// #111 — mutable current-image state: navigating swaps these in place instead
// of re-rendering the whole viewer (which used to refetch the file + listing).
let currentPath = data.path;
let currentTitle = data.title || (currentPath || "").split("/").pop();
let imgUrl = buildImageUrl(vault, currentPath);
let currentMeta = {
image_mime: data.image_mime || imageMimeFor(currentPath),
size_bytes: data.size_bytes,
modified: data.modified,
};
area.innerHTML = "";
const container = el("div", { class: "image-viewer-container", tabindex: "0" });
// ── Toolbar ────────────────────────────────────────────────────────────
const toolbar = el("div", { class: "image-toolbar" });
const titleEl = el("span", { class: "image-title", title: currentPath }, [
document.createTextNode(currentTitle),
]);
toolbar.appendChild(titleEl);
toolbar.appendChild(el("div", { class: "image-toolbar-spacer" }));
const counter = el("span", { class: "image-counter", hidden: true }, [document.createTextNode("")]);
toolbar.appendChild(counter);
const zoomBadge = el("span", { class: "image-zoom-badge" }, [document.createTextNode("100%")]);
toolbar.appendChild(zoomBadge);
const mkBtn = (iconName, label, cls) => {
const b = el("button", { class: `btn-action${cls ? " " + cls : ""}`, type: "button", title: label, "aria-label": label }, [icon(iconName, 14)]);
return b;
};
const zoomOutBtn = mkBtn("zoom-out", t("viewer.image_zoom_out"));
const zoomInBtn = mkBtn("zoom-in", t("viewer.image_zoom_in"));
const zoomResetBtn = mkBtn("rotate-ccw", t("viewer.image_zoom_reset"));
const prevBtn = mkBtn("chevron-left", t("viewer.image_prev"));
const nextBtn = mkBtn("chevron-right", t("viewer.image_next"));
const originalBtn = mkBtn("external-link", t("viewer.image_open_original"));
const downloadBtn = mkBtn("download", t("viewer.download"));
const metaBtn = mkBtn("info", t("viewer.image_metadata"), "image-btn-metadata");
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"), "image-btn-lightbox");
[prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn]
.forEach((b) => toolbar.appendChild(b));
container.appendChild(toolbar);
// ── Body: image stage + metadata sidebar (right) ───────────────────────
const body = el("div", { class: "image-viewer-body" });
const stage = el("div", { class: "image-stage" });
const img = el("img", { class: "image-main", src: imgUrl, alt: currentTitle, draggable: "false" });
stage.appendChild(img);
// ── Overlay arrows along the frame edges (#111) ────────────────────────
const prevArrow = el("button", {
class: "image-nav-arrow image-nav-arrow-prev",
type: "button",
title: t("viewer.image_prev"),
"aria-label": t("viewer.image_prev"),
hidden: true,
}, [icon("chevron-left", 32)]);
const nextArrow = el("button", {
class: "image-nav-arrow image-nav-arrow-next",
type: "button",
title: t("viewer.image_next"),
"aria-label": t("viewer.image_next"),
hidden: true,
}, [icon("chevron-right", 32)]);
stage.appendChild(prevArrow);
stage.appendChild(nextArrow);
body.appendChild(stage);
// ── Metadata sidebar (kept open across ←/→ navigation) ─────────────────
const metaPanel = el("div", { class: "image-meta-panel" });
metaPanel.hidden = !_imageViewerState.meta;
body.appendChild(metaPanel);
container.appendChild(body);
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
// #112 — the film scrolls with the mouse wheel and two translucent edge
// arrows; the scrollable strip is wrapped so the arrows stay fixed.
const navWrap = el("div", { class: "image-nav", hidden: true });
const strip = el("div", { class: "image-nav-strip" });
const stripPrev = el("button", {
class: "image-strip-arrow image-strip-arrow-prev",
type: "button",
title: t("viewer.image_strip_prev"),
"aria-label": t("viewer.image_strip_prev"),
}, [icon("chevron-left", 20)]);
const stripNext = el("button", {
class: "image-strip-arrow image-strip-arrow-next",
type: "button",
title: t("viewer.image_strip_next"),
"aria-label": t("viewer.image_strip_next"),
}, [icon("chevron-right", 20)]);
navWrap.appendChild(strip);
navWrap.appendChild(stripPrev);
navWrap.appendChild(stripNext);
container.appendChild(navWrap);
let scale = 1;
let tx = 0;
let ty = 0;
const applyTransform = () => {
img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
zoomBadge.textContent = `${Math.round(scale * 100)}%`;
};
const resetView = () => { scale = 1; tx = 0; ty = 0; applyTransform(); };
const setZoom = (next) => {
scale = clampImageZoom(next);
if (scale === 1) { tx = 0; ty = 0; }
applyTransform();
};
const renderMeta = () => {
const rows = [
[t("viewer.image_type"), currentMeta.image_mime || ""],
[t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""],
[t("viewer.metadata_size"), formatBytes(currentMeta.size_bytes)],
[t("viewer.metadata_path"), currentPath],
];
if (currentMeta.modified) rows.push([t("viewer.metadata_modified"), currentMeta.modified]);
metaPanel.innerHTML = "";
const dl = el("dl", {});
rows.forEach(([k, v]) => {
dl.appendChild(el("dt", {}, [document.createTextNode(k)]));
dl.appendChild(el("dd", {}, [document.createTextNode(v || "—")]));
});
metaPanel.appendChild(dl);
};
// ── Sibling navigation (in place, #111) ────────────────────────────────
let siblings = [];
let currentIndex = -1;
let thumbs = [];
const preloaded = new Set();
const preload = (i) => {
if (!siblings.length) return;
const it = siblings[(i % siblings.length + siblings.length) % siblings.length];
if (!it || preloaded.has(it.path)) return;
preloaded.add(it.path);
const pre = new Image();
pre.src = buildImageUrl(vault, it.path);
};
const updateStripActive = () => {
thumbs.forEach((thumb, i) => thumb.classList.toggle("active", i === currentIndex));
const active = thumbs[currentIndex];
if (!active) return;
// Scroll the filmstrip only (never the document) to reveal the active thumb.
const left = active.offsetLeft;
const right = left + active.offsetWidth;
if (left < strip.scrollLeft) strip.scrollLeft = Math.max(0, left - 8);
else if (right > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = right - strip.clientWidth + 8;
};
const updateArrows = () => {
const multi = siblings.length > 1;
prevArrow.hidden = nextArrow.hidden = prevBtn.disabled = nextBtn.disabled = !multi;
prevArrow.disabled = nextArrow.disabled = !multi;
counter.hidden = !multi;
counter.textContent = multi ? `${currentIndex + 1} / ${siblings.length}` : "";
};
/** Swap to the sibling at *index* without rebuilding the viewer. */
const showSibling = (index) => {
const item = siblings[index];
if (!item || item.path === currentPath) return;
currentIndex = index;
currentPath = item.path;
currentTitle = item.name;
imgUrl = buildImageUrl(vault, currentPath);
currentMeta = {
image_mime: imageMimeFor(currentPath),
size_bytes: item.size,
modified: null,
};
scale = 1; tx = 0; ty = 0;
applyTransform();
img.style.display = "";
const errEl = stage.querySelector(".image-error");
if (errEl) errEl.remove();
img.alt = currentTitle;
img.src = imgUrl;
titleEl.textContent = currentTitle;
titleEl.title = currentPath;
updateStripActive();
updateArrows();
state.currentVault = vault;
state.currentPath = currentPath;
try { syncActiveFileTreeItem(vault, currentPath); } catch (_) { /* best-effort */ }
preload(index - 1);
preload(index + 1);
if (!metaPanel.hidden) renderMeta();
};
const go = (delta) => {
if (siblings.length < 2 || currentIndex < 0) return;
showSibling((currentIndex + delta + siblings.length) % siblings.length);
};
const renderStrip = () => {
if (siblings.length < 2) { navWrap.hidden = true; return; }
navWrap.hidden = false;
strip.innerHTML = "";
thumbs = siblings.map((s, i) => {
const thumb = el("img", {
class: `image-thumb${i === currentIndex ? " active" : ""}`,
src: buildThumbUrl(vault, s.path, 96),
alt: s.name,
title: s.name,
loading: "lazy",
decoding: "async",
});
thumb.addEventListener("click", () => showSibling(i));
strip.appendChild(thumb);
return thumb;
});
updateStripActive();
};
// #112 — scroll the film with the wheel (vertical → horizontal) and the
// two overlay arrows; the active image never changes, only the strip view.
const scrollStrip = (dir) => {
strip.scrollBy({ left: dir * Math.max(140, strip.clientWidth * 0.8), behavior: "smooth" });
};
stripPrev.addEventListener("click", () => scrollStrip(-1));
stripNext.addEventListener("click", () => scrollStrip(1));
strip.addEventListener("wheel", (e) => {
if (strip.scrollWidth <= strip.clientWidth) return;
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
strip.scrollLeft += delta;
e.preventDefault();
}, { passive: false });
(async () => {
try {
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
siblings = await listImageSiblings(vault, dir);
currentIndex = siblings.findIndex((it) => it.path === currentPath);
renderStrip();
updateArrows();
preload(currentIndex - 1);
preload(currentIndex + 1);
} catch (_) { /* navigation is best-effort */ }
})();
// ── Interactions ───────────────────────────────────────────────────────
stage.addEventListener("wheel", (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
setZoom(scale * factor);
}, { passive: false });
const isArrow = (e) => !!(e.target && e.target.closest && e.target.closest(".image-nav-arrow"));
let dragging = false;
let startX = 0;
let startY = 0;
let startTx = 0;
let startTy = 0;
stage.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || isArrow(e)) return;
dragging = true;
startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty;
stage.classList.add("panning");
if (stage.setPointerCapture) stage.setPointerCapture(e.pointerId);
});
stage.addEventListener("pointermove", (e) => {
if (!dragging) return;
tx = startTx + (e.clientX - startX);
ty = startTy + (e.clientY - startY);
applyTransform();
});
const endDrag = (e) => {
dragging = false;
stage.classList.remove("panning");
if (stage.releasePointerCapture && e.pointerId != null) {
try { stage.releasePointerCapture(e.pointerId); } catch (_) { /* already released */ }
}
};
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
stage.addEventListener("dblclick", (e) => { if (!isArrow(e)) resetView(); });
const stopArrowPointer = (e) => e.stopPropagation();
prevArrow.addEventListener("pointerdown", stopArrowPointer);
nextArrow.addEventListener("pointerdown", stopArrowPointer);
prevArrow.addEventListener("dblclick", stopArrowPointer);
nextArrow.addEventListener("dblclick", stopArrowPointer);
prevArrow.addEventListener("click", (e) => { e.stopPropagation(); go(-1); });
nextArrow.addEventListener("click", (e) => { e.stopPropagation(); go(1); });
zoomInBtn.addEventListener("click", () => setZoom(scale * 1.25));
zoomOutBtn.addEventListener("click", () => setZoom(scale / 1.25));
zoomResetBtn.addEventListener("click", resetView);
prevBtn.addEventListener("click", () => go(-1));
nextBtn.addEventListener("click", () => go(1));
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
downloadBtn.addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
window.open(dlUrl, "_blank");
});
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
metaBtn.addEventListener("click", () => {
_imageViewerState.meta = !_imageViewerState.meta;
metaPanel.hidden = !_imageViewerState.meta;
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
if (_imageViewerState.meta) renderMeta();
});
const syncLightbox = () => {
container.classList.toggle("lightbox", _imageViewerState.lightbox);
lightboxBtn.setAttribute("aria-pressed", _imageViewerState.lightbox ? "true" : "false");
};
syncLightbox();
lightboxBtn.addEventListener("click", () => {
_imageViewerState.lightbox = !_imageViewerState.lightbox;
syncLightbox();
});
img.addEventListener("load", () => {
img.style.display = "";
const errEl = stage.querySelector(".image-error");
if (errEl) errEl.remove();
if (!metaPanel.hidden) renderMeta();
});
img.addEventListener("error", () => {
img.style.display = "none";
if (!stage.querySelector(".image-error")) {
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
}
});
const onKey = (e) => {
if (e.key === "ArrowLeft") { e.preventDefault(); go(-1); }
else if (e.key === "ArrowRight") { e.preventDefault(); go(1); }
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(scale * 1.25); }
else if (e.key === "-") { e.preventDefault(); setZoom(scale / 1.25); }
else if (e.key === "0") { e.preventDefault(); resetView(); }
else if (e.key === "Escape") {
_imageViewerState.lightbox = false;
syncLightbox();
}
};
document.addEventListener("keydown", onKey);
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
area.appendChild(container);
safeCreateIcons();
applyTransform();
if (_imageViewerState.meta) renderMeta();
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
}
// #110 — Audio/video are handled by the global Now Playing controller
// (frontend/js/now-playing.js): a single shared media element is teleported
// between this inline view and a body-level dock, so playback survives tab,
// pane and page navigation. The inline branches below just hand over the
// surface; the legacy "stop on re-render" cleanup no longer applies.
export { formatMediaDuration, buildMediaUrl, renderFallback as renderMediaFallback } from "./now-playing.js";
/** Audio viewer (roadmap #109-B, made persistent by #110). */
export function renderAudioViewer(area, data) {
NowPlaying.attachInline(area, data);
}
/** Video viewer (roadmap #109-C, made persistent by #110). */
export function renderVideoViewer(area, data) {
NowPlaying.attachInline(area, data);
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
// instead of being wiped mid-session by a tab switch / sidebar click.
if (isInlineEditorActive()) detachInlineEditor();
// #108 — release the image viewer's document-level shortcuts before swapping
// the content area (otherwise they leak on every re-render).
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
// BUG-072 / #111 — any render that is not an image viewer starts from a clean
// viewer. Image→image navigation now happens in place (see renderImageViewer),
// so the viewer is never rebuilt between siblings.
if (!data.is_image) {
_imageViewerState.lightbox = false;
_imageViewerState.meta = false;
}
const area = getContentArea();
// #110 — if this render is about to replace the surface that currently hosts
// the shared media element, hand it back to the persistent dock first.
NowPlaying.handleRender(area, data);
// Handle PDF files — render in iframe with TOC sidebar
if (data.is_pdf) {
@@ -537,7 +1027,7 @@ export function renderFile(data) {
tocHtml = '<div class="pdf-toc"><h3>Table des matières</h3><ul>';
for (const item of toc) {
const indent = (item.level - 1) * 16;
tocHtml += `<li style="padding-left:${indent}px"><a href="#" onclick="document.querySelector('.pdf-iframe').contentWindow.location.hash='page=${item.page}';return false">${escapeHtml(item.title)}</a> <span class="toc-page">p.${item.page}</span></li>`;
tocHtml += `<li style="padding-left:${indent}px"><a href="#" data-page="${item.page}">${escapeHtml(item.title)}</a> <span class="toc-page">p.${item.page}</span></li>`;
}
tocHtml += '</ul></div>';
}
@@ -555,9 +1045,15 @@ export function renderFile(data) {
</div>
<div class="pdf-body">
${tocHtml}
<iframe src="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
<iframe src="${pdfUrl}" data-pdf-url="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
</div>
</div>`;
area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
navigatePdfToPage(area, link.getAttribute('data-page'));
});
});
lucide.createIcons();
return;
}
@@ -568,25 +1064,19 @@ export function renderFile(data) {
return;
}
// Handle images
// Handle images — dedicated zoom/pan viewer (roadmap #108-D)
if (data.is_image) {
const imgUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
area.innerHTML = `
<div class="image-viewer-container">
<div class="file-toolbar">
<span class="file-info">${escapeHtml(data.title)}</span>
<button class="btn-action" onclick="window.open('${imgUrl}', '_blank')">
<i data-lucide="maximize" style="width:14px;height:14px"></i> Plein écran
</button>
<button class="btn-action" onclick="window.open('/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}', '_blank')">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>
<div class="image-viewer-body">
${data.html}
</div>
</div>`;
lucide.createIcons();
renderImageViewer(area, data);
return;
}
// Handle audio / video — native HTML5 players (roadmap #109)
if (data.is_audio) {
renderAudioViewer(area, data);
return;
}
if (data.is_video) {
renderVideoViewer(area, data);
return;
}
@@ -601,7 +1091,7 @@ export function renderFile(data) {
<div class="unsupported-file">
<i data-lucide="file" style="width:48px;height:48px"></i>
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
<div>Ce fichier est binaire et ne peut pas être affiché.</div>
<div>${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}</div>
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
<button class="btn-action" id="unsupported-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
@@ -928,7 +1418,12 @@ 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" }, fileActions)]));
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv]));
// #115 — the action bar is a direct child of the scroll container (wrapped in
// `.file-toolbar`) so it can stay pinned while long documents scroll; nested
// inside `.file-header` it would stop sticking as soon as the header left the
// viewport.
area.appendChild(el("div", { class: "file-toolbar" }, [el("div", { class: "file-actions" }, fileActions)]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
area.appendChild(rawDiv);
@@ -1266,6 +1761,8 @@ export function showWelcome() {
// Restore or rebuild the dashboard with tabbed sections
const area = getContentArea();
// #110 — keep playing media alive if the dashboard replaces its surface.
NowPlaying.handleRender(area, null);
const home = document.getElementById("dashboard-home");
if (area && !home) {
+270 -10
View File
@@ -80,12 +80,16 @@
"admin.users_title": "User management",
"ai.action_applied": "Applied ✓",
"ai.action_apply": "Apply",
"ai.action_apply_all": "Approve all ({count})",
"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.confirm_actions": "{count} actions to approve",
"ai.stop": "Stop the assistant",
"ai.stopped": "Run stopped.",
"ai.agent_mode_off": "Agent mode off (read/search + actions)",
"ai.agent_mode_on": "Agent mode on (read/search tools + actions)",
"ai.casual": "Casual tone",
@@ -363,6 +367,14 @@
"config.ai_keys_desc": "Configure API keys for the AI editor.",
"config.ai_model": "Model",
"config.ai_openrouter_label": "OpenRouter API Key",
"config.ai_header_desc": "Configure your API keys provider by provider. Expand a card to enter a key, then click Test to load the models.",
"config.ai_search_placeholder": "Search a provider…",
"config.ai_default_section": "Default configuration",
"config.ai_providers_title": "API providers",
"config.ai_providers_empty": "No matching provider",
"config.ai_status_configured": "Configured",
"config.ai_status_not_configured": "Not configured",
"config.ai_delete_key_title": "Delete the API key",
"config.api_keys_saved": "API keys saved",
"config.section_sources": "🔗 Connected sources & search",
"config.sources_desc": "Keys used by the AI assistant tools (keyed web search: Tavily, Brave, SerpAPI, Exa; connected sources: Gitea, GitHub). They are stored server-side and take precedence over environment variables.",
@@ -372,6 +384,16 @@
"config.delete_key": "Delete",
"config.delete_key_confirm": "Delete key",
"config.key_deleted": "Key deleted:",
"config.avatar_label": "Profile picture",
"config.avatar_hint": "PNG, JPG or WEBP — square image cropped and resized to 256 px. Shown in the sidebar.",
"config.avatar_presets_label": "Or pick a preset avatar",
"config.avatar_choose": "Choose an image",
"config.avatar_remove": "Remove picture",
"config.avatar_updated": "Profile picture updated",
"config.avatar_removed": "Profile picture removed",
"config.avatar_too_large": "Image too large (8 MB max)",
"config.avatar_invalid_type": "Unsupported format — use PNG, JPG or WEBP",
"config.avatar_upload_failed": "Failed to save the profile picture",
"config.backups": "Backups",
"config.backups_desc": "Manage automatic file backups.",
"config.client_config": "Client config",
@@ -488,7 +510,7 @@
"config.section_fonctionnalites": "Features",
"config.section_format-du-payload": "Format du payload",
"config.section_gestion-des-onglets": "📑 Gestion des onglets",
"config.section_hidden": "Hidden files",
"config.section_hidden": "🗂️ Hidden files",
"config.section_historique-recent-redemarrage-non-requis": "📋 Recent History\n No restart required",
"config.section_indicateurs-visuels": "Indicateurs visuels",
"config.section_intelligence-artificielle-dans-l-editeur": "🤖 AI in the Editor",
@@ -561,9 +583,39 @@
"config.test": "Test",
"config.timeout_label": "Search timeout (ms)",
"config.title": "Settings",
"config.toc_close": "Close contents",
"config.toc_toggle": "Show contents",
"config.title_boost": "Title boost",
"config.title_boost_hint": "Relevance multiplier for title matches",
"config.title_boost_label": "Title boost",
"config.nav_tokens": "🔑 API & MCP keys",
"config.section_tokens": "🔑 API & MCP keys",
"config.tokens_desc": "Long-lived tokens for the REST API and the MCP server — the same key works for both (Authorization: Bearer header).",
"config.tokens_empty": "No API keys created.",
"config.token_name_placeholder": "Name (e.g. Claude Desktop)",
"config.token_name_required": "A name is required",
"config.token_expiry_1d": "1 day",
"config.token_expiry_30d": "1 month",
"config.token_expiry_180d": "6 months",
"config.token_expiry_365d": "1 year",
"config.token_expiry_never": "Never",
"config.token_create": "Create key",
"config.token_secret_warning": "Copy this key now — it will never be shown again.",
"config.token_copy": "Copy",
"config.token_copied": "Key copied to clipboard",
"config.token_done": "Done",
"config.token_usage": "Usage",
"config.token_usage_detail": ": \"Authorization: Bearer <key>\" header on the API; for MCP, declare it in the headers of the /mcp URL.",
"config.token_created": "Created",
"config.token_expires": "Expires",
"config.token_last_used": "Last used",
"config.token_never_used": "Never used",
"config.token_status_active": "Active",
"config.token_status_expired": "Expired",
"config.token_revoke": "Revoke",
"config.token_revoke_confirm": "Revoke key",
"config.token_revoked_toast": "Key revoked (immediate effect on API + MCP)",
"config.token_created_toast": "Key created",
"config.url_required": "URL required",
"config.watcher_debounce_label": "Debounce (s)",
"config.watcher_enabled_label": "Enable watcher",
@@ -740,6 +792,7 @@
"header.menu_theme": "Theme",
"header.menu_theme_light": "Light",
"header.menu_toggle": "Menu",
"header.menu_version": "Version",
"header.profile": "Profile",
"header.quick_help": "Quick Help",
"header.refresh": "Refresh",
@@ -1069,7 +1122,7 @@
"help.desc_markdown_all": ": Full markdown rendering",
"help.desc_md_render": "Markdown files are rendered with:",
"help.desc_menu_header": ": \"Vault\" dropdown in the Options menu",
"help.desc_menu_options": ": Access to settings, theme, help",
"help.desc_menu_options": ": Access to settings, theme, help and version",
"help.desc_op_ext": ": Filter by file type",
"help.desc_op_path": ": Filter by path",
"help.desc_op_phrase": ": Exact phrase search",
@@ -1272,6 +1325,8 @@
"help.sidebar_filtering": "Sidebar filtering",
"help.sidebar_section": "Sidebar",
"help.sidebar_tabs": "The sidebar has two tabs:",
"help.sidebar_user": "Account",
"help.desc_sidebar_user": ": Avatar, name, role and sign-out at the bottom of the sidebar",
"help.sidebar_toggle_btn": "Sidebar toggle button",
"help.simple": "Simple",
"help.simple_search": "Simple search",
@@ -1302,6 +1357,7 @@
"help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.",
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
"help.assistant_agent_run": "Actions appear in the thread: the assistant groups modifications into a single approval (\"Approve all\") and refreshes the file tree and the open document as soon as they are applied. The send button becomes \"Stop\" to interrupt the run at any time.",
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
"help.assistant_slash": "Type / to run a skill (research, summary, correction, plan…) or an admin command (/help, /providers, /model, /keys).",
@@ -1471,6 +1527,76 @@
"pwa.install_button": "Install",
"pwa.install_desc": "Install this app on your device for quick access.",
"pwa.install_title": "Install ObsiGate",
"qa.all_actions": "All actions",
"qa.audit_code": "Detect bugs and potential flaws",
"qa.audit_code.prompt": "Identify potential bugs, unhandled edge cases and security flaws in this code, with proposed fixes.",
"qa.backlinks": "Suggest vault links and backlinks",
"qa.backlinks.prompt": "Suggest relevant [[wikilinks]] to other notes in the vault and backlinks to add to this document.",
"qa.badge_code": "Code file",
"qa.badge_directory": "Directory",
"qa.badge_general": "General mode",
"qa.badge_multi": "{count} docs open",
"qa.badge_selection": "Active selection",
"qa.badge_single": "1 doc open",
"qa.capabilities": "What can you do?",
"qa.capabilities.prompt": "What can you do? Present your capabilities on this vault.",
"qa.cat_code": "Code & Scripts",
"qa.cat_cross": "Cross-documents",
"qa.cat_edition": "Editing & Rewriting",
"qa.cat_general": "Assistant",
"qa.cat_structure": "Productivity & Structuring",
"qa.cat_synthesis": "Synthesis & Analysis",
"qa.checklist": "Extract the action checklist",
"qa.checklist.prompt": "Extract every concrete action to take as a Markdown to-do list with [ ] checkboxes.",
"qa.compare": "Compare differences and convergences",
"qa.compare.prompt": "Compare all open documents and summarise their convergences, divergences and oppositions.",
"qa.concise": "Make it more concise and punchy",
"qa.concise.prompt": "Rewrite the selection to make it more concise and punchy without losing the essentials.",
"qa.create_note": "Create a meeting note",
"qa.create_note.prompt": "Create a meeting notes file in the vault.",
"qa.doc_code": "Add documentation and types",
"qa.doc_code.prompt": "Add the appropriate docstrings, JSDoc and type annotations to every function in this file.",
"qa.drawer_title": "Action library",
"qa.empty_hint": "Pick a context-aware quick action, or ask a question directly.",
"qa.explain_code": "Explain the script logic",
"qa.explain_code.prompt": "Analyse and explain step by step the structure and algorithm of this source code.",
"qa.explain_selection": "Explain the selection",
"qa.explain_selection.prompt": "Explain the selected passage: its role, its context and what it implies.",
"qa.faq": "Generate a FAQ / key questions",
"qa.faq.prompt": "Generate a list of 5 key questions and answers to check comprehension of this text.",
"qa.fix_style": "Fix and improve the style",
"qa.fix_style.prompt": "Fix spelling and grammar mistakes and improve the syntactic flow of this passage.",
"qa.frictions": "Spot frictions and contradictions",
"qa.frictions.prompt": "Analyse this document and point out inconsistencies, blind spots or contradictions.",
"qa.frontmatter": "Generate the YAML frontmatter",
"qa.frontmatter.prompt": "Generate a complete YAML frontmatter block in the vault's format and apply it to the open document (insert it at the top of the file, or replace the existing block, keeping non-empty values already present): titre, auteur, creation_date and modification_date in ISO-8601 with timezone, catégorie, tags (inline list [a, b]), aliases, status, publish, favoris, template, task, archive, draft, private (booleans), NomDeVoute (current vault name), Description (a one-sentence summary of the content).",
"qa.frontmatter_update": "Update the frontmatter",
"qa.frontmatter_update.prompt": "Update the YAML frontmatter of the open document without deleting existing fields: refresh modification_date (current ISO-8601 timestamp with timezone), recompute titre, tags, aliases, catégorie, NomDeVoute and Description from the current content, fill in any missing metadata field (auteur, creation_date, status, publish, favoris, template, task, archive, draft, private) and apply the change to the file.",
"qa.header_suggested": "Suggested actions",
"qa.memo": "Write a shareable executive memo",
"qa.memo.prompt": "Write a shareable executive memo based on this document: context, findings, recommendations.",
"qa.merge": "Merge into one synthesis note",
"qa.merge.prompt": "Merge the essential elements of all open documents into one unified, flowing synthesis note.",
"qa.no_match": "No matching action.",
"qa.plan": "Create a step-by-step action plan",
"qa.plan.prompt": "Turn this content into a step-by-step action plan with priorities and estimates.",
"qa.rephrase": "Rephrase this passage",
"qa.rephrase.prompt": "Rephrase this passage keeping the meaning but using different wording.",
"qa.search_help": "Search my notes effectively",
"qa.search_help.prompt": "How do I search my notes effectively?",
"qa.search_placeholder": "Search an action...",
"qa.sections": "Structure into hierarchical sections",
"qa.sections.prompt": "Restructure this document with a logical hierarchy of Markdown headings (H2, H3) and clean bullet lists.",
"qa.summarize_3": "Summarize in 3 key points",
"qa.summarize_3.prompt": "Provide a concise summary of this document in 3 clear key points.",
"qa.test_code": "Generate unit tests",
"qa.test_code.prompt": "Write a unit test suite covering nominal and error cases for this code.",
"qa.timeline": "Build a cross-document timeline",
"qa.timeline.prompt": "Build a cross-document timeline of the dated events mentioned in these documents.",
"qa.translate": "Translate the selection",
"qa.translate.prompt": "Translate this passage into the appropriate language (English if the text is in French, and vice versa).",
"qa.vulgarize": "Plain-language explainer",
"qa.vulgarize.prompt": "Explain the content of this document in simple, accessible language without jargon.",
"search.advanced_operators": "Advanced operators",
"search.aria_label": "Search suggestions",
"search.case_sensitive": "Case sensitive",
@@ -1512,25 +1638,21 @@
"search.whole_word": "Whole word",
"settings.about": "📦 About",
"settings.ai": "🤖 AI",
"settings.backend": "⚙️ Backend",
"settings.backend_hint": "These settings are saved on the server. Some require a restart or reindexing.",
"settings.backend_section": "Backend Settings",
"settings.client_label": "These settings apply immediately on the client side.",
"settings.diagnostics": "🩺 Diagnostics",
"settings.explorer": "Files",
"settings.history_count_desc": "Between 5 and 100 files (saved on server)",
"settings.history_count_label": "Files in history",
"settings.history_section": "Recent History",
"settings.no_restart_badge": "No restart needed",
"settings.profile": "👤 Profile",
"settings.reindex": "Force reindex",
"settings.restart_badge": "Restart required",
"settings.save": "Save",
"settings.search": "Search",
"settings.tabs": "Tabs",
"settings.search_placeholder": "Search...",
"settings.sync": "🔄 Sync",
"settings.tabs": "Tabs",
"settings.themes": "🎨 Themes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Link copied!",
"share.copy_link": "Copy link",
"share.create": "Create share link",
@@ -1575,6 +1697,8 @@
"sidebar.tab_saved": "Searches",
"sidebar.tab_tags": "Tags",
"sidebar.tab_vaults": "Vaults",
"sidebar.user_role_admin": "Administrator",
"sidebar.user_role_user": "User",
"sidebar.vault_context_all": "All vaults",
"sidebar.vault_specific": "Specific vault",
"sync.autocomplete": "Autocomplete",
@@ -1716,11 +1840,26 @@
"viewer.export_title": "Export document",
"viewer.forge_brand": "Forge",
"viewer.forge_title": "Forge (new editor)",
"viewer.image_dimensions": "Dimensions",
"viewer.image_error": "Unable to load the image",
"viewer.image_fullscreen": "Full screen (lightbox)",
"viewer.image_metadata": "Metadata",
"viewer.image_next": "Next image",
"viewer.image_open_original": "Open original",
"viewer.image_prev": "Previous image",
"viewer.image_strip_next": "Scroll thumbnails right",
"viewer.image_strip_prev": "Scroll thumbnails left",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom in",
"viewer.image_zoom_out": "Zoom out",
"viewer.image_zoom_reset": "Reset zoom",
"viewer.index_start": "Starting index...",
"viewer.index_updated": "Updated",
"viewer.loaded_from_cache": "File loaded from offline cache",
"viewer.loading": "Loading file...",
"viewer.markdown": "Markdown",
"viewer.media_too_large": "File too large for inline playback. Download it to watch.",
"viewer.media_unsupported": "This format cannot be played in your browser.",
"viewer.metadata_created": "Created",
"viewer.metadata_modified": "Modified",
"viewer.metadata_path": "Path",
@@ -1781,6 +1920,19 @@
"mfa.disable_confirm_btn": "Disable 2FA",
"mfa.disabled_success": "2FA has been disabled.",
"mfa.fill_all_fields": "Please fill in all fields.",
"mfa.qr_unavailable": "QR code unavailable — use manual entry below.",
"mfa.password_change_title": "Password",
"mfa.password_change_desc": "Change your account password (min. 8 characters). All other sessions are invalidated.",
"mfa.current_password_label": "Current password",
"mfa.current_password_placeholder": "Your current password",
"mfa.new_password_label": "New password",
"mfa.new_password_placeholder": "Min. 8 characters",
"mfa.new_password_confirm_label": "Confirm new password",
"mfa.new_password_confirm_placeholder": "Repeat the new password",
"mfa.password_change_btn": "Change password",
"mfa.password_mismatch": "The two passwords do not match.",
"mfa.password_changed": "Password updated.",
"mfa.challenge_unavailable": "Verification screen unavailable — please reload the page.",
"bookslm.title": "BooksLM",
"bookslm.files_indexed": "{count} files indexed",
"bookslm.chars_loaded": "{chars} chars loaded",
@@ -1895,6 +2047,23 @@
"mfa.webauthn_btn": "Verify with my key",
"mfa.webauthn_cancelled": "WebAuthn ceremony cancelled.",
"mfa.webauthn_no_key": "No security key registered for this account.",
"player.now_playing": "Now playing",
"player.play": "Play",
"player.pause": "Pause",
"player.previous": "Previous",
"player.next": "Next",
"player.seek": "Seek",
"player.volume": "Volume",
"player.speed": "Playback speed",
"player.expand": "Expand player",
"player.minimize": "Minimize player",
"player.close": "Stop and close player",
"player.continues": "Playback continues — use the player to stop it.",
"player.reopen_tab": "Return to media",
"player.pip": "Picture in picture",
"player.move": "Move",
"player.resize": "Resize",
"player.resume": "Resuming last playback",
"plugins.title": "🧩 Plugins",
"plugins.description": "Extend ObsiGate with custom renderers, search filters, and editor actions.",
"plugins.install": "Install Plugin",
@@ -1953,5 +2122,96 @@
"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"
}
"help.footer_tagline": "- Web gateway for your Obsidian vaults",
"guide105.nav_architecture": "🏗️ Architecture",
"guide105.nav_library": "⭐ Library",
"guide105.nav_diagrams": "📊 Diagrams",
"guide105.nav_offline": "📴 Offline",
"guide105.nav_collab": "👥 Collaboration",
"guide105.nav_desktop": "🖥️ Desktop",
"guide105.nav_api": "🔌 API",
"guide105.nav_languages": "🌍 Languages",
"guide105.arch_intro": "ObsiGate is a full web application built as independent layers, with no external database: notes live in your Obsidian folders, app state in JSON files under <code>data/</code>, the search index in memory.",
"guide105.arch_diagram_note": "The diagram is interactive in the app: zoom, fullscreen, copy SVG or code.",
"guide105.arch_h3_layers": "The main components",
"guide105.arch_lbl_fe": "Frontend",
"guide105.arch_fe": " — vanilla-JavaScript SPA (ES modules), no framework, no npm build: <code>frontend/js/</code> (~30 modules). CSS variables drive the themes.",
"guide105.arch_lbl_be": "Backend",
"guide105.arch_be": " — FastAPI server (Python 3.11): markdown rendering (mistune + wikilinks), stemmed TF-IDF search (in-memory inverted index), watchdog file watchers, JWT + Argon2id, HMAC webhooks, PDF export (WeasyPrint).",
"guide105.arch_lbl_realtime": "Realtime & MCP",
"guide105.arch_rt": " — WebSocket gateway (Yjs collaboration, SSE/push notifications) and an MCP server (Streamable HTTP, <code>/mcp</code>) for external clients.",
"guide105.arch_lbl_ai": "AI layer",
"guide105.arch_ai": " — editor assistant and BooksLM with multiple providers (DeepSeek, OpenRouter, Gemini, Mistral…), a tool library (function calling, web search, crawl, document reading) and optional embeddings for semantic search.",
"guide105.arch_lbl_data": "Data",
"guide105.arch_data": " — Obsidian vaults on disk (the source of truth), JSON configuration (<code>data/</code>), timestamped backups (<code>.obsigate-backup/</code>), a JSON-lines audit log, encrypted API keys in <code>data/api_keys.json</code>.",
"guide105.arch_lbl_deploy": "Deployment",
"guide105.arch_deploy": " — Tauri desktop app (Rust) embedding the Python backend, a Docker container, or an installable PWA in the browser (offline mode).",
"guide105.arch_h3_flux": "Typical request flow",
"guide105.arch_flux": " Clicking a file issues <code>GET /api/file/...</code>; the backend resolves the path safely, parses frontmatter, renders the markdown and returns HTML; the frontend enriches the view (Mermaid, syntax highlighting, clickable wikilinks). Every write creates a backup before applying.",
"guide105.dia_intro": "<code>```mermaid</code> blocks in your notes render as interactive diagrams (Mermaid v11, loaded from a CDN).",
"guide105.dia_zoom": "Zoom: + / − buttons in the diagram toolbar.",
"guide105.dia_fs": "Fullscreen: ideal for large charts.",
"guide105.dia_copy": "Copy: export the SVG or the source code (dedicated buttons).",
"guide105.dia_toggle": "Preview / Code toggle to edit the source without leaving the view.",
"guide105.dia_theme": "Theme: the diagram follows the app's light/dark theme.",
"guide105.dia_types": "Supported types: flowchart, sequence, class, state, ER, gantt, pie, journey, quadrant, radar, mindmap, timeline, C4, xychart, sankey — plus a preprocessor that understands Obsidian syntax.",
"guide105.dia_excalidraw_ref": "Hand-drawn sketches (<code>.excalidraw</code>, <code>.excalidraw.md</code>) are covered in the 🎨 Excalidraw section.",
"guide105.lib_h3_bookmarks": "Bookmarks & recents",
"guide105.lib_bookmarks": "Star a file with the Bookmark button in the action bar: it joins the dashboard's bookmark list. Recently opened files are listed automatically in the sidebar's \"Recent\" tab, with a dedicated search filter.",
"guide105.lib_h3_saved": "Saved searches",
"guide105.lib_saved": "Save a search from the results page to rerun it in one click from the sidebar: each saved search keeps its operators and filters.",
"guide105.lib_h3_backlinks": "Backlinks & graph",
"guide105.lib_backlinks": "The Backlinks panel lists every note pointing to the open file. The Graph view (🕸️ button) shows links between files: drag nodes, scroll to zoom, double-click a node to open the note.",
"guide105.lib_h3_conflicts": "Sync conflicts",
"guide105.lib_conflicts": "If you sync the vault with Syncthing, ObsiGate detects conflict files (\"sync-conflict\" copies) and offers to compare then resolve them from a dedicated page in the Options menu.",
"guide105.lib_h3_attach": "Attachments & media",
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, smooth navigation between images in the folder — image resized to the frame, hover side arrows, persistent thumbnail filmstrip —, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser; playback continues while you navigate thanks to a floating mini-player (audio) or a mini video window, letting you return to the media or stop it at any time. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
"guide105.off_pwa": "ObsiGate is a PWA: install it (install icon in the address bar) to open it like an app. The service worker caches the UI and your recently viewed documents.",
"guide105.off_edit": "Offline you can read cached documents and even edit them: changes are queued in IndexedDB.",
"guide105.off_sync": "When back online the queue replays automatically (sync badge in the header). If the server version diverged meanwhile, the file is flagged as conflict and the server copy is kept as a backup.",
"guide105.off_watch": "External changes (Obsidian on disk) are detected by the watcher: the view reloads without losing your position, or flags \"modified externally\" during an edit.",
"guide105.col_intro": "Open a document in Edit mode: several people can work on the same file simultaneously over a Yjs (CRDT) WebSocket. Changes merge without locks.",
"guide105.col_cursors": "Collaborators' cursors and selections appear with a per-person colour and name (awareness).",
"guide105.col_save": "The merge is persisted server-side after 2 s of idle; every write creates a timestamped backup before applying.",
"guide105.col_perm": "Limited to authenticated users with permission on the vault.",
"guide105.des_get": "The ObsiGate desktop app (Tauri) embeds the Python server: no Docker install needed. Download it from the repository's Releases page; it auto-updates (signed updater).",
"guide105.des_wizard": "On first launch a wizard asks for your vaults folder (or creates a demo vault). Any document can be detached into its own native window.",
"guide105.des_data": "Desktop data stays in the app directory; vaults point at your existing folders. Every web feature (search, AI, sharing) is available.",
"guide105.des_native": "Native system menu, optional global show/hide shortcut and a recents vault jumplist.",
"guide105.api_intro": "ObsiGate exposes a REST API covering the whole application (vaults, files, search, backups, export, AI, sharing, admin), documented in OpenAPI 3.1:",
"guide105.api_docs_url": "<code>/docs</code> — Swagger UI to try requests live.",
"guide105.api_redoc": "<code>/redoc</code> — compact alternative reference.",
"guide105.api_landing": "<code>/api</code> — landing page grouping endpoints by category.",
"guide105.api_schema": "<code>/openapi.json</code> — the machine schema, import into Postman or Insomnia.",
"guide105.api_h3_auth": "Authentication",
"guide105.api_auth": "Log in via <code>POST /api/auth/login</code> to get a Bearer token (the same token is accepted as an HttpOnly cookie, so browser clients can use <code>credentials: \"include\"</code>). All <code>/api/*</code> routes require it unless documented otherwise.",
"guide105.api_h3_mcp": "MCP server",
"guide105.api_mcp": "The assistant's tools (read, list, search, open, write…) are exposed to any MCP client (Claude Desktop, Cursor, Cline…) at <code>https://your-instance/mcp</code> with an API token. Setup and examples: <code>docs/MCP_GUIDE.md</code>.",
"guide105.api_h3_autom": "Automation",
"guide105.api_autom": "To automate from outside: <code>GET /api/search?q=…</code> and <code>GET /api/file/{vault}?path=…</code> let another tool index or re-read your notes; outgoing webhooks (🪝 section) avoid polling.",
"guide105.lng_how": "The interface is fully bilingual FR/EN. Settings → Profile → Language: the choice is stored on your account and follows you across devices.",
"guide105.lng_scope": "Everything is translated: menus, messages, notifications, and this guide. AI assistant answers follow the language of your documents.",
"guide105.lng_export": "This guide's Markdown / PDF buttons download the version in your language.",
"guide105.h3_semantic": "Semantic search (hybrid)",
"guide105.sem_p1": "Toggle the \"S\" button in the search bar (or Alt-S) to combine TF-IDF with vector similarity (RRF fusion): near concepts (\"velvet\" finds \"soft fabric\") surface higher.",
"guide105.sem_p2": "The embedding engine (multilingual model) is optional: without it a hash fallback keeps hybrid search working. Vectors are recomputed on each vault reindex.",
"guide105.h3_push": "Web notifications (push)",
"guide105.push_p1": "Grant notification permission (🔔 button in the header) to be alerted of offline-sync completions and important events. Subscription management lives in Configuration.",
"guide105.push_p2": "Built on the Web Push API (VAPID keys); works on desktop and mobile PWA with no third-party service: the server sends directly to browser push endpoints.",
"guide105.h3_panes": "Multi-pane split view",
"guide105.panes_p": "The \"Split\" button in the action bar opens the document in a twin pane; stack several panes to compare two notes or read and edit side by side. Pane widths drag on the border and are remembered.",
"guide105.h3_dupe": "Duplicate-proof uploads",
"guide105.dupe_p": "Bulk upload (drag a folder onto the sidebar) compares each file with existing content: an already-present file is skipped rather than duplicated with a \"(1)\" suffix. Handy when restoring a vault.",
"guide105.h3_pdf": "PDF export",
"guide105.pdf_p": "A document's \"PDF\" button renders it with the same engine as the viewer (WeasyPrint): headings, tables, lists and code are preserved. From a public link, the <code>/s/{token}/pdf</code> route produces the same PDF.",
"guide105.h3_exports": "HTML / ePub / ZIP export",
"guide105.exp_p": "The \"Export\" menu offers three formats: standalone HTML (single file, images inlined), ePub for e-readers and, for a folder, a Markdown ZIP bundle — links and resources resolved during export.",
"guide105.h3_mfa": "MFA: TOTP, WebAuthn, recovery codes",
"guide105.mfa_p": "Enable two-factor auth in Settings → Profile: TOTP apps (Authy, Aegis…), security keys and passkeys (WebAuthn, including Windows Hello) and 10 recovery codes to keep offline. Each method can be enabled and disabled independently.",
"guide105.h3_admin": "Admin dashboard",
"guide105.admin_p": "The admin role unlocks a dedicated <code>/admin.html</code> page (Options menu button): live server status, users, vaults, active sessions and the audit log. User CRUD also lives in Configuration.",
"guide105.dl_md_title": "Download this guide as Markdown",
"guide105.dl_pdf_title": "Download this guide as PDF",
"guide105.export_title": "ObsiGate User Guide",
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application."
}
+271 -11
View File
@@ -80,12 +80,16 @@
"admin.users_title": "Gestion utilisateurs",
"ai.action_applied": "Appliqué ✓",
"ai.action_apply": "Appliquer",
"ai.action_apply_all": "Tout approuver ({count})",
"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.confirm_actions": "{count} actions à approuver",
"ai.stop": "Arrêter l'assistant",
"ai.stopped": "Exécution arrêtée.",
"ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)",
"ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)",
"ai.casual": "Ton décontracté",
@@ -363,6 +367,14 @@
"config.ai_keys_desc": "Configurez les clés API pour l'éditeur IA.",
"config.ai_model": "Modèle",
"config.ai_openrouter_label": "OpenRouter API Key",
"config.ai_header_desc": "Configurez vos clés API fournisseur par fournisseur. Dépliez une carte pour saisir une clé, puis cliquez sur Tester pour charger les modèles.",
"config.ai_search_placeholder": "Rechercher un fournisseur…",
"config.ai_default_section": "Configuration par défaut",
"config.ai_providers_title": "Fournisseurs d'API",
"config.ai_providers_empty": "Aucun fournisseur ne correspond",
"config.ai_status_configured": "Configuré",
"config.ai_status_not_configured": "Non configuré",
"config.ai_delete_key_title": "Supprimer la clé API",
"config.api_keys_saved": "Clés API sauvegardées",
"config.section_sources": "🔗 Sources connectées & recherche",
"config.sources_desc": "Clés utilisées par les outils de l'Assistant IA (recherche web à clé : Tavily, Brave, SerpAPI, Exa ; sources connectées : Gitea, GitHub). Elles sont stockées sur le serveur et priment sur les variables d'environnement.",
@@ -372,6 +384,16 @@
"config.delete_key": "Supprimer",
"config.delete_key_confirm": "Supprimer la clé",
"config.key_deleted": "Clé supprimée :",
"config.avatar_label": "Photo de profil",
"config.avatar_hint": "PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px. Apparaît dans la barre latérale.",
"config.avatar_presets_label": "Ou choisissez un avatar prédéfini",
"config.avatar_choose": "Choisir une image",
"config.avatar_remove": "Supprimer la photo",
"config.avatar_updated": "Photo de profil mise à jour",
"config.avatar_removed": "Photo de profil supprimée",
"config.avatar_too_large": "Image trop lourde (8 Mo maximum)",
"config.avatar_invalid_type": "Format non pris en charge — utilisez PNG, JPG ou WEBP",
"config.avatar_upload_failed": "Échec de l'enregistrement de la photo de profil",
"config.backups": "Sauvegardes",
"config.backups_desc": "Gérez les sauvegardes automatiques de vos fichiers.",
"config.client_config": "Configuration client",
@@ -488,7 +510,7 @@
"config.section_fonctionnalites": "Fonctionnalités",
"config.section_format-du-payload": "Format du payload",
"config.section_gestion-des-onglets": "📑 Gestion des onglets",
"config.section_hidden": "Fichiers cachés",
"config.section_hidden": "🗂️ Fichiers cachés",
"config.section_historique-recent-redemarrage-non-requis": "📋 Historique récent\n Redémarrage non requis",
"config.section_indicateurs-visuels": "Indicateurs visuels",
"config.section_intelligence-artificielle-dans-l-editeur": "🤖 Intelligence Artificielle dans l'Éditeur",
@@ -531,7 +553,7 @@
"config.section_securite-signature-hmac-sha256": "Sécurité : signature HMAC-SHA256",
"config.section_selection-de-vault": "Sélection de vault",
"config.section_server": "Serveur",
"config.section_shares": "Partages publics",
"config.section_shares": "📤 Partages publics",
"config.section_sidebar-barre-laterale": "Sidebar (barre latérale)",
"config.section_synchronisation-automatique": "Synchronisation automatique",
"config.section_tag-cloud": "Tag cloud",
@@ -561,9 +583,39 @@
"config.test": "Tester",
"config.timeout_label": "Timeout recherche (ms)",
"config.title": "Configuration",
"config.toc_close": "Fermer le sommaire",
"config.toc_toggle": "Afficher le sommaire",
"config.title_boost": "Boost titre",
"config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre",
"config.title_boost_label": "Boost titre",
"config.nav_tokens": "🔑 Clés API & MCP",
"config.section_tokens": "🔑 Clés API & MCP",
"config.tokens_desc": "Jetons longue durée pour l'API REST et le serveur MCP — la même clé fonctionne pour les deux (en-tête Authorization: Bearer).",
"config.tokens_empty": "Aucune clé API créée.",
"config.token_name_placeholder": "Nom (ex: Claude Desktop)",
"config.token_name_required": "Un nom est requis",
"config.token_expiry_1d": "1 jour",
"config.token_expiry_30d": "1 mois",
"config.token_expiry_180d": "6 mois",
"config.token_expiry_365d": "1 an",
"config.token_expiry_never": "Sans fin",
"config.token_create": "Créer une clé",
"config.token_secret_warning": "Copiez cette clé maintenant — elle ne sera plus jamais affichée.",
"config.token_copy": "Copier",
"config.token_copied": "Clé copiée dans le presse-papiers",
"config.token_done": "Terminé",
"config.token_usage": "Utilisation",
"config.token_usage_detail": ": en-tête « Authorization: Bearer <clé> » sur l'API ; pour MCP, déclarez-la dans les headers de l'URL /mcp.",
"config.token_created": "Créée le",
"config.token_expires": "Expire",
"config.token_last_used": "Dernière utilisation",
"config.token_never_used": "Jamais utilisée",
"config.token_status_active": "Active",
"config.token_status_expired": "Expirée",
"config.token_revoke": "Révoquer",
"config.token_revoke_confirm": "Révoquer la clé",
"config.token_revoked_toast": "Clé révoquée (effet immédiat API + MCP)",
"config.token_created_toast": "Clé créée",
"config.url_required": "URL requise",
"config.watcher_debounce_label": "Debounce (s)",
"config.watcher_enabled_label": "Activer la surveillance",
@@ -740,6 +792,7 @@
"header.menu_theme": "Thème",
"header.menu_theme_light": "Clair",
"header.menu_toggle": "Menu",
"header.menu_version": "Version",
"header.profile": "Profil",
"header.quick_help": "Raccourcis & Astuces",
"header.refresh": "Rafraîchir",
@@ -1069,7 +1122,7 @@
"help.desc_markdown_all": ":\n Rendu complet du markdown",
"help.desc_md_render": "Les fichiers markdown sont rendus avec :",
"help.desc_menu_header": ": Dropdown \"Vault\" dans le menu Options",
"help.desc_menu_options": ": Accès aux\n paramètres, thème, aide",
"help.desc_menu_options": ": Accès aux\n paramètres, thème, aide et version",
"help.desc_op_ext": ": Filtrer par type de fichier",
"help.desc_op_path": ": Filtrer par chemin",
"help.desc_op_phrase": ": Recherche de phrase entre guillemets",
@@ -1272,6 +1325,8 @@
"help.sidebar_filtering": "Filtrage de la sidebar",
"help.sidebar_section": "Sidebar (barre latérale)",
"help.sidebar_tabs": "La sidebar est divisée en deux onglets :",
"help.sidebar_user": "Compte",
"help.desc_sidebar_user": ": Avatar, nom, rôle et déconnexion en bas de la sidebar",
"help.sidebar_toggle_btn": "Bouton toggle sidebar",
"help.simple": "Simple",
"help.simple_search": "Recherche simple",
@@ -1302,6 +1357,7 @@
"help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.",
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
"help.assistant_agent_run": "Les actions s'affichent dans le fil : l'assistant regroupe les modifications en une seule approbation (« Tout approuver ») et met à jour l'arborescence et le document ouvert dès qu'elles sont appliquées. Le bouton d'envoi devient « Stop » pour interrompre l'exécution à tout moment.",
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
"help.assistant_slash": "Tapez / pour lancer un skill (recherche, résumé, correction, plan…) ou une commande admin (/help, /providers, /model, /keys).",
@@ -1471,6 +1527,76 @@
"pwa.install_button": "Installer",
"pwa.install_desc": "Installez cette application sur votre appareil pour un accès rapide.",
"pwa.install_title": "Installer ObsiGate",
"qa.all_actions": "Toutes les actions",
"qa.audit_code": "Détecter les bugs et failles potentielles",
"qa.audit_code.prompt": "Identifie les bugs potentiels, cas limites non gérés et failles de sécurité dans ce code, avec les correctifs proposés.",
"qa.backlinks": "Suggérer des liens et backlinks du vault",
"qa.backlinks.prompt": "Suggère des liens [[wikilinks]] pertinents vers d'autres notes du vault et des backlinks à ajouter à ce document.",
"qa.badge_code": "Fichier de code",
"qa.badge_directory": "Répertoire",
"qa.badge_general": "Mode général",
"qa.badge_multi": "{count} docs ouverts",
"qa.badge_selection": "Sélection active",
"qa.badge_single": "1 doc ouvert",
"qa.capabilities": "Que sais-tu faire ?",
"qa.capabilities.prompt": "Que sais-tu faire ? Présente tes capacités sur ce vault.",
"qa.cat_code": "Code & Scripts",
"qa.cat_cross": "Cross-documents",
"qa.cat_edition": "Édition & Reformulation",
"qa.cat_general": "Assistant",
"qa.cat_structure": "Productivité & Structuration",
"qa.cat_synthesis": "Synthèse & Analyse",
"qa.checklist": "Extraire la checklist d'actions",
"qa.checklist.prompt": "Extrais toutes les actions concrètes à mener sous forme de to-do list Markdown avec cases à cocher [ ].",
"qa.compare": "Comparer différences et convergences",
"qa.compare.prompt": "Compare l'ensemble des documents ouverts et résume leurs convergences, divergences et oppositions.",
"qa.concise": "Rendre plus concis et percutant",
"qa.concise.prompt": "Reformule la sélection pour la rendre plus concise et percutante, sans perdre l'essentiel.",
"qa.create_note": "Créer une note de réunion",
"qa.create_note.prompt": "Crée un fichier de notes de réunion dans le vault.",
"qa.doc_code": "Ajouter la documentation et les types",
"qa.doc_code.prompt": "Ajoute les docstrings, JSDoc et annotations de type appropriés à toutes les fonctions de ce fichier.",
"qa.drawer_title": "Bibliothèque d'actions",
"qa.empty_hint": "Sélectionnez une action instantanée adaptée à votre contexte, ou posez une question directe.",
"qa.explain_code": "Expliquer la logique du script",
"qa.explain_code.prompt": "Analyse et explique pas à pas la structure et l'algorithme de ce code source.",
"qa.explain_selection": "Expliquer la sélection",
"qa.explain_selection.prompt": "Explique le passage sélectionné : son rôle, son contexte et ce qu'il implique.",
"qa.faq": "Générer une FAQ / questions-clés",
"qa.faq.prompt": "Génère une liste de 5 questions-réponses clés pour évaluer la compréhension de ce texte.",
"qa.fix_style": "Corriger et améliorer le style",
"qa.fix_style.prompt": "Corrige les fautes d'orthographe, de grammaire et améliore la fluidité syntaxique de ce passage.",
"qa.frictions": "Relever les frictions et contradictions",
"qa.frictions.prompt": "Analyse ce document et relève les incohérences, zones d'ombre ou contradictions.",
"qa.frontmatter": "Générer le frontmatter YAML",
"qa.frontmatter.prompt": "Génère un bloc frontmatter YAML complet au format du vault et applique-le au document ouvert (insère-le en tête du fichier ou remplace le bloc existant, en conservant les valeurs non vides déjà présentes) : titre, auteur, creation_date et modification_date en ISO-8601 avec fuseau, catégorie, tags (liste en ligne [a, b]), aliases, status, publish, favoris, template, task, archive, draft, private (booléens), NomDeVoute (nom du vault courant), Description (résumé en une phrase du contenu).",
"qa.frontmatter_update": "Mettre à jour le frontmatter",
"qa.frontmatter_update.prompt": "Mets à jour le frontmatter YAML du document ouvert sans supprimer les champs existants : actualise modification_date (horodatage courant ISO-8601 avec fuseau), recalcule titre, tags, aliases, catégorie, NomDeVoute et Description d'après le contenu actuel, complète tout champ de la section métadonnée manquant (auteur, creation_date, status, publish, favoris, template, task, archive, draft, private) et applique la modification au fichier.",
"qa.header_suggested": "Actions suggérées",
"qa.memo": "Rédiger un mémo exécutif partageable",
"qa.memo.prompt": "Rédige un mémo exécutif partageable basé sur ce document : contexte, constats, recommandations.",
"qa.merge": "Fusionner en une note de synthèse",
"qa.merge.prompt": "Fusionne les éléments essentiels de tous les documents ouverts en une note de synthèse unifiée et fluide.",
"qa.no_match": "Aucune action ne correspond.",
"qa.plan": "Créer un plan d'action par étapes",
"qa.plan.prompt": "Transforme ce contenu en un plan d'action structuré par étapes, avec priorités et estimations.",
"qa.rephrase": "Reformuler ce passage",
"qa.rephrase.prompt": "Reformule ce passage en gardant le sens mais avec une tournure différente.",
"qa.search_help": "Rechercher efficacement dans mes notes",
"qa.search_help.prompt": "Comment rechercher efficacement dans mes notes ?",
"qa.search_placeholder": "Rechercher une action...",
"qa.sections": "Structurer en sections hiérarchiques",
"qa.sections.prompt": "Restructure ce document avec une hiérarchie logique de titres Markdown (H2, H3) et des listes à puces propres.",
"qa.summarize_3": "Résumer en 3 points clés",
"qa.summarize_3.prompt": "Fais un résumé synthétique de ce document en 3 points clés, clairs et concis.",
"qa.test_code": "Générer les tests unitaires",
"qa.test_code.prompt": "Rédige une suite de tests unitaires couvrant les cas nominaux et d'erreur pour ce code.",
"qa.timeline": "Construire une chronologie transversale",
"qa.timeline.prompt": "Construis une chronologie transversale des événements datés mentionnés dans ces documents.",
"qa.translate": "Traduire la sélection",
"qa.translate.prompt": "Traduis ce passage dans la langue appropriée (anglais si le texte est en français, et inversement).",
"qa.vulgarize": "Vulgariser ce document",
"qa.vulgarize.prompt": "Explique le contenu de ce document de manière simple, accessible et sans jargon.",
"search.advanced_operators": "Opérateurs avancés",
"search.aria_label": "Suggestions de recherche",
"search.case_sensitive": "Respecter la casse",
@@ -1512,25 +1638,21 @@
"search.whole_word": "Mot entier",
"settings.about": "📦 À propos",
"settings.ai": "🤖 IA",
"settings.backend": "⚙️ Backend",
"settings.backend_hint": "Ces paramètres sont sauvegardés sur le serveur. Certains nécessitent un redémarrage ou une réindexation.",
"settings.backend_section": "Paramètres backend",
"settings.client_label": "Ces paramètres s'appliquent immédiatement côté client.",
"settings.diagnostics": "🩺 Diagnostics",
"settings.explorer": "Explorateur",
"settings.history_count_desc": "Entre 5 et 100 fichiers (sauvegarde sur le serveur)",
"settings.history_count_label": "Nombre de fichiers dans l'historique",
"settings.history_section": "Historique récent",
"settings.no_restart_badge": "Redémarrage non requis",
"settings.profile": "👤 Profil",
"settings.reindex": "Forcer réindexation",
"settings.restart_badge": "Redémarrage requis",
"settings.save": "Sauvegarder",
"settings.search": "Recherche",
"settings.tabs": "Tabs",
"settings.search_placeholder": "Rechercher...",
"settings.sync": "🔄 Synchronisation",
"settings.tabs": "Onglets",
"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",
@@ -1575,6 +1697,8 @@
"sidebar.tab_saved": "Recherches",
"sidebar.tab_tags": "Tags",
"sidebar.tab_vaults": "Vaults",
"sidebar.user_role_admin": "Administrateur",
"sidebar.user_role_user": "Utilisateur",
"sidebar.vault_context_all": "Toutes les vaults",
"sidebar.vault_specific": "Vault spécifique",
"sync.autocomplete": "Auto-complétion",
@@ -1716,11 +1840,26 @@
"viewer.export_title": "Exporter le document",
"viewer.forge_brand": "Forge",
"viewer.forge_title": "Forge (nouvel éditeur)",
"viewer.image_dimensions": "Dimensions",
"viewer.image_error": "Impossible de charger l'image",
"viewer.image_fullscreen": "Plein écran (lightbox)",
"viewer.image_metadata": "Métadonnées",
"viewer.image_next": "Image suivante",
"viewer.image_open_original": "Ouvrir l'original",
"viewer.image_prev": "Image précédente",
"viewer.image_strip_next": "Faire défiler les miniatures vers la droite",
"viewer.image_strip_prev": "Faire défiler les miniatures vers la gauche",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom avant",
"viewer.image_zoom_out": "Zoom arrière",
"viewer.image_zoom_reset": "Réinitialiser le zoom",
"viewer.index_start": "Démarrage index.",
"viewer.index_updated": "Mise à jour",
"viewer.loaded_from_cache": "Fichier chargé depuis le cache hors-ligne",
"viewer.loading": "Chargement du fichier...",
"viewer.markdown": "Markdown",
"viewer.media_too_large": "Fichier trop volumineux pour la lecture intégrée. Téléchargez-le pour le lire.",
"viewer.media_unsupported": "Ce format ne peut pas être lu dans votre navigateur.",
"viewer.metadata_created": "Créé",
"viewer.metadata_modified": "Modifié",
"viewer.metadata_path": "Chemin",
@@ -1781,6 +1920,19 @@
"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.",
"mfa.qr_unavailable": "QR code indisponible — utilisez la saisie manuelle ci-dessous.",
"mfa.password_change_title": "Mot de passe",
"mfa.password_change_desc": "Modifiez le mot de passe de votre compte (min. 8 caractères). Toutes les autres sessions sont invalidées.",
"mfa.current_password_label": "Mot de passe actuel",
"mfa.current_password_placeholder": "Votre mot de passe actuel",
"mfa.new_password_label": "Nouveau mot de passe",
"mfa.new_password_placeholder": "Min. 8 caractères",
"mfa.new_password_confirm_label": "Confirmer le nouveau mot de passe",
"mfa.new_password_confirm_placeholder": "Répétez le nouveau mot de passe",
"mfa.password_change_btn": "Changer le mot de passe",
"mfa.password_mismatch": "Les deux mots de passe ne correspondent pas.",
"mfa.password_changed": "Mot de passe mis à jour.",
"mfa.challenge_unavailable": "Écran de vérification indisponible — veuillez recharger la page.",
"bookslm.title": "BooksLM",
"bookslm.files_indexed": "{count} fichiers indexés",
"bookslm.chars_loaded": "{chars} caractères chargés",
@@ -1895,6 +2047,23 @@
"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.",
"player.now_playing": "Lecture en cours",
"player.play": "Lecture",
"player.pause": "Pause",
"player.previous": "Précédent",
"player.next": "Suivant",
"player.seek": "Position de lecture",
"player.volume": "Volume",
"player.speed": "Vitesse de lecture",
"player.expand": "Agrandir le lecteur",
"player.minimize": "Réduire le lecteur",
"player.close": "Arrêter et fermer le lecteur",
"player.continues": "Lecture en cours — utilisez le lecteur pour l'arrêter.",
"player.reopen_tab": "Revenir au média",
"player.pip": "Image dans l'image",
"player.move": "Déplacer",
"player.resize": "Redimensionner",
"player.resume": "Reprise de la dernière lecture",
"plugins.title": "🧩 Plugins",
"plugins.description": "Étendez ObsiGate avec des renderers personnalisés, filtres de recherche et actions d'éditeur.",
"plugins.install": "Installer le plugin",
@@ -1953,5 +2122,96 @@
"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"
}
"help.footer_tagline": "- Porte d'entrée web pour vos vaults Obsidian",
"guide105.nav_architecture": "🏗️ Architecture",
"guide105.nav_library": "⭐ Bibliothèque",
"guide105.nav_diagrams": "📊 Diagrammes",
"guide105.nav_offline": "📴 Hors-ligne",
"guide105.nav_collab": "👥 Collaboration",
"guide105.nav_desktop": "🖥️ Desktop",
"guide105.nav_api": "🔌 API",
"guide105.nav_languages": "🌍 Multilingue",
"guide105.arch_intro": "ObsiGate est une application web complète construite en couches indépendantes, sans base de données externe : les notes vivent dans vos dossiers Obsidian, l'état applicatif dans des fichiers JSON de <code>data/</code>, l'index de recherche en mémoire.",
"guide105.arch_diagram_note": "Le diagramme est interactif dans l'application : zoom, plein écran, copie SVG ou code.",
"guide105.arch_h3_layers": "Les grandes composantes",
"guide105.arch_lbl_fe": "Frontend",
"guide105.arch_fe": " — SPA en JavaScript vanilla (modules ES), sans framework ni build npm : <code>frontend/js/</code> (~30 modules). Le CSS utilise des variables pour les thèmes.",
"guide105.arch_lbl_be": "Backend",
"guide105.arch_be": " — serveur FastAPI (Python 3.11) : rendu markdown (mistune + wikilinks), recherche TF-IDF stemmisée (index inversé en mémoire), watchers watchdog, JWT + Argon2id, webhooks HMAC, export PDF (WeasyPrint).",
"guide105.arch_lbl_realtime": "Temps réel & MCP",
"guide105.arch_rt": " — passerelle WebSocket (collaboration Yjs, notifications SSE/push) et serveur MCP (Streamable HTTP, <code>/mcp</code>) pour les clients externes.",
"guide105.arch_lbl_ai": "Couche IA",
"guide105.arch_ai": " — assistants d'édition et BooksLM multi-providers (DeepSeek, OpenRouter, Gemini, Mistral…), bibliothèque d'outils (function calling, recherche web, crawl, lecture de documents) et embeddings optionnels pour la recherche sémantique.",
"guide105.arch_lbl_data": "Données",
"guide105.arch_data": " — les vaults Obsidian sur disque (source de vérité), la configuration en JSON (<code>data/</code>), les backups horodatés (<code>.obsigate-backup/</code>), l'audit en JSON lines, les clés API chiffrées dans <code>data/api_keys.json</code>.",
"guide105.arch_lbl_deploy": "Déploiement",
"guide105.arch_deploy": " — application desktop Tauri (Rust) embarquant le backend Python, conteneur Docker, ou PWA installable dans le navigateur (mode hors-ligne).",
"guide105.arch_h3_flux": "Flux typique",
"guide105.arch_flux": " Un clic sur un fichier émet <code>GET /api/file/...</code> ; le backend résout le chemin en sécurité, parse le frontmatter, rend le markdown et renvoie le HTML ; le frontend enrichit l'affichage (Mermaid, coloration, wikilinks cliquables). Chaque écriture crée un backup avant application.",
"guide105.dia_intro": "Les blocs <code>```mermaid</code> de vos notes sont rendus en diagrammes interactifs (Mermaid v11, chargé depuis un CDN).",
"guide105.dia_zoom": "Zoom : boutons + / − dans la barre d'outils du diagramme.",
"guide105.dia_fs": "Plein écran : idéal pour les grandes matrices.",
"guide105.dia_copy": "Copie : exportez le SVG ou le code source (boutons dédiés).",
"guide105.dia_toggle": "Bascule Aperçu / Code pour éditer la source sans quitter la vue.",
"guide105.dia_theme": "Thème : le diagramme suit le thème clair/sombre de l'application.",
"guide105.dia_types": "Types supportés : flowchart, sequence, class, state, ER, gantt, pie, journey, quadrant, radar, mindmap, timeline, C4, xychart, sankey — plus un préprocesseur qui comprend la syntaxe Obsidian.",
"guide105.dia_excalidraw_ref": "Les dessins à main levée (<code>.excalidraw</code>, <code>.excalidraw.md</code>) sont couverts dans la section 🎨 Excalidraw.",
"guide105.lib_h3_bookmarks": "Signets & récents",
"guide105.lib_bookmarks": "Marquez un fichier d'un ★ (bouton Signet de la barre d'actions) : il rejoint la liste des signets du dashboard. Les fichiers récemment ouverts sont listés automatiquement dans l'onglet « Récents » de la sidebar, avec un filtre de recherche dédié.",
"guide105.lib_h3_saved": "Recherches sauvegardées",
"guide105.lib_saved": "Enregistrez une recherche depuis la page de résultats pour la relancer en un clic depuis la sidebar : chaque recherche sauvegardée conserve ses opérateurs et filtres.",
"guide105.lib_h3_backlinks": "Backlinks & graphe",
"guide105.lib_backlinks": "Le panneau Backlinks liste toutes les notes qui pointent vers le fichier ouvert. La vue Graphe (bouton 🕸️) affiche les liens entre fichiers : glissez les nœuds, zoomez à la molette, double-cliquez pour ouvrir une note.",
"guide105.lib_h3_conflicts": "Conflits de synchronisation",
"guide105.lib_conflicts": "Si vous synchronisez le vault avec Syncthing, ObsiGate détecte les fichiers de conflit (copies « sync-conflict ») et propose de les comparer puis résoudre depuis la page dédiée du menu Options.",
"guide105.lib_h3_attach": "Fichiers joints & médias",
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation fluide entre les images du dossier — image redimensionnée au cadre, flèches latérales au survol, pellicule de miniatures persistante —, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur ; la lecture continue pendant la navigation grâce à un mini-lecteur flottant (audio) ou une mini-fenêtre vidéo, qui permet à tout moment de revenir au média ou de l'arrêter. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
"guide105.off_pwa": "ObsiGate est une PWA : installez-la (icône d'installation de la barre d'adresse) pour l'ouvrir comme une application. Le service worker met en cache l'interface et vos derniers documents consultés.",
"guide105.off_edit": "Hors-ligne, vous pouvez lire les documents en cache et même les éditer : les modifications sont mises en file d'attente dans IndexedDB.",
"guide105.off_sync": "Au retour en ligne, la file se rejoue automatiquement (badge de synchronisation dans l'en-tête). Si la version serveur a divergé entre-temps, le fichier est marqué en conflit et la version serveur est préservée en backup.",
"guide105.off_watch": "Les modifications externes (Obsidian sur disque) sont détectées par le watcher : la vue se recharge sans perte de position, ou signale « modifié en externe » pendant une édition.",
"guide105.col_intro": "Ouvrez un document en mode Édition : plusieurs personnes peuvent travailler simultanément sur le même fichier via un WebSocket Yjs (CRDT). Les modifications fusionnent sans verrou.",
"guide105.col_cursors": "Les curseurs et sélections des collaborateurs apparaissent avec une couleur et un nom par personne (awareness).",
"guide105.col_save": "La fusion est persistée côté serveur après 2 s d'inactivité ; chaque écriture crée un backup horodaté avant application.",
"guide105.col_perm": "Accès limité aux utilisateurs authentifiés disposant de la permission sur la vault.",
"guide105.des_get": "L'application desktop ObsiGate (Tauri) embarque le serveur Python : aucune installation de Docker nécessaire. Elle se télécharge sur la page des Releases du dépôt et se met à jour automatiquement (updater signé).",
"guide105.des_wizard": "Au premier lancement, un assistant demande le dossier de vos vaults (ou crée un vault de démonstration). Chaque document peut être détaché en fenêtre native séparée.",
"guide105.des_data": "Les données desktop restent dans le répertoire applicatif ; les vaults pointent sur vos dossiers existants. Toutes les fonctionnalités web (recherche, IA, partage) sont disponibles.",
"guide105.des_native": "Menu système natif, raccourci global optionnel pour afficher/masquer la fenêtre et jumplist des vaults récents.",
"guide105.api_intro": "ObsiGate expose une API REST couvrant toute l'application (vaults, fichiers, recherche, backups, export, IA, partage, admin), documentée en OpenAPI 3.1 :",
"guide105.api_docs_url": "<code>/docs</code> — interface Swagger UI pour essayer les requêtes en direct.",
"guide105.api_redoc": "<code>/redoc</code> — référence alternative plus compacte.",
"guide105.api_landing": "<code>/api</code> — page de garde regroupant les endpoints par catégorie.",
"guide105.api_schema": "<code>/openapi.json</code> — le schéma machine, à importer dans Postman ou Insomnia.",
"guide105.api_h3_auth": "Authentification",
"guide105.api_auth": "Connectez-vous via <code>POST /api/auth/login</code> pour obtenir un token Bearer (le même jeton est accepté en cookie HttpOnly, ce qui permet aux clients navigateur d'utiliser <code>credentials: \"include\"</code>). Toutes les routes <code>/api/*</code> exigent ce jeton sauf mention contraire.",
"guide105.api_h3_mcp": "Serveur MCP",
"guide105.api_mcp": "Les outils de l'assistant IA (lire, lister, chercher, ouvrir, écrire…) sont exposés à tout client MCP (Claude Desktop, Cursor, Cline…) sur <code>https://votre-instance/mcp</code> avec un token d'API. Configuration et exemples : <code>docs/MCP_GUIDE.md</code>.",
"guide105.api_h3_autom": "Automatisation",
"guide105.api_autom": "Pour automatiser depuis l'extérieur : <code>GET /api/search?q=…</code> et <code>GET /api/file/{vault}?path=…</code> permettent d'indexer ou relire vos notes dans un autre outil ; les webhooks sortants (section 🪝) évitent le polling.",
"guide105.lng_how": "L'interface est intégralement bilingue français / anglais. Réglages → Profil → Langue : le choix est enregistré sur votre compte et vous suit sur tous les appareils.",
"guide105.lng_scope": "Tout est traduit : menus, messages, notifications, et le présent guide. Les réponses de l'assistant IA suivent la langue de vos documents.",
"guide105.lng_export": "Les boutons Markdown / PDF de ce guide téléchargent la version dans votre langue.",
"guide105.h3_semantic": "Recherche sémantique (hybride)",
"guide105.sem_p1": "Activez le bouton « S » de la barre de recherche (ou Alt-S) pour combiner TF-IDF et similarité vectorielle (fusion RRF) : les concepts approchants (« velours » trouve « tissu doux ») remontent mieux.",
"guide105.sem_p2": "Le moteur d'embeddings (modèle multilingue) est optionnel : sans lui, un repli par hash conserve une recherche hybride fonctionnelle. Les vecteurs sont recalculés à chaque indexation du vault.",
"guide105.h3_push": "Notifications web (push)",
"guide105.push_p1": "Autorisez les notifications (bouton 🔔 de l'en-tête) pour être averti des fins de synchronisation hors-ligne et des événements importants. La gestion des abonnements est dans les Configurations.",
"guide105.push_p2": "Basée sur la Web Push API (clés VAPID) ; fonctionne sur desktop et PWA mobile, sans service tiers : le serveur émet directement vers les endpoints push des navigateurs.",
"guide105.h3_panes": "Vue multi-panneaux (split view)",
"guide105.panes_p": "Le bouton « Diviser » de la barre d'actions ouvre le document dans un panneau jumeau ; empilez plusieurs panneaux pour comparer deux notes ou lire et éditer en parallèle. Les largeurs se règlent au bord des panneaux et sont mémorisées.",
"guide105.h3_dupe": "Anti-doublons à l'upload",
"guide105.dupe_p": "L'upload en masse (glisser-déposer un dossier sur la sidebar) compare chaque fichier au contenu existant : un fichier déjà présent est ignoré plutôt que dupliqué avec un suffixe « (1) ». Utile pour restaurer un vault sans créer de doublons.",
"guide105.h3_pdf": "Export PDF",
"guide105.pdf_p": "Le bouton « PDF » d'un document le rend avec le même moteur que la vue (WeasyPrint) : titres, tableaux, listes et code sont conservés. Depuis un lien public, la route <code>/s/{token}/pdf</code> produit le même PDF.",
"guide105.h3_exports": "Export HTML / ePub / ZIP",
"guide105.exp_p": "Le menu « Exporter » propose trois formats : HTML autonome (fichier unique, images incluses), ePub pour les liseuses et, pour un dossier, un bundle Markdown en ZIP — liens et ressources résolus pendant l'export.",
"guide105.h3_mfa": "MFA : TOTP, WebAuthn, codes de secours",
"guide105.mfa_p": "Activez la double authentification dans Réglages → Profil : applications TOTP (Authy, Aegis…), clés de sécurité et passkeys (WebAuthn, y compris Windows Hello) et 10 codes de secours à conserver hors ligne. Chaque méthode s'active et se désactive indépendamment.",
"guide105.h3_admin": "Tableau de bord administrateur",
"guide105.admin_p": "Le rôle admin ouvre une page dédiée <code>/admin.html</code> (bouton du menu Options) : statut du serveur en direct, utilisateurs, vaults, sessions actives et journal d'audit. Le CRUD utilisateurs est aussi disponible dans les Configurations.",
"guide105.dl_md_title": "Télécharger ce guide en Markdown",
"guide105.dl_pdf_title": "Télécharger ce guide en PDF",
"guide105.export_title": "Guide d'utilisation ObsiGate",
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application."
}
+7 -2
View File
@@ -38,7 +38,7 @@
}
})();
</script>
<link rel="stylesheet" href="/static/style.css?v=2">
<link rel="stylesheet" href="/static/style.css?v=3">
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-theme-dark">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-theme-light" disabled>
@@ -871,8 +871,13 @@ const RightSidebarManager = {
group.forEach(function (b) { actionsDiv.appendChild(b); });
});
header.appendChild(actionsDiv);
area.appendChild(header);
// #115 — pinned action bar (direct child of the scroll container) so it
// stays visible while long documents scroll, as in the main viewer.
const toolbar = document.createElement("div");
toolbar.className = "file-toolbar";
toolbar.appendChild(actionsDiv);
area.appendChild(toolbar);
// Frontmatter — Accent Card
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
+1799 -46
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v20';
const SW_VERSION = 'v26';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
@@ -109,6 +109,13 @@ self.addEventListener('fetch', (event) => {
// Let the browser handle range requests (PDF/streamed media) directly.
if (request.headers.has('range')) return;
// Streamed audio/video is large and range-driven — never cache it
// (roadmap #109-E2). Image thumbnails (/api/media/{vault}/thumb) stay cached.
if (url.pathname.startsWith('/api/media/') && !url.pathname.endsWith('/thumb')) {
event.respondWith(fetch(request));
return;
}
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, API_CACHE, () =>
new Response(JSON.stringify({ error: 'Offline' }), {
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Génère un token d'accès ObsiGate longue durée pour le client MCP
# Conteneur de test local: obsigate-test (adapter le nom selon l'instance)
docker exec -i obsigate-test python - <<'PYEOF'
import time, uuid, json
from jose import jwt
key = open("data/secret.key").read().strip()
u = json.load(open("data/users.json"))["users"]["admin"]
now = int(time.time())
tok = jwt.encode({
"sub": "admin",
"role": u["role"],
"vaults": u["vaults"],
"jti": str(uuid.uuid4()),
"iat": now,
"exp": now + 31536000, # 1 an
"type": "access",
}, key, algorithm="HS256")
print(tok)
PYEOF
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.11.3",
"version": "2.25.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
@@ -9,7 +9,8 @@
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test:e2e": "bash scripts/run-e2e-local.sh"
"test:e2e": "bash scripts/run-e2e-local.sh",
"test:e2e:ps": "pwsh -NoProfile -File scripts/run-e2e-local.ps1"
},
"repository": {
"type": "git",
+66
View File
@@ -0,0 +1,66 @@
"""Pré-rend les diagrammes Mermaid du guide intégré en PNG (#105).
Scanne les blocs ``<pre class="mermaid-code">`` de frontend/index.html,
extrait leur code, et appelle scripts/render_guide_diagram.mjs (Chromium +
mermaid v11) pour produire ``backend/assets/guide_diagrams/<sha1>.png``.
Ces PNG sont commités : le PDF du guide les embarque comme vraies images
(WeasyPrint ne sait pas exécuter Mermaid).
Usage : python scripts/build_guide_diagrams.py
"""
import hashlib
import html
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ASSETS = ROOT / "backend" / "assets" / "guide_diagrams"
def extract_mermaid() -> dict[str, str]:
page_html = (ROOT / "frontend" / "index.html").read_text(encoding="utf-8")
out: dict[str, str] = {}
for m in re.finditer(
r'<pre class="mermaid-code"><code class="language-mermaid">(.*?)</code></pre>',
page_html,
re.DOTALL,
):
code = m.group(1)
code = html.unescape(code).strip()
sha = hashlib.sha1(code.encode("utf-8")).hexdigest()[:16]
out[sha] = code
return out
def main() -> int:
jobs = extract_mermaid()
if not jobs:
print("aucun bloc mermaid dans index.html")
return 1
todo = {k: v for k, v in jobs.items() if not (ASSETS / f"{k}.png").exists()}
print("diagrammes:", len(jobs), "| à rendre:", len(todo))
if not todo:
return 0
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f:
json.dump(todo, f)
path = f.name
r = subprocess.run(
["node", str(ROOT / "scripts" / "render_guide_diagram.mjs"), path],
cwd=ROOT,
capture_output=True,
check=False,
text=True,
timeout=300,
)
print(r.stdout[-2000:])
if r.returncode != 0:
print(r.stderr[-2000:])
return r.returncode
if __name__ == "__main__":
sys.exit(main())
+626
View File
@@ -0,0 +1,626 @@
# -*- coding: utf-8 -*-
"""Nouveau contenu du Guide d'utilisation (#105) — source unique de vérité.
Rôles :
1. ``CONTENT`` : dictionnaire i18n clé -> (fr, en). Les locales FR/EN sont
générées depuis ce dictionnaire par ``scripts/merge_guide_locales.py``
(ne jamais éditer les blocs ``guide105.*`` des JSON à la main).
2. Les constantes ``SECTION_*`` / ``EXTRA_BLOCKS`` / ``TOC_INSERT_BEFORE``
décrivent le HTML à insérer dans ``frontend/index.html`` par
``scripts/insert_guide_sections.py``. Le texte FR inline de chaque
élément portant ``data-i18n="guide105.X"`` DOIT être identique à
``CONTENT[X][0]`` (sinon ``_applyDOM`` afficherait un texte incohérent
quand la locale FR est appliquée).
Règle i18n : tout élément textuel des nouvelles sections porte une clé
``guide105.*``. Les clés préexistantes ne sont jamais réutilisées avec un
texte différent.
"""
# ---------------------------------------------------------------------------
# Dictionnaire i18n (clé -> FR, EN)
# ---------------------------------------------------------------------------
CONTENT: dict[str, tuple[str, str]] = {
# -- TOC / titres de sections ---------------------------------------------
"nav_architecture": ("🏗️ Architecture", "🏗️ Architecture"),
"nav_library": ("⭐ Bibliothèque", "⭐ Library"),
"nav_diagrams": ("📊 Diagrammes", "📊 Diagrams"),
"nav_offline": ("📴 Hors-ligne", "📴 Offline"),
"nav_collab": ("👥 Collaboration", "👥 Collaboration"),
"nav_desktop": ("🖥️ Desktop", "🖥️ Desktop"),
"nav_api": ("🔌 API", "🔌 API"),
"nav_languages": ("🌍 Multilingue", "🌍 Languages"),
# -- Section Architecture ---------------------------------------------------
"arch_intro": (
"ObsiGate est une application web complète construite en couches indépendantes, sans base"
" de données externe : les notes vivent dans vos dossiers Obsidian, l'état applicatif dans"
" des fichiers JSON de <code>data/</code>, l'index de recherche en mémoire.",
"ObsiGate is a full web application built as independent layers, with no external"
" database: notes live in your Obsidian folders, app state in JSON files under"
" <code>data/</code>, the search index in memory.",
),
"arch_diagram_note": (
"Le diagramme est interactif dans l'application : zoom, plein écran, copie SVG ou code.",
"The diagram is interactive in the app: zoom, fullscreen, copy SVG or code.",
),
"arch_h3_layers": ("Les grandes composantes", "The main components"),
"arch_lbl_fe": ("Frontend", "Frontend"),
"arch_fe": (
" — SPA en JavaScript vanilla (modules ES), sans framework ni build npm :"
" <code>frontend/js/</code> (~30 modules). Le CSS utilise des variables pour les thèmes.",
" — vanilla-JavaScript SPA (ES modules), no framework, no npm build:"
" <code>frontend/js/</code> (~30 modules). CSS variables drive the themes.",
),
"arch_lbl_be": ("Backend", "Backend"),
"arch_be": (
" — serveur FastAPI (Python 3.11) : rendu markdown (mistune + wikilinks),"
" recherche TF-IDF stemmisée (index inversé en mémoire), watchers watchdog, JWT +"
" Argon2id, webhooks HMAC, export PDF (WeasyPrint).",
" — FastAPI server (Python 3.11): markdown rendering (mistune + wikilinks), stemmed"
" TF-IDF search (in-memory inverted index), watchdog file watchers, JWT + Argon2id,"
" HMAC webhooks, PDF export (WeasyPrint).",
),
"arch_lbl_realtime": ("Temps réel & MCP", "Realtime & MCP"),
"arch_rt": (
" — passerelle WebSocket (collaboration Yjs, notifications SSE/push) et serveur MCP"
" (Streamable HTTP, <code>/mcp</code>) pour les clients externes.",
" — WebSocket gateway (Yjs collaboration, SSE/push notifications) and an MCP server"
" (Streamable HTTP, <code>/mcp</code>) for external clients.",
),
"arch_lbl_ai": ("Couche IA", "AI layer"),
"arch_ai": (
" — assistants d'édition et BooksLM multi-providers (DeepSeek, OpenRouter, Gemini,"
" Mistral…), bibliothèque d'outils (function calling, recherche web, crawl, lecture de"
" documents) et embeddings optionnels pour la recherche sémantique.",
" — editor assistant and BooksLM with multiple providers (DeepSeek, OpenRouter, Gemini,"
" Mistral…), a tool library (function calling, web search, crawl, document reading) and"
" optional embeddings for semantic search.",
),
"arch_lbl_data": ("Données", "Data"),
"arch_data": (
" — les vaults Obsidian sur disque (source de vérité), la configuration en JSON"
" (<code>data/</code>), les backups horodatés (<code>.obsigate-backup/</code>),"
" l'audit en JSON lines, les clés API chiffrées dans <code>data/api_keys.json</code>.",
" — Obsidian vaults on disk (the source of truth), JSON configuration"
" (<code>data/</code>), timestamped backups (<code>.obsigate-backup/</code>), a JSON-lines"
" audit log, encrypted API keys in <code>data/api_keys.json</code>.",
),
"arch_lbl_deploy": ("Déploiement", "Deployment"),
"arch_deploy": (
" — application desktop Tauri (Rust) embarquant le backend Python, conteneur Docker, ou"
" PWA installable dans le navigateur (mode hors-ligne).",
" — Tauri desktop app (Rust) embedding the Python backend, a Docker container, or an"
" installable PWA in the browser (offline mode).",
),
"arch_h3_flux": ("Flux typique", "Typical request flow"),
"arch_flux": (
" Un clic sur un fichier émet <code>GET /api/file/...</code> ; le backend résout le chemin"
" en sécurité, parse le frontmatter, rend le markdown et renvoie le HTML ; le frontend"
" enrichit l'affichage (Mermaid, coloration, wikilinks cliquables). Chaque écriture crée"
" un backup avant application.",
" Clicking a file issues <code>GET /api/file/...</code>; the backend resolves the path"
" safely, parses frontmatter, renders the markdown and returns HTML; the frontend"
" enriches the view (Mermaid, syntax highlighting, clickable wikilinks). Every write"
" creates a backup before applying.",
),
# -- Section Diagrammes ---------------------------------------------------------
"dia_intro": (
"Les blocs <code>```mermaid</code> de vos notes sont rendus en diagrammes interactifs"
" (Mermaid v11, chargé depuis un CDN).",
"<code>```mermaid</code> blocks in your notes render as interactive diagrams (Mermaid v11,"
" loaded from a CDN).",
),
"dia_zoom": ("Zoom : boutons + / − dans la barre d'outils du diagramme.",
"Zoom: + / − buttons in the diagram toolbar."),
"dia_fs": ("Plein écran : idéal pour les grandes matrices.",
"Fullscreen: ideal for large charts."),
"dia_copy": ("Copie : exportez le SVG ou le code source (boutons dédiés).",
"Copy: export the SVG or the source code (dedicated buttons)."),
"dia_toggle": ("Bascule Aperçu / Code pour éditer la source sans quitter la vue.",
"Preview / Code toggle to edit the source without leaving the view."),
"dia_theme": ("Thème : le diagramme suit le thème clair/sombre de l'application.",
"Theme: the diagram follows the app's light/dark theme."),
"dia_types": (
"Types supportés : flowchart, sequence, class, state, ER, gantt, pie, journey, quadrant,"
" radar, mindmap, timeline, C4, xychart, sankey — plus un préprocesseur qui comprend la"
" syntaxe Obsidian.",
"Supported types: flowchart, sequence, class, state, ER, gantt, pie, journey, quadrant,"
" radar, mindmap, timeline, C4, xychart, sankey — plus a preprocessor that understands"
" Obsidian syntax.",
),
"dia_excalidraw_ref": (
"Les dessins à main levée (<code>.excalidraw</code>, <code>.excalidraw.md</code>) sont"
" couverts dans la section 🎨 Excalidraw.",
"Hand-drawn sketches (<code>.excalidraw</code>, <code>.excalidraw.md</code>) are covered"
" in the 🎨 Excalidraw section.",
),
# -- Section Bibliothèque & signets ---------------------------------------------
"lib_h3_bookmarks": ("Signets & récents", "Bookmarks & recents"),
"lib_bookmarks": (
"Marquez un fichier d'un ★ (bouton Signet de la barre d'actions) : il rejoint la liste"
" des signets du dashboard. Les fichiers récemment ouverts sont listés automatiquement"
" dans l'onglet « Récents » de la sidebar, avec un filtre de recherche dédié.",
"Star a file with the Bookmark button in the action bar: it joins the dashboard's"
" bookmark list. Recently opened files are listed automatically in the sidebar's"
" \"Recent\" tab, with a dedicated search filter.",
),
"lib_h3_saved": ("Recherches sauvegardées", "Saved searches"),
"lib_saved": (
"Enregistrez une recherche depuis la page de résultats pour la relancer en un clic depuis"
" la sidebar : chaque recherche sauvegardée conserve ses opérateurs et filtres.",
"Save a search from the results page to rerun it in one click from the sidebar: each saved"
" search keeps its operators and filters.",
),
"lib_h3_backlinks": ("Backlinks & graphe", "Backlinks & graph"),
"lib_backlinks": (
"Le panneau Backlinks liste toutes les notes qui pointent vers le fichier ouvert. La vue"
" Graphe (bouton 🕸️) affiche les liens entre fichiers : glissez les nœuds, zoomez à la"
" molette, double-cliquez pour ouvrir une note.",
"The Backlinks panel lists every note pointing to the open file. The Graph view (🕸️"
" button) shows links between files: drag nodes, scroll to zoom, double-click a node to"
" open the note.",
),
"lib_h3_conflicts": ("Conflits de synchronisation", "Sync conflicts"),
"lib_conflicts": (
"Si vous synchronisez le vault avec Syncthing, ObsiGate détecte les fichiers de conflit"
" (copies « sync-conflict ») et propose de les comparer puis résoudre depuis la page"
" dédiée du menu Options.",
"If you sync the vault with Syncthing, ObsiGate detects conflict files"
" (\"sync-conflict\" copies) and offers to compare then resolve them from a dedicated"
" page in the Options menu.",
),
"lib_h3_attach": ("Fichiers joints & médias", "Attachments & media"),
"lib_attach": (
"Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF"
" intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche ; le"
" bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
"Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded"
" PDFs) are rendered in the viewer and indexed for search; the \"Rescan attachments\""
" button in Configuration rebuilds the attachment index.",
),
# -- Section Hors-ligne -----------------------------------------------------------
"off_pwa": (
"ObsiGate est une PWA : installez-la (icône d'installation de la barre d'adresse) pour"
" l'ouvrir comme une application. Le service worker met en cache l'interface et vos"
" derniers documents consultés.",
"ObsiGate is a PWA: install it (install icon in the address bar) to open it like an app."
" The service worker caches the UI and your recently viewed documents.",
),
"off_edit": (
"Hors-ligne, vous pouvez lire les documents en cache et même les éditer : les"
" modifications sont mises en file d'attente dans IndexedDB.",
"Offline you can read cached documents and even edit them: changes are queued in"
" IndexedDB.",
),
"off_sync": (
"Au retour en ligne, la file se rejoue automatiquement (badge de synchronisation dans"
" l'en-tête). Si la version serveur a divergé entre-temps, le fichier est marqué en"
" conflit et la version serveur est préservée en backup.",
"When back online the queue replays automatically (sync badge in the header). If the"
" server version diverged meanwhile, the file is flagged as conflict and the server copy"
" is kept as a backup.",
),
"off_watch": (
"Les modifications externes (Obsidian sur disque) sont détectées par le watcher : la vue"
" se recharge sans perte de position, ou signale « modifié en externe » pendant une"
" édition.",
"External changes (Obsidian on disk) are detected by the watcher: the view reloads"
" without losing your position, or flags \"modified externally\" during an edit.",
),
# -- Section Collaboration ----------------------------------------------------------
"col_intro": (
"Ouvrez un document en mode Édition : plusieurs personnes peuvent travailler"
" simultanément sur le même fichier via un WebSocket Yjs (CRDT). Les modifications"
" fusionnent sans verrou.",
"Open a document in Edit mode: several people can work on the same file simultaneously"
" over a Yjs (CRDT) WebSocket. Changes merge without locks.",
),
"col_cursors": (
"Les curseurs et sélections des collaborateurs apparaissent avec une couleur et un nom"
" par personne (awareness).",
"Collaborators' cursors and selections appear with a per-person colour and name"
" (awareness).",
),
"col_save": (
"La fusion est persistée côté serveur après 2 s d'inactivité ; chaque écriture crée un"
" backup horodaté avant application.",
"The merge is persisted server-side after 2 s of idle; every write creates a timestamped"
" backup before applying.",
),
"col_perm": (
"Accès limité aux utilisateurs authentifiés disposant de la permission sur la vault.",
"Limited to authenticated users with permission on the vault.",
),
# -- Section Desktop ------------------------------------------------------------------
"des_get": (
"L'application desktop ObsiGate (Tauri) embarque le serveur Python : aucune installation"
" de Docker nécessaire. Elle se télécharge sur la page des Releases du dépôt et se met à"
" jour automatiquement (updater signé).",
"The ObsiGate desktop app (Tauri) embeds the Python server: no Docker install needed."
" Download it from the repository's Releases page; it auto-updates (signed updater).",
),
"des_wizard": (
"Au premier lancement, un assistant demande le dossier de vos vaults (ou crée un vault de"
" démonstration). Chaque document peut être détaché en fenêtre native séparée.",
"On first launch a wizard asks for your vaults folder (or creates a demo vault). Any"
" document can be detached into its own native window.",
),
"des_data": (
"Les données desktop restent dans le répertoire applicatif ; les vaults pointent sur vos"
" dossiers existants. Toutes les fonctionnalités web (recherche, IA, partage) sont"
" disponibles.",
"Desktop data stays in the app directory; vaults point at your existing folders. Every web"
" feature (search, AI, sharing) is available.",
),
"des_native": (
"Menu système natif, raccourci global optionnel pour afficher/masquer la fenêtre et"
" jumplist des vaults récents.",
"Native system menu, optional global show/hide shortcut and a recents vault jumplist.",
),
# -- Section API ------------------------------------------------------------------------
"api_intro": (
"ObsiGate expose une API REST couvrant toute l'application (vaults, fichiers, recherche,"
" backups, export, IA, partage, admin), documentée en OpenAPI 3.1 :",
"ObsiGate exposes a REST API covering the whole application (vaults, files, search,"
" backups, export, AI, sharing, admin), documented in OpenAPI 3.1:",
),
"api_docs_url": (
"<code>/docs</code> — interface Swagger UI pour essayer les requêtes en direct.",
"<code>/docs</code> — Swagger UI to try requests live.",
),
"api_redoc": (
"<code>/redoc</code> — référence alternative plus compacte.",
"<code>/redoc</code> — compact alternative reference.",
),
"api_landing": (
"<code>/api</code> — page de garde regroupant les endpoints par catégorie.",
"<code>/api</code> — landing page grouping endpoints by category.",
),
"api_schema": (
"<code>/openapi.json</code> — le schéma machine, à importer dans Postman ou Insomnia.",
"<code>/openapi.json</code> — the machine schema, import into Postman or Insomnia.",
),
"api_h3_auth": ("Authentification", "Authentication"),
"api_auth": (
"Connectez-vous via <code>POST /api/auth/login</code> pour obtenir un token Bearer (le"
" même jeton est accepté en cookie HttpOnly, ce qui permet aux clients navigateur"
' d\'utiliser <code>credentials: "include"</code>). Toutes les routes'
" <code>/api/*</code> exigent ce jeton sauf mention contraire.",
"Log in via <code>POST /api/auth/login</code> to get a Bearer token (the same token is"
" accepted as an HttpOnly cookie, so browser clients can use"
' <code>credentials: "include"</code>). All <code>/api/*</code> routes require it unless'
" documented otherwise.",
),
"api_h3_mcp": ("Serveur MCP", "MCP server"),
"api_mcp": (
"Les outils de l'assistant IA (lire, lister, chercher, ouvrir, écrire…) sont exposés à"
" tout client MCP (Claude Desktop, Cursor, Cline…) sur"
" <code>https://votre-instance/mcp</code> avec un token d'API. Configuration et exemples :"
" <code>docs/MCP_GUIDE.md</code>.",
"The assistant's tools (read, list, search, open, write…) are exposed to any MCP client"
" (Claude Desktop, Cursor, Cline…) at <code>https://your-instance/mcp</code> with an API"
" token. Setup and examples: <code>docs/MCP_GUIDE.md</code>.",
),
"api_h3_autom": ("Automatisation", "Automation"),
"api_autom": (
"Pour automatiser depuis l'extérieur : <code>GET /api/search?q=…</code> et"
" <code>GET /api/file/{vault}?path=…</code> permettent d'indexer ou relire vos notes dans"
" un autre outil ; les webhooks sortants (section 🪝) évitent le polling.",
"To automate from outside: <code>GET /api/search?q=…</code> and"
" <code>GET /api/file/{vault}?path=…</code> let another tool index or re-read your notes;"
" outgoing webhooks (🪝 section) avoid polling.",
),
# -- Section Multilingue -------------------------------------------------------------------
"lng_how": (
"L'interface est intégralement bilingue français / anglais. Réglages → Profil → Langue :"
" le choix est enregistré sur votre compte et vous suit sur tous les appareils.",
"The interface is fully bilingual FR/EN. Settings → Profile → Language: the choice is"
" stored on your account and follows you across devices.",
),
"lng_scope": (
"Tout est traduit : menus, messages, notifications, et le présent guide. Les réponses de"
" l'assistant IA suivent la langue de vos documents.",
"Everything is translated: menus, messages, notifications, and this guide. AI assistant"
" answers follow the language of your documents.",
),
"lng_export": (
"Les boutons Markdown / PDF de ce guide téléchargent la version dans votre langue.",
"This guide's Markdown / PDF buttons download the version in your language.",
),
# -- Compléments dans sections existantes ---------------------------------------------------
"h3_semantic": ("Recherche sémantique (hybride)", "Semantic search (hybrid)"),
"sem_p1": (
"Activez le bouton « S » de la barre de recherche (ou Alt-S) pour combiner TF-IDF et"
" similarité vectorielle (fusion RRF) : les concepts approchants (« velours » trouve"
" « tissu doux ») remontent mieux.",
"Toggle the \"S\" button in the search bar (or Alt-S) to combine TF-IDF with vector"
" similarity (RRF fusion): near concepts (\"velvet\" finds \"soft fabric\") surface"
" higher.",
),
"sem_p2": (
"Le moteur d'embeddings (modèle multilingue) est optionnel : sans lui, un repli par hash"
" conserve une recherche hybride fonctionnelle. Les vecteurs sont recalculés à chaque"
" indexation du vault.",
"The embedding engine (multilingual model) is optional: without it a hash fallback keeps"
" hybrid search working. Vectors are recomputed on each vault reindex.",
),
"h3_push": ("Notifications web (push)", "Web notifications (push)"),
"push_p1": (
"Autorisez les notifications (bouton 🔔 de l'en-tête) pour être averti des fins de"
" synchronisation hors-ligne et des événements importants. La gestion des abonnements est"
" dans les Configurations.",
"Grant notification permission (🔔 button in the header) to be alerted of offline-sync"
" completions and important events. Subscription management lives in Configuration.",
),
"push_p2": (
"Basée sur la Web Push API (clés VAPID) ; fonctionne sur desktop et PWA mobile, sans"
" service tiers : le serveur émet directement vers les endpoints push des navigateurs.",
"Built on the Web Push API (VAPID keys); works on desktop and mobile PWA with no"
" third-party service: the server sends directly to browser push endpoints.",
),
"h3_panes": ("Vue multi-panneaux (split view)", "Multi-pane split view"),
"panes_p": (
"Le bouton « Diviser » de la barre d'actions ouvre le document dans un panneau jumeau ;"
" empilez plusieurs panneaux pour comparer deux notes ou lire et éditer en parallèle. Les"
" largeurs se règlent au bord des panneaux et sont mémorisées.",
"The \"Split\" button in the action bar opens the document in a twin pane; stack several"
" panes to compare two notes or read and edit side by side. Pane widths drag on the"
" border and are remembered.",
),
"h3_dupe": ("Anti-doublons à l'upload", "Duplicate-proof uploads"),
"dupe_p": (
"L'upload en masse (glisser-déposer un dossier sur la sidebar) compare chaque fichier au"
" contenu existant : un fichier déjà présent est ignoré plutôt que dupliqué avec un"
" suffixe « (1) ». Utile pour restaurer un vault sans créer de doublons.",
"Bulk upload (drag a folder onto the sidebar) compares each file with existing content: an"
" already-present file is skipped rather than duplicated with a \"(1)\" suffix. Handy"
" when restoring a vault.",
),
"h3_pdf": ("Export PDF", "PDF export"),
"pdf_p": (
"Le bouton « PDF » d'un document le rend avec le même moteur que la vue (WeasyPrint) :"
" titres, tableaux, listes et code sont conservés. Depuis un lien public, la route"
" <code>/s/{token}/pdf</code> produit le même PDF.",
"A document's \"PDF\" button renders it with the same engine as the viewer (WeasyPrint):"
" headings, tables, lists and code are preserved. From a public link, the"
" <code>/s/{token}/pdf</code> route produces the same PDF.",
),
"h3_exports": ("Export HTML / ePub / ZIP", "HTML / ePub / ZIP export"),
"exp_p": (
"Le menu « Exporter » propose trois formats : HTML autonome (fichier unique, images"
" incluses), ePub pour les liseuses et, pour un dossier, un bundle Markdown en ZIP —"
" liens et ressources résolus pendant l'export.",
"The \"Export\" menu offers three formats: standalone HTML (single file, images inlined),"
" ePub for e-readers and, for a folder, a Markdown ZIP bundle — links and resources"
" resolved during export.",
),
"h3_mfa": ("MFA : TOTP, WebAuthn, codes de secours", "MFA: TOTP, WebAuthn, recovery codes"),
"mfa_p": (
"Activez la double authentification dans Réglages → Profil : applications TOTP (Authy,"
" Aegis…), clés de sécurité et passkeys (WebAuthn, y compris Windows Hello) et 10 codes"
" de secours à conserver hors ligne. Chaque méthode s'active et se désactive"
" indépendamment.",
"Enable two-factor auth in Settings → Profile: TOTP apps (Authy, Aegis…), security keys"
" and passkeys (WebAuthn, including Windows Hello) and 10 recovery codes to keep offline."
" Each method can be enabled and disabled independently.",
),
"h3_admin": ("Tableau de bord administrateur", "Admin dashboard"),
"admin_p": (
"Le rôle admin ouvre une page dédiée <code>/admin.html</code> (bouton du menu Options) :"
" statut du serveur en direct, utilisateurs, vaults, sessions actives et journal d'audit."
" Le CRUD utilisateurs est aussi disponible dans les Configurations.",
"The admin role unlocks a dedicated <code>/admin.html</code> page (Options menu button):"
" live server status, users, vaults, active sessions and the audit log. User CRUD also"
" lives in Configuration.",
),
# -- Boutons de téléchargement ---------------------------------------------------------------
"dl_md_title": ("Télécharger ce guide en Markdown", "Download this guide as Markdown"),
"dl_pdf_title": ("Télécharger ce guide en PDF", "Download this guide as PDF"),
# -- Export MD/PDF (côté serveur) -------------------------------------------------------------
"export_title": ("Guide d'utilisation ObsiGate", "ObsiGate User Guide"),
"export_footer": (
"Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ;"
" la version la plus récente est toujours dans l'application.",
"Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the"
" latest version always lives in the application.",
),
}
# ---------------------------------------------------------------------------
# Diagramme Mermaid de la section Architecture (également utilisé par l'export)
# ---------------------------------------------------------------------------
ARCH_MERMAID = """flowchart TB
subgraph client["Clients"]
UI["SPA vanilla JS\\n(frontend/js)"]
PWA["PWA hors-ligne\\n(service worker + IndexedDB)"]
DESK["App desktop Tauri\\n(fenêtre native)"]
end
subgraph server["Serveur FastAPI (Python 3.11)"]
API["REST /api\\nJWT + Argon2id"]
IDX["Index recherche\\nTF-IDF + embeddings"]
FS["Accès fichiers\\nwatchdog + safe paths"]
PDF["Rendu markdown\\nmistune + WeasyPrint"]
AI["Assistant IA\\nproviders + outils"]
MCP["Serveur MCP\\n/mcp (HTTP)"]
WS["WebSocket\\ncollab Yjs + SSE"]
WH["Webhooks\\nHMAC-SHA256"]
end
subgraph data["Données"]
V1["Vault 1 (dossier)"]
V2["Vault 2 (dossier)"]
CFG["data/*.json\\nconfig, users, audit"]
BK[".obsigate-backup/\\nbackups horodatés"]
end
UI -- HTTP --> API
PWA -- "cache + queue" --> API
DESK -- embarqué --> API
API --> IDX
API --> FS
API --> PDF
API --> AI
MCP --> AI
WS --> FS
FS --> V1
FS --> V2
IDX --> V1
IDX --> V2
BK --> V1
API --> CFG
API -- événements --> WH"""
# ---------------------------------------------------------------------------
# Constructeurs de HTML (FR inline == CONTENT[key][0] garanti)
# ---------------------------------------------------------------------------
def section_html(title_key: str, sec_id: str, body: str) -> str:
"""Nouvelle <section> complète avec son h2 data-i18n."""
return (
' <section class="help-section" id="%s">\n'
' <h2 data-i18n="guide105.%s">%s</h2>\n'
"%s"
" </section>\n"
"\n" % (sec_id, title_key, CONTENT[title_key][0], body)
)
def _li(key: str) -> str:
return ' <li data-i18n="guide105.%s">%s</li>\n' % (key, CONTENT[key][0])
def _bullets(keys: list) -> str:
return " <ul>\n" + "".join(_li(k) for k in keys) + " </ul>\n"
def _p(key: str) -> str:
return ' <p data-i18n="guide105.%s">%s</p>\n' % (key, CONTENT[key][0])
def _h3(key: str) -> str:
return ' <h3 data-i18n="guide105.%s">%s</h3>\n' % (key, CONTENT[key][0])
def _pair(label_key: str, text_key: str) -> str:
"""<li><strong>Label</strong><span> — texte</span></li> (deux clés i18n)."""
return (
" <li>\n"
' <strong data-i18n="guide105.%s">%s</strong>'
'<span data-i18n="guide105.%s">%s</span>\n'
" </li>\n"
% (label_key, CONTENT[label_key][0], text_key, CONTENT[text_key][0])
)
def _h3p(h3_key: str, *p_keys: str) -> str:
return _h3(h3_key) + "".join(_p(k) for k in p_keys)
# ---------------------------------------------------------------------------
# Les huit nouvelles sections
# ---------------------------------------------------------------------------
SECTION_ARCHITECTURE = section_html(
"nav_architecture",
"help-architecture",
_p("arch_intro")
+ ' <pre class="mermaid-code"><code class="language-mermaid">%s</code></pre>\n' % ARCH_MERMAID
+ _p("arch_diagram_note")
+ _h3("arch_h3_layers")
+ " <ul>\n"
+ _pair("arch_lbl_fe", "arch_fe")
+ _pair("arch_lbl_be", "arch_be")
+ _pair("arch_lbl_realtime", "arch_rt")
+ _pair("arch_lbl_ai", "arch_ai")
+ _pair("arch_lbl_data", "arch_data")
+ _pair("arch_lbl_deploy", "arch_deploy")
+ " </ul>\n"
+ _h3p("arch_h3_flux", "arch_flux"),
)
SECTION_DIAGRAMS = section_html(
"nav_diagrams",
"help-diagrams",
_p("dia_intro")
+ _bullets(["dia_zoom", "dia_fs", "dia_copy", "dia_toggle", "dia_theme"])
+ _p("dia_types")
+ _p("dia_excalidraw_ref"),
)
SECTION_LIBRARY = section_html(
"nav_library",
"help-library",
_h3p("lib_h3_bookmarks", "lib_bookmarks")
+ _h3p("lib_h3_saved", "lib_saved")
+ _h3p("lib_h3_backlinks", "lib_backlinks")
+ _h3p("lib_h3_conflicts", "lib_conflicts")
+ _h3p("lib_h3_attach", "lib_attach"),
)
SECTION_OFFLINE = section_html("nav_offline", "help-offline", _bullets(["off_pwa", "off_edit", "off_sync", "off_watch"]))
SECTION_COLLAB = section_html("nav_collab", "help-collab", _bullets(["col_intro", "col_cursors", "col_save", "col_perm"]))
SECTION_DESKTOP = section_html("nav_desktop", "help-desktop", _bullets(["des_get", "des_wizard", "des_data", "des_native"]))
SECTION_API = section_html(
"nav_api",
"help-api",
_p("api_intro")
+ _bullets(["api_docs_url", "api_redoc", "api_landing", "api_schema"])
+ _h3p("api_h3_auth", "api_auth")
+ _h3p("api_h3_mcp", "api_mcp")
+ _h3p("api_h3_autom", "api_autom"),
)
SECTION_LANG = section_html("nav_languages", "help-languages", _bullets(["lng_how", "lng_scope", "lng_export"]))
# (id de section, HTML complet, id de la section AVANT laquelle insérer)
NEW_SECTIONS: list[tuple[str, str, str]] = [
("help-architecture", SECTION_ARCHITECTURE, "help-interface"),
("help-diagrams", SECTION_DIAGRAMS, "help-edition"),
("help-library", SECTION_LIBRARY, "help-graphe"),
("help-offline", SECTION_OFFLINE, "help-partage"),
("help-collab", SECTION_COLLAB, "help-partage"),
("help-desktop", SECTION_DESKTOP, "help-partage"),
("help-api", SECTION_API, "help-plugins"),
("help-languages", SECTION_LANG, "help-plugins"),
]
# Compléments insérés À LA FIN de sections existantes (avant leur </section>) :
# section id -> bloc HTML
EXTRA_BLOCKS: list[tuple[str, str]] = [
("help-interface", _h3p("h3_push", "push_p1", "push_p2")),
("help-recherche", _h3p("h3_semantic", "sem_p1", "sem_p2")),
("help-fichiers", _h3p("h3_pdf", "pdf_p") + _h3p("h3_exports", "exp_p") + _h3p("h3_dupe", "dupe_p")),
("help-personnalisation", _h3p("h3_panes", "panes_p")),
("help-securite", _h3p("h3_mfa", "mfa_p") + _h3p("h3_admin", "admin_p")),
]
# Entrées TOC à insérer AVANT l'entrée dont l'href est la 3e valeur.
TOC_INSERT_BEFORE: list[tuple[str, str, str]] = [
("nav_architecture", "#help-architecture", "#help-interface"),
("nav_diagrams", "#help-diagrams", "#help-edition"),
("nav_library", "#help-library", "#help-graphe"),
("nav_offline", "#help-offline", "#help-partage"),
("nav_collab", "#help-collab", "#help-partage"),
("nav_desktop", "#help-desktop", "#help-partage"),
("nav_api", "#help-api", "#help-plugins"),
("nav_languages", "#help-languages", "#help-plugins"),
]
# Section « Édition mobile » dédiée (fix BUG-067) : créée par le script
# d'insertion après la section help-edition.
MOBILE_SECTION_TITLE_KEY = "help.nav_mobile_editor"
MOBILE_SECTION_TITLE_FR = "📱 Édition mobile"
+124
View File
@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
"""Insère le nouveau contenu guide #105 dans frontend/index.html.
- 8 nouvelles sections + entrées de TOC (guide_content.py)
- compléments h3 dans 5 sections existantes
- BUG-067 : le bloc « Édition mobile » de help-edition devient la section
dédiée help-mobile-editor (ancre morte → ancre vivante)
- retire l'attribut data-i18n-placeholder dupliqué sur #help-nav-search
Idempotent : refuse de tourner deux fois (détecte guide105.* déjà présent).
Préserve les fins de ligne CRLF de index.html.
"""
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from guide_content import ( # noqa: E402
CONTENT,
EXTRA_BLOCKS,
NEW_SECTIONS,
TOC_INSERT_BEFORE,
)
HTML = Path("C:/dev/git/python/ObsiGate/frontend/index.html")
_CRLF = False
def to_crlf(s: str) -> str:
return s.replace("\n", "\r\n") if _CRLF else s
def main() -> int:
with open(HTML, encoding="utf-8", newline="") as f:
raw = f.read()
global _CRLF
_CRLF = "\r\n" in raw
if _CRLF:
raw = raw.replace("\r\n", "\n") # normaliser ; régénéré à l'écriture
if "guide105." in raw:
print("already inserted — abort")
return 1
# Localise le guide (après la modale de config) : on opère uniquement dans
# la fenêtre help-modal pour ne pas toucher le TOC #cfg-* de la config.
guide_start = raw.index('<div class="editor-modal" id="help-modal">')
head, guide = raw[:guide_start], raw[guide_start:]
# ── Fix BUG-067 : section mobile dédiée ────────────────────────────────
m_h3 = guide.index('<h3 data-i18n="help.mobile_editor_title">')
# le bloc va jusqu'à la fin de la section help-edition
m_sec_end = guide.index("</section>", m_h3)
mobile_block = guide[m_h3:m_sec_end]
guide = guide[:m_h3] + guide[m_sec_end:]
# h3 -> h2, et on emballe en section dédiée
mobile_h2 = mobile_block.replace('<h3 data-i18n="help.mobile_editor_title">',
'<h2 data-i18n="help.mobile_editor_title">', 1)
mobile_h2 = mobile_h2.replace("</h3>", "</h2>", 1)
mobile_section = (
' <section class="help-section" id="help-mobile-editor">\n'
+ mobile_h2.rstrip()
+ "\n </section>\n\n"
)
# insérer après help-edition (donc avant help-graphe)
anchor = guide.index('<section class="help-section" id="help-graphe">')
guide = guide[:anchor] + mobile_section + guide[anchor:]
# ── Compléments h3 dans sections existantes ────────────────────────────
for sec_id, block in EXTRA_BLOCKS:
pat = 'id="%s"' % sec_id
a = guide.index(pat)
b = guide.index("</section>", a)
guide = guide[:b] + to_crlf(block) + guide[b:]
# ── Nouvelles sections ─────────────────────────────────────────────────
for sec_id, html, before_id in NEW_SECTIONS:
anchor = guide.index('<section class="help-section" id="%s">' % before_id)
guide = guide[:anchor] + to_crlf(html) + guide[anchor:]
# ── Entrées TOC ────────────────────────────────────────────────────────
for key, href, before_href in TOC_INSERT_BEFORE:
label = CONTENT[key][0]
entry = to_crlf(
" <li>\n"
' <a href="%s" class="help-nav-link" data-i18n="guide105.%s">%s</a>\n'
" </li>\n" % (href, key, label)
)
needle = 'href="%s"' % before_href
# l'entrée <li> qui contient ce href
i = guide.index(needle)
li_start = guide.rindex("<li>", 0, i)
guide = guide[:li_start] + entry + guide[li_start:]
# ── Anchor #help-ia mort (nav) → #help-ai (id de section réel) ─────────
guide = guide.replace('href="#help-ia"', 'href="#help-ai"')
# ── Attribut dupliqué sur #help-nav-search ─────────────────────────────
guide = guide.replace(
' data-i18n-placeholder="help.search_placeholder" data-i18n-placeholder="help.search_placeholder"',
' data-i18n-placeholder="help.search_placeholder"',
1,
)
out = head + guide
if _CRLF:
out = out.replace("\n", "\r\n")
with open(HTML, "w", encoding="utf-8", newline="") as f:
f.write(out)
# ── Vérifications structurelles ────────────────────────────────────────
d = HTML.read_text(encoding="utf-8")
opens, closes = len(re.findall(r"<section[\s>]", d)), d.count("</section>")
print("section balance:", opens, closes)
hrefs = set(re.findall(r'href="#(help-[a-z-]+)"', d))
ids = set(re.findall(r'id="(help-[a-z-]+)"', d))
missing = sorted(hrefs - ids)
print("dead anchors:", missing or "none")
return 0 if not missing else 2
if __name__ == "__main__":
raise SystemExit(main())

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