Compare commits

...
26 Commits
Author SHA1 Message Date
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
142 changed files with 16976 additions and 2890 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 ─────────────────────────────────────────────────────────
+6 -1
View File
@@ -44,9 +44,13 @@ 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, ~5 min) : reproduit le job CI e2e (port 2029, auth désactivée)
# 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`.
@@ -91,6 +95,7 @@ bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
| 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` |
+490 -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.12.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.23.0**.
---
@@ -14,6 +14,495 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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é
+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.12.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.23.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.12.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.23.0).
---
*Projet : ObsiGate | Version : 2.12.0 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.23.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.12.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.23.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.12.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.23.0).
---
*Project: ObsiGate | Version: 2.12.0 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.23.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.12.0
2.23.0
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
+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 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';"
)
# 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.12.0"
version = "2.23.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.12.0"
version = "2.23.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.12.0",
"version": "2.23.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2 -2
View File
@@ -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` |
+19
View File
@@ -174,6 +174,14 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *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é |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -244,6 +252,17 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 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) |
---
+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).
+15 -13
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.12.0 | **Dernière mise à jour :** 2026-09-18
> **Version :** 2.23.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/`
@@ -187,6 +178,17 @@
| 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) |
---
@@ -194,11 +196,11 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102–104, #92 | ~115 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** |
---
+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.
+1 -1
View File
@@ -67,7 +67,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": "***"}
}}}
```
+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é).
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+676 -321
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';
}
}
+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>
+253 -34
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',
@@ -920,6 +929,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>
@@ -1071,6 +1081,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 +2044,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 +2097,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 = {}) {
+427 -1
View File
@@ -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();
}
});
@@ -826,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();
}
});
@@ -847,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 ---
@@ -1287,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");
@@ -2151,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');
@@ -2188,4 +2526,92 @@ 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');
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 */ });
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();
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();
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();
+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) {
+7
View File
@@ -2080,11 +2080,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 +2212,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) {
+31 -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",
+483 -19
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
@@ -540,13 +541,480 @@ export function navigatePdfToPage(area, page) {
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) {
@@ -596,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;
}
@@ -629,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
@@ -1294,6 +1756,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) {
+256 -10
View File
@@ -380,6 +380,15 @@
"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_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",
@@ -496,7 +505,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",
@@ -569,9 +578,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",
@@ -748,6 +787,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",
@@ -1077,7 +1117,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",
@@ -1280,6 +1320,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",
@@ -1479,6 +1521,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",
@@ -1520,25 +1632,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",
@@ -1583,6 +1691,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",
@@ -1724,11 +1834,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",
@@ -1789,6 +1914,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",
@@ -1903,6 +2041,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",
@@ -1961,5 +2116,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."
}
+257 -11
View File
@@ -380,6 +380,15 @@
"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_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",
@@ -496,7 +505,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",
@@ -539,7 +548,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",
@@ -569,9 +578,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",
@@ -748,6 +787,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",
@@ -1077,7 +1117,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",
@@ -1280,6 +1320,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",
@@ -1479,6 +1521,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",
@@ -1520,25 +1632,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",
@@ -1583,6 +1691,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",
@@ -1724,11 +1834,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",
@@ -1789,6 +1914,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",
@@ -1903,6 +2041,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",
@@ -1961,5 +2116,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."
}
+1472 -44
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 = 'v21';
const SW_VERSION = 'v24';
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.12.0",
"version": "2.23.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())
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""Injecte les clés guide105.* de scripts/guide_content.py dans les locales.
Insertion TEXTUELLE avant la dernière accolade (préserve le formatage exact
des fichiers existants). Idempotent : remplace un bloc guide105.* déjà
présent. Écriture LF (comme les blobs git).
"""
import json
import re
import sys
from pathlib import Path
ROOT = Path("C:/dev/git/python/ObsiGate")
sys.path.insert(0, str(ROOT / "scripts"))
from guide_content import CONTENT # noqa: E402
KEY_RE = re.compile(r'^\s*"guide105\.[a-z0-9_]+"\s*:', re.M)
def merge(locale_file: Path, idx: int) -> None:
raw = locale_file.read_text(encoding="utf-8")
# retire un éventuel ancien bloc (idempotence)
raw = "".join(l for l in raw.splitlines(keepends=True) if not KEY_RE.match(l))
data = json.loads(raw)
entries = [' "guide105.%s": %s' % (k, json.dumps(v[idx], ensure_ascii=False)) for k, v in CONTENT.items()]
block = ",\n".join(entries) + "\n"
i = raw.rstrip().rfind("}")
head = raw[:i].rstrip() # dernière clé existante (sans virgule finale)
new = head + ",\n" + block + "}\n"
parsed = json.loads(new) # doit rester valide
assert len(parsed) == len(data) + len(CONTENT)
locale_file.write_bytes(new.encode("utf-8"))
print(locale_file.name, "->", len(parsed), "keys (+%d guide105)" % len(CONTENT))
merge(ROOT / "frontend/locales/fr.json", 0)
merge(ROOT / "frontend/locales/en.json", 1)
en = json.loads((ROOT / "frontend/locales/en.json").read_bytes().decode("utf-8"))
fr = json.loads((ROOT / "frontend/locales/fr.json").read_bytes().decode("utf-8"))
assert set(en) == set(fr), sorted(set(en) ^ set(fr))[:5]
print("parity OK:", len(en), "keys")
+49
View File
@@ -0,0 +1,49 @@
// Pré-rend les diagrammes Mermaid du guide (#105 PDF) en PNG.
// Usage: node scripts/render_guide_diagram.mjs
// Lit le(s) code(s) Mermaid via --input JSON {sha: code} et écrit
// backend/assets/guide_diagrams/<sha>.png (rendu chromium + mermaid v11 CDN,
// scale 2, fond blanc).
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { chromium } = require('playwright');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'backend', 'assets', 'guide_diagrams');
(async () => {
const jobs = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); // {sha: code}
fs.mkdirSync(OUT_DIR, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1800, height: 1400 }, deviceScaleFactor: 2 });
await page.goto('about:blank');
await page.addScriptTag({ url: 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js' });
await page.waitForFunction(() => typeof window.mermaid !== 'undefined');
for (const [sha, code] of Object.entries(jobs)) {
const out = await page.evaluate(async (src) => {
window.mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose', fontFamily: 'DejaVu Sans, Arial, sans-serif' });
try {
const { svg } = await window.mermaid.render('d_' + Math.random().toString(36).slice(2), src);
const box = document.createElement('div');
box.innerHTML = svg;
box.style.background = 'white';
document.body.replaceChildren(box);
const svgEl = box.querySelector('svg');
if (!svgEl) return { err: 'no svg' };
svgEl.style.maxWidth = 'none';
const r = svgEl.getBoundingClientRect();
return { w: Math.min(Math.ceil(r.width), 2000), h: Math.min(Math.ceil(r.height), 2600), ok: true };
} catch (e) {
return { err: String(e).slice(0, 300) };
}
}, code);
if (out.err) { console.error(sha, 'RENDER ERROR', out.err); process.exitCode = 1; continue; }
const file = path.join(OUT_DIR, sha + '.png');
await page.screenshot({ path: file, clip: { x: 0, y: 0, width: out.w, height: out.h } });
console.log(sha, '->', file, out.w + 'x' + out.h);
}
await browser.close();
})();
+120
View File
@@ -0,0 +1,120 @@
<#
.SYNOPSIS
ObsiGate — E2E locaux (Playwright) sous Windows/PowerShell.
.DESCRIPTION
Équivalent PowerShell de `scripts/run-e2e-local.sh`, pour les postes Windows
où `bash` n'est pas utilisable (WSL indisponible, git-bash bloqué par une
politique de contrôle d'application). Démarre le backend nativement via
uvicorn (auth désactivée, fixtures TestVault/TestDir, port 2029 — mêmes
conditions que le job CI `e2e`), lance la suite Playwright puis nettoie.
.PARAMETER PlaywrightArgs
Arguments transmis à `npx playwright test`, ex. `-g "image viewer"`,
`--headed`.
.EXAMPLE
./scripts/run-e2e-local.ps1
./scripts/run-e2e-local.ps1 -g "BUG-072"
./scripts/run-e2e-local.ps1 --headed
#>
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$PlaywrightArgs
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $Root
$Port = if ($env:E2E_PORT) { $env:E2E_PORT } else { "2029" }
$BaseUrl = "http://127.0.0.1:$Port"
$ServerLog = "data/e2e-server.log"
$ServerErrLog = "data/e2e-server.err.log"
function Assert-Command([string]$Name, [string]$Hint) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "[ERR] $Name introuvable. $Hint"
}
}
Assert-Command "uv" "Installez-le : https://docs.astral.sh/uv/"
Assert-Command "npx" "Installez Node.js (>= 20)."
# ----- Venv Python 3.11 (créé une seule fois) -----
$Python = ".venv-e2e/Scripts/python.exe"
if (-not (Test-Path -LiteralPath $Python)) {
Write-Host "[INFO] Création du venv .venv-e2e (Python 3.11)..."
uv venv .venv-e2e --python 3.11
uv pip install --python $Python -r backend/requirements.txt
}
# ----- Port déjà occupé ? -----
try {
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
Write-Host "[ERR] Quelque chose répond déjà sur $BaseUrl."
Write-Host " Arrêtez-le ou choisissez un autre port : `$env:E2E_PORT=2030; ./scripts/run-e2e-local.ps1"
exit 1
} catch {
# port libre
}
# ----- Démarrage du serveur (mêmes conditions que le CI e2e) -----
Write-Host "[INFO] Démarrage d'ObsiGate sur $BaseUrl (auth désactivée)..."
New-Item -ItemType Directory -Force -Path "data" | Out-Null
$env:OBSIGATE_AUTH_ENABLED = "false"
$env:VAULT_1_NAME = "TestVault"
$env:VAULT_1_PATH = (Resolve-Path -LiteralPath "test_vault").Path
$env:DIR_1_NAME = "TestDir"
$env:DIR_1_PATH = (Resolve-Path -LiteralPath "test_dir").Path
$server = Start-Process -FilePath $Python `
-ArgumentList "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", $Port `
-RedirectStandardOutput $ServerLog -RedirectStandardError $ServerErrLog `
-PassThru -WindowStyle Hidden
$exitCode = 1
try {
# ----- Attente du health check (30 s max, comme le CI) -----
$ready = $false
for ($i = 0; $i -lt 30; $i++) {
try {
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
$ready = $true
break
} catch {
if ($server.HasExited) {
Write-Host "[ERR] Le serveur a quitté prématurément. Log :"
Get-Content -LiteralPath $ServerErrLog -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
Start-Sleep -Seconds 1
}
}
if (-not $ready) {
Write-Host "[ERR] Serveur injoignable sur $BaseUrl. Log :"
Get-Content -LiteralPath $ServerErrLog -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
Write-Host "[OK] Serveur prêt."
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
npx playwright install chromium
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
$env:BASE_URL = $BaseUrl
& npx playwright test --project=chromium-desktop @PlaywrightArgs
$exitCode = $LASTEXITCODE
} finally {
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
# uvicorn (via uv) peut lancer un interpréteur enfant : tuer le groupe resté sur le port.
Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
}
exit $exitCode
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
Voici les trois niveaux de résumé pour le document **"Agents IA — Panorama Complet 2026"** :
---
---
### **1. En une phrase**
Ce document est **un panorama exhaustif et structuré des 72 agents IA disponibles en 2026**, classés par catégories, avec leurs descriptions, statuts, installations, utilisations et comparatifs techniques.
---
---
### **2. En un paragraphe**
Le document **"Agents IA — Panorama Complet 2026"** est une **référence complète** pour comprendre, installer et utiliser les agents IA du marché. Il recense **72 agents** répartis en **16 catégories** (ex. : agents locaux, poids lourds open source, trésors open source, local-first, propriétaires, etc.). Chaque agent est détaillé avec sa **description**, ses **liens officiels** (page principale et documentation), son **statut** (actif, stagnant ou fermé), ses **étapes d'installation** (commandes précises) et son **mode d'utilisation** (exemples de commandes). Le document inclut également un **tableau récapitulatif comparatif** (stars GitHub, type, compatibilité locale, etc.), des **recommandations**, une **matrice de décision**, ainsi que des sections dédiées aux **benchmarks**, **coûts**, **sécurité** et **glossaire**. Les sources sont vérifiées et mises à jour en **août 2026**.
---
---
### **3. En une page**
---
#### **Contexte**
Le document **"Agents IA — Panorama Complet 2026"**, rédigé par **Bruno Charest**, est une **ressource de référence** pour les développeurs, chercheurs et utilisateurs souhaitant explorer l’écosystème des agents IA. Il s’appuie sur des sources officielles (GitHub, arXiv, blogs spécialisés) et est **mis à jour au 12 août 2026**. L’objectif est de fournir une **vue d’ensemble structurée** des agents disponibles, leurs fonctionnalités, et leurs spécificités techniques.
---
#### **Structure et contenu**
Le document est organisé en **23 sections**, dont :
- **Tableau récapitulatif comparatif** : Une vue synthétique des 72 agents, avec des colonnes pour le **nom**, la **catégorie**, le **type** (OSS, propriétaire, etc.), le **nombre d’étoiles GitHub**, le **statut** (🟢 actif, 🟡 stagnant, 🔴 fermé), la **compatibilité locale** (✅/~/❌), et les **méthodes d’installation**.
- **Catégories d’agents** :
- **Agents installés localement** (ex. : Claude Code, Hermes Agent, Atomic Agent, Pi, Tiny Agents, NemoClaw).
- **Prime Agent** : Agent open-source basé sur le modèle **RLM** (Recursive Language Model), avec des fonctionnalités avancées comme la mémoire persistante et l’auto-amélioration.
- **Poids lourds open source** (ex. : OpenCode, Claw Code, Gemini CLI, Codex CLI, OpenHands, Goose, Aider).
- **Trésors open source** : Agents optimisés pour des cas d’usage spécifiques (ex. : jcode en Rust, agentty en C++26, NullClaw en Zig).
- **Agents local-first** : Solutions conçues pour fonctionner **100 % localement** (ex. : openyak, Kun, iPolloWork).
- **Écosystème OpenClaw** : Une famille d’agents légers et sécurisés (ex. : OpenClaw, ZeroClaw, NanoClaw).
- **Agents propriétaires** : Outils fermés mais populaires (ex. : Cursor CLI, Warp, GitHub Copilot CLI, Devin).
- **Sections transverses** :
- **Orchestrateurs & harnesses** : Outils pour gérer des workflows multi-agents.
- **Infrastructure & outils** : Solutions pour déployer et superviser des agents.
- **Benchmarks & évaluation** : Méthodes pour tester les performances des agents.
- **Coût & modèles** : Analyse des coûts associés (abonnements, inférence locale, etc.).
- **Sécurité & gouvernance** : Bonnes pratiques pour un usage sécurisé.
- **Glossaire** : Définitions des termes techniques (ex. : RLM, MCP, LSP).
---
#### **Points clés**
- **Diversité des agents** : Le document couvre des agents **génériques** (ex. : Claude Code, OpenCode) et **spécialisés** (ex. : SWE-agent pour résoudre des issues GitHub, Plandex pour le "plan-first").
- **Statuts variés** : Certains projets sont **actifs** (ex. : Prime Agent, OpenCode), tandis que d’autres sont **fermés** (ex. : Claw Code, transformé en musée) ou **stagnants** (ex. : Plandex, Groq Code CLI).
- **Installation et utilisation** : Chaque agent est accompagné de **commandes précises** pour son installation (ex. : `npm install -g @anthropic-ai/claude-code` pour Claude Code) et son utilisation (ex. : `claude "explique ce repo"`).
- **Approches techniques** :
- **Local-first** : Agents conçus pour fonctionner **hors ligne** (ex. : Atomic Agent, NullClaw).
- **Multi-modèles** : Compatibilité avec plusieurs fournisseurs de LLM (ex. : Hermes Agent supporte 300+ providers).
- **Sandboxing** : Solutions pour exécuter du code en toute sécurité (ex. : NemoClaw avec des politiques réseau strictes).
- **Comparatifs** : Le tableau récapitulatif permet de **filtrer rapidement** les agents en fonction de critères comme la compatibilité locale ou le nombre d’étoiles GitHub.
---
#### **Conclusions**
Ce document est une **mine d’informations** pour :
- **Choisir un agent** en fonction de ses besoins (ex. : développement local, collaboration en équipe, résolution de bugs).
- **Comprendre les tendances** du marché (ex. : montée en puissance des agents **local-first**, adoption croissante des modèles **open-source**).
- **Installer et configurer** un agent rapidement grâce aux **instructions pas-à-pas**.
- **Comparer les solutions** via des critères objectifs (statut, stars GitHub, compatibilité locale).
Il s’adresse aussi bien aux **débutants** qu’aux **experts**, avec des sections adaptées à chaque niveau de connaissance. Les **sources citées** (GitHub, arXiv, blogs) garantissent la **fiabilité** des informations.
+26
View File
@@ -0,0 +1,26 @@
# Beloeil et ses Villains
Plongez dans un univers où les légendes locales sortent de l’ombre pour hanter les vivants. Entre les ruelles pavées, usées par des siècles de secrets, et les forêts mystérieuses, dont les arbres semblent murmurer des avertissements aux voyageurs égarés, **Beloeil** est une ville où chaque pierre, chaque souffle de vent, porte le poids d’un passé maudit.
---
### Les Gardiens de l’Obscurité
Les méchants de Beloeil ne sont pas de simples antagonistes. Ils sont les **gardiens d’un héritage sombre**, tissé de magie ancienne, de rivalités familiales et de serments brisés. Leur présence imprègne la ville d’une atmosphère où la frontière entre le bien et le mal s’estompe, où chaque choix semble mener à une nouvelle malédiction.
- **Le Comte Déchu** : Son armure rouillée grince encore entre les murs de son château en ruines. Ses anciens sujets, transformés en spectres, murmurent des prières pour une rédemption qui ne viendra jamais. Son règne, autrefis glorieux, n’est plus qu’un écho de trahisons et de regrets.
- **La Sorcière des Marais** : Ses potions ne se contentent pas d’empoisonner les corps, elles **corrompent les âmes**. Les villageois qui osent s’aventurer près de son repaire reviennent transformés, assoiffés de vengeance, hantés par des voix qu’ils ne reconnaissent plus.
- **Les Frères Jumeaux** : Autrefois unis par un pacte de sang, l’un a trahi l’autre par ambition. Leur malédiction les lie désormais dans une **danse macabre**, condamnés à se combattre pour l’éternité, leurs lames s’entrechoquant dans un ballet sans fin.
---
### Une Ville de Secrets et de Retournements
Beloeil est un lieu où **les apparences trompent**. Le héros tant admiré pourrait cacher un passé taché de crimes. La malédiction qui pèse sur la ville pourrait trouver son origine dans un **amour interdit** entre une fée et un mortel. Chaque recoin cache une intrigue, chaque murmure un avertissement.
Le surnaturel y est omniprésent : sorts oubliés, artefacts maudits, créatures légendaires. **La magie et l’horreur s’y mêlent**, plongeant les lecteurs dans un monde où chaque page révèle un nouveau mystère, une nouvelle terreur.
---
### Une Histoire à Écrire
Beloeil est une toile de fond parfaite pour une narration **riche en intrigues complexes et en retournements inattendus**. Que ce soit à travers des quêtes désespérées, des alliances fragiles ou des trahisons imprévisibles, cette ville offre un terrain fertile pour explorer **l’obscurité de l’âme humaine et la puissance des légendes**.
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
# Nouveau fichier dans Dir
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# PostgreSQL backup script for HabitForge
set -e
# Configuration
BACKUP_DIR="/backups"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_FILE="${BACKUP_DIR}/habitforge_backup_${TIMESTAMP}.sql.gz"
RETENTION_DAYS=7
# Database settings (from environment)
DB_HOST="${POSTGRES_HOST:-db}"
DB_PORT="${POSTGRES_PORT:-5432}"
DB_NAME="${POSTGRES_DB:-habitforge}"
DB_USER="${POSTGRES_USER:-habitforge}"
# Create backup directory if it doesn't exist
mkdir -p "${BACKUP_DIR}"
echo "Starting backup at $(date)"
# Perform backup
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump \
-h "${DB_HOST}" \
-p "${DB_PORT}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
--format=plain \
--no-owner \
--no-acl | gzip > "${BACKUP_FILE}"
# Check if backup was successful
if [ $? -eq 0 ]; then
echo "Backup completed successfully: ${BACKUP_FILE}"
# Get file size
SIZE=$(du -h "${BACKUP_FILE}" | cut -f1)
echo "Backup size: ${SIZE}"
# Remove old backups (older than RETENTION_DAYS)
echo "Removing backups older than ${RETENTION_DAYS} days..."
find "${BACKUP_DIR}" -name "habitforge_backup_*.sql.gz" -type f -mtime +${RETENTION_DAYS} -delete
# List remaining backups
echo "Current backups:"
ls -lh "${BACKUP_DIR}"/habitforge_backup_*.sql.gz 2>/dev/null || echo "No backups found"
else
echo "Backup failed!"
exit 1
fi
echo "Backup finished at $(date)"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""
Script to import Zepp health data from export-zepp directory into HabitForge database.
Usage: python scripts/import_zepp_data.py [--user-id USER_ID]
"""
import sys
import os
import argparse
# Add parent directory to path to import backend modules
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from backend.integrations.import_zepp import main as import_main, USER_ID as DEFAULT_USER_ID
from backend.integrations import import_zepp
def run_import(user_id: int = None):
"""Run the Zepp data import."""
if user_id:
# Override the default user ID
import_zepp.USER_ID = user_id
print(f"Importing data for user ID: {user_id}")
else:
print(f"Importing data for default user ID: {DEFAULT_USER_ID}")
import_main()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Import Zepp health data into HabitForge")
parser.add_argument(
"--user-id",
type=int,
help="User ID to import data for (default: 1)",
default=None
)
args = parser.parse_args()
run_import(args.user_id)
+162
View File
@@ -0,0 +1,162 @@
"""
Migration script from SQLite to PostgreSQL.
This script migrates data from the existing SQLite database to PostgreSQL.
Usage:
python scripts/migrate_to_postgres.py
Prerequisites:
- PostgreSQL database must be running
- DATABASE_URL environment variable must point to PostgreSQL
- SQLite database must exist at data/habitforge.db
"""
import os
import sys
from datetime import datetime
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from backend import models
from backend.database import Base, SessionLocal as SQLiteSessionLocal
# PostgreSQL connection
POSTGRES_URL = os.getenv("DATABASE_URL", "postgresql://habitforge:habitforge@localhost:5432/habitforge")
def migrate_data():
"""Migrate data from SQLite to PostgreSQL."""
print("Starting migration from SQLite to PostgreSQL...")
# Create PostgreSQL engine
postgres_engine = create_engine(POSTGRES_URL)
PostgresSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=postgres_engine)
# Create tables in PostgreSQL
print("Creating tables in PostgreSQL...")
Base.metadata.create_all(bind=postgres_engine)
# Get SQLite session
sqlite_session = SQLiteSessionLocal()
# Get PostgreSQL session
postgres_session = PostgresSessionLocal()
try:
# Migrate Users
print("Migrating Users...")
users = sqlite_session.query(models.User).all()
for user in users:
new_user = models.User(
id=user.id,
username=user.username,
hashed_password=user.hashed_password
)
postgres_session.add(new_user)
postgres_session.commit()
print(f"Migrated {len(users)} users")
# Migrate Challenges
print("Migrating Challenges...")
challenges = sqlite_session.query(models.Challenge).all()
for challenge in challenges:
new_challenge = models.Challenge(
id=challenge.id,
name=challenge.name,
period_type=challenge.period_type,
daily_target=challenge.daily_target,
start_date=challenge.start_date,
end_date=challenge.end_date,
display_order=challenge.display_order,
user_id=challenge.user_id,
icon=challenge.icon,
image_url=challenge.image_url,
description=challenge.description,
unit_type=challenge.unit_type,
rest_days=challenge.rest_days,
frequency=challenge.frequency
)
postgres_session.add(new_challenge)
postgres_session.commit()
print(f"Migrated {len(challenges)} challenges")
# Migrate Tracking
print("Migrating Tracking...")
trackings = sqlite_session.query(models.Tracking).all()
for tracking in trackings:
new_tracking = models.Tracking(
id=tracking.id,
challenge_id=tracking.challenge_id,
date=tracking.date,
reps=tracking.reps,
completed=tracking.completed,
notes=tracking.notes
)
postgres_session.add(new_tracking)
postgres_session.commit()
print(f"Migrated {len(trackings)} trackings")
# Migrate DailyHealthMetrics
print("Migrating DailyHealthMetrics...")
metrics = sqlite_session.query(models.DailyHealthMetrics).all()
for metric in metrics:
new_metric = models.DailyHealthMetrics(
id=metric.id,
user_id=metric.user_id,
date=metric.date,
step_count=metric.step_count,
calories_burned=metric.calories_burned,
distance_meters=metric.distance_meters,
sleep_duration_minutes=metric.sleep_duration_minutes,
deep_sleep_minutes=metric.deep_sleep_minutes,
light_sleep_minutes=metric.light_sleep_minutes,
rem_sleep_minutes=metric.rem_sleep_minutes,
awake_duration_minutes=metric.awake_duration_minutes,
avg_heart_rate=metric.avg_heart_rate,
min_heart_rate=metric.min_heart_rate,
max_heart_rate=metric.max_heart_rate,
avg_spo2=metric.avg_spo2,
pai_score=metric.pai_score,
weight=metric.weight,
resting_heart_rate=metric.resting_heart_rate,
hrv=metric.hrv
)
postgres_session.add(new_metric)
postgres_session.commit()
print(f"Migrated {len(metrics)} daily health metrics")
print("\nMigration completed successfully!")
print(f"Total records migrated:")
print(f" - Users: {len(users)}")
print(f" - Challenges: {len(challenges)}")
print(f" - Trackings: {len(trackings)}")
print(f" - DailyHealthMetrics: {len(metrics)}")
except Exception as e:
print(f"\nError during migration: {e}")
postgres_session.rollback()
raise
finally:
sqlite_session.close()
postgres_session.close()
postgres_engine.dispose()
if __name__ == "__main__":
# Check if SQLite database exists
if not os.path.exists("data/habitforge.db"):
print("Error: SQLite database not found at data/habitforge.db")
sys.exit(1)
# Confirm migration
print("This will migrate data from SQLite to PostgreSQL.")
print(f"PostgreSQL URL: {POSTGRES_URL}")
response = input("Do you want to continue? (yes/no): ")
if response.lower() != "yes":
print("Migration cancelled.")
sys.exit(0)
migrate_data()
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Production startup script for HabitForge
set -e
echo "Starting HabitForge in production mode..."
# Wait for database to be ready
echo "Waiting for database..."
while ! pg_isready -h ${POSTGRES_HOST:-db} -U ${POSTGRES_USER:-habitforge} -d ${POSTGRES_DB:-habitforge} > /dev/null 2>&1; do
echo "Database is unavailable - sleeping"
sleep 1
done
echo "Database is ready!"
# Run migrations
echo "Running database migrations..."
alembic upgrade head
# Start Gunicorn
echo "Starting Gunicorn..."
exec gunicorn backend.main:app \
--workers ${GUNICORN_WORKERS:-4} \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--access-logfile - \
--error-logfile - \
--log-level ${LOG_LEVEL:-info}
+281
View File
@@ -0,0 +1,281 @@
{
"type": "excalidraw",
"version": 2,
"elements": [
{
"id": "E_M7axF81tmVRs3_iSNTB",
"type": "rectangle",
"x": 474,
"y": 146.33334350585938,
"width": 415.3333740234375,
"height": 216.66665649414062,
"angle": 0,
"strokeColor": "#e03131",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a0",
"roundness": {
"type": 3
},
"seed": 975148614,
"version": 75,
"versionNonce": 1779287411,
"isDeleted": false,
"boundElements": [
{
"id": "9M-JFwSrjH6KSHIaBvvTI",
"type": "arrow"
}
],
"updated": 1789701383205,
"created": 1789695569357,
"link": null,
"locked": false
},
{
"id": "r0qk76e8Zwvzg03cCJTet",
"type": "diamond",
"x": 602,
"y": 154.33334350585938,
"width": 195.3333740234375,
"height": 202,
"angle": 0,
"strokeColor": "#f08c00",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a1",
"roundness": {
"type": 2
},
"seed": 1824592326,
"version": 88,
"versionNonce": 1410387869,
"isDeleted": false,
"boundElements": [],
"updated": 1789701386097,
"created": 1789695572009,
"link": null,
"locked": false
},
{
"id": "9KK6ZVdnduPeYFFFCFAsy",
"type": "ellipse",
"x": 980.6668701171875,
"y": 170.0000457763672,
"width": 162.66668701171875,
"height": 141.3333740234375,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a2",
"roundness": {
"type": 2
},
"seed": 1669693053,
"version": 190,
"versionNonce": 1391288573,
"isDeleted": false,
"boundElements": [
{
"id": "9M-JFwSrjH6KSHIaBvvTI",
"type": "arrow"
}
],
"updated": 1789701376494,
"link": null,
"locked": false
},
{
"id": "9M-JFwSrjH6KSHIaBvvTI",
"type": "arrow",
"x": 895.3334350585938,
"y": 248.66673278808594,
"width": 82.66668701171875,
"height": 3.333343505859375,
"angle": 0,
"strokeColor": "#e03131",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a3",
"roundness": {
"type": 2
},
"seed": 1977221331,
"version": 35,
"versionNonce": 23426611,
"isDeleted": false,
"boundElements": null,
"updated": 1789701380015,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
82.66668701171875,
-3.333343505859375
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "E_M7axF81tmVRs3_iSNTB",
"focus": 0.022412363413033574,
"gap": 6.00006103515625
},
"endBinding": {
"elementId": "9KK6ZVdnduPeYFFFCFAsy",
"focus": -0.017303733989364446,
"gap": 2.836434396181596
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false
}
],
"appState": {
"showWelcomeScreen": false,
"theme": "dark",
"collaborators": {},
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#f08c00",
"currentItemRoundness": "round",
"currentItemArrowType": "round",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"currentHoveredFontFamily": null,
"cursorButton": "up",
"activeEmbeddable": null,
"newElement": null,
"editingTextElement": null,
"editingGroupId": null,
"editingLinearElement": null,
"activeTool": {
"type": "selection",
"customType": null,
"locked": false,
"lastActiveTool": {
"type": "selection",
"customType": null,
"locked": false,
"lastActiveTool": null
}
},
"penMode": false,
"penDetected": false,
"errorMessage": null,
"exportBackground": true,
"exportScale": 1,
"exportEmbedScene": false,
"exportWithDarkMode": false,
"fileHandle": null,
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"isBindingEnabled": true,
"defaultSidebarDockedPreference": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-2026-09-17-2148",
"contextMenu": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"openDialog": null,
"pasteDialog": {
"shown": false,
"data": null
},
"previousSelectedElementIds": {
"E_M7axF81tmVRs3_iSNTB": true
},
"resizingElement": null,
"scrolledOutside": false,
"scrollX": -23.33343505859375,
"scrollY": 101.99995422363281,
"selectedElementIds": {
"r0qk76e8Zwvzg03cCJTet": true
},
"hoveredElementIds": {},
"selectedGroupIds": {},
"selectedElementsAreBeingDragged": false,
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"stats": {
"open": false,
"panels": 3
},
"startBoundElement": null,
"suggestedBindings": [],
"frameRendering": {
"enabled": true,
"clip": true,
"name": true,
"outline": true
},
"frameToHighlight": null,
"editingFrame": null,
"elementsToHighlight": null,
"toast": null,
"viewBackgroundColor": "#ffffff",
"zenModeEnabled": false,
"zoom": {
"value": 1
},
"viewModeEnabled": false,
"pendingImageElementId": null,
"showHyperlinkPopup": false,
"selectedLinearElement": null,
"snapLines": [],
"originSnapOffset": null,
"objectsSnapModeEnabled": false,
"userToFollow": null,
"followedBy": {},
"isCropping": false,
"croppingElementId": null,
"searchMatches": [],
"offsetLeft": 0,
"offsetTop": 0,
"width": 1280,
"height": 800
},
"files": {}
}
+30 -1
View File
@@ -1,3 +1,32 @@
---
titre: ObsiGate — Analyse complète & Recommandations V1.1
auteur: Hermes-Claw
tags:
- analyse
- obsigate
- recommandations
- sécurité
- roadmap
- audit
- v1.1
aliases:
- ObsiGate Analyse
- Audit ObsiGate V1.1
catégorie: Analyse Technique
NomDeVoute: TestVault
Description: Analyse complète et recommandations pour ObsiGate, incluant fonctionnalités, forces, points d'amélioration, roadmap et checklist de professionnalisation.
creation_date: 2026-05-25T00:00:00+02:00
modification_date: 2026-10-07T12:00:00+02:00
status: finalisé
publish: true
favoris: true
template: false
task: false
archive: false
draft: false
private: false
---
# ObsiGate — Analyse complète & Recommandations V1.1
> **Date :** 2026-05-25 | **Analyste :** Hermes-Claw + Audit Code Source | **Version testée :** 1.4.0 (backend) / 1.5.0 (frontend) — http://openclaw1.dev.home:2020
@@ -381,4 +410,4 @@ TOTP ou WebAuthn pour l'accès admin.
---
*Analyse réalisée le 2026-05-25 — Révisée V1.1 après audit complet du code source*
*Document : ANALYSE_REVIEW.md*
*Document : ANALYSE_REVIEW.md*
Binary file not shown.
+24 -8
View File
@@ -7,16 +7,32 @@ publish: true
date: 2025-01-15
---
# Bienvenue dans le vault de test
# 📌 **Bienvenue dans TestVault**
Ceci est un document de test pour [[ObsiGate]].
**TestVault** est votre espace centralisé pour organiser, structurer et optimiser la gestion de vos notes et connaissances. Conçue pour allier **efficacité** et **simplicité**, cette voute vous permet de travailler de manière intuitive tout en bénéficiant d'outils puissants.
## Sections
---
- [[Projets/Projet Alpha]] - Un projet en cours
- [[Notes/Configuration serveur]] - Documentation technique
- [[Recettes/Pâtes carbonara]] - Une recette
## 🚀 **Fonctionnalités clés**
## Tags
✅ **Gestion intelligente des notes**
- Créez, modifiez et organisez vos notes en toute simplicité.
- Accédez rapidement à vos informations grâce à une structure claire.
✅ **Organisation avancée**
- Utilisez des **tags** et des **catégories** pour classer vos notes de manière logique.
- Retrouvez vos informations en un clin d'œil.
✅ **Recherche rapide et efficace**
- Trouvez ce dont vous avez besoin en quelques secondes.
✅ **Synchronisation multi-appareils**
- Accédez à vos notes où que vous soyez, sur tous vos appareils.
---
## 📂 **Structure de la voute**
Cette voute est conçue pour être **modulaire** et **évolutive**. Vous pouvez l'adapter selon vos besoins pour une expérience personnalisée.
---
#accueil #important #test
+214 -13
View File
@@ -10,6 +10,35 @@ title: Docker Guide
# Docker Guide
## Installation de Docker
### Sur Linux (Debian/Ubuntu)
```bash
# Mettre à jour les paquets
sudo apt update
# Installer les dépendances
sudo apt install -y apt-transport-https ca-certificates curl gnupg lsb-release
# Ajouter la clé GPG officielle de Docker
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Ajouter le dépôt Docker
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Installer Docker Engine
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io
# Vérifier l'installation
sudo docker run hello-world
```
### Sur macOS/Windows
- Télécharger [Docker Desktop](https://www.docker.com/products/docker-desktop/) et suivre les instructions.
---
## Commandes essentielles
```bash
@@ -20,36 +49,208 @@ docker ps -a
docker build -t myapp:latest .
# Lancer un conteneur
docker run -d -p 8080:80 myapp:latest
docker run -d -p 8080:80 --name my_container myapp:latest
# Voir les logs
docker logs -f container_name
docker logs -f my_container
# Arrêter un conteneur
docker stop my_container
# Supprimer un conteneur
docker rm my_container
# Supprimer une image
docker rmi myapp:latest
```
## Docker Compose
---
```yaml
version: "3.9"
services:
web:
image: nginx:alpine
ports:
- "80:80"
## Réseaux Docker
Docker propose plusieurs types de réseaux pour isoler ou connecter vos conteneurs :
| Type | Description | Exemple d'utilisation |
|------------|--------------------------------------|-------------------------------------|
| **bridge** | Réseau par défaut pour les conteneurs | `docker run -p 8080:80 myapp` |
| **host** | Conteneur utilise l'IP de l'hôte | `docker run --network host myapp` |
| **overlay**| Pour les swarms multi-hôtes | Utilisé avec `docker stack deploy` |
**Exemple : Créer un réseau personnalisé**
```bash
docker network create my_network
docker run -d --network my_network --name db redis
```
---
## Volumes
Les volumes permettent de persister les données :
- **Named volumes** : gérés par Docker
```bash
docker volume create my_volume
docker run -v my_volume:/data myapp
```
- **Bind mounts** : montage direct depuis l'hôte
```bash
docker run -v /chemin/absolu:/data myapp
```
- **tmpfs** : stockage en mémoire
```bash
docker run --tmpfs /data myapp
```
Voir aussi : [[Proxmox Setup]]
---
## Docker Compose
Exemple de fichier `docker-compose.yml` avec variables d'environnement et dépendances :
```yaml
version: "3.9"
services:
web:
image: nginx:alpine
ports:
- "80:80"
environment:
- NGINX_ENV=production
depends_on:
- redis
networks:
- frontend
redis:
image: redis:alpine
volumes:
- redis_data:/data
networks:
- frontend
volumes:
redis_data:
networks:
frontend:
driver: bridge
```
**Commandes utiles**
```bash
# Démarrer les services
docker-compose up -d
# Arrêter les services
docker-compose down
# Voir les logs
docker-compose logs -f
```
---
## Dockerfile : Exemple commenté
Voici un exemple de `Dockerfile` pour une application Python (FastAPI) :
```dockerfile
# Utiliser une image officielle Python comme base
FROM python:3.9-slim
# Définir le répertoire de travail
WORKDIR /app
# Copier les fichiers de dépendances
COPY requirements.txt .
# Installer les dépendances
RUN pip install --no-cache-dir -r requirements.txt
# Copier le reste du code source
COPY . .
# Exposer le port 8000
EXPOSE 8000
# Commande pour lancer l'application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
**Bonnes pratiques pour un Dockerfile** :
- Utiliser des images `slim` ou `alpine` pour réduire la taille.
- Multi-stage builds pour les applications compilées (Go, Rust, etc.).
- Toujours inclure un fichier `.dockerignore`.
---
## Sécurité
### Bonnes pratiques
- **Éviter de lancer des conteneurs en tant que `root`** :
```bash
docker run --user 1000:1000 myapp
```
- **Rendre le système de fichiers en lecture seule** :
```bash
docker run --read-only myapp
```
- **Limiter les capacités du conteneur** :
```bash
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
```
- **Scanner les images pour des vulnérabilités** :
Utiliser des outils comme [Trivy](https://github.com/aquasecurity/trivy) ou [Clair](https://github.com/quay/clair).
---
## Nettoyage des ressources
Pour libérer de l'espace, supprimez les ressources inutilisées :
```bash
# Supprimer les conteneurs arrêtés
docker container prune
# Supprimer les images inutilisées
docker image prune -a
# Supprimer les volumes inutilisés
docker volume prune
# Supprimer les réseaux inutilisés
docker network prune
# Nettoyer TOUT (attention, irréversible !)
docker system prune -a --volumes
```
---
## Bonnes pratiques
- [ ] Utiliser des images Alpine
- [ ] Multi-stage builds
- [x] Utiliser des images Alpine ou `slim`
- [x] Multi-stage builds pour les applications compilées
- [x] Fichier `.dockerignore`
- [x] Un processus par conteneur
- [x] Un processus par conteneur
- [x] Limiter les ressources (CPU/mémoire) avec `--memory` et `--cpus`
- [x] Utiliser des secrets pour les variables sensibles (ex: `--env-file` ou `docker secret`)
---
## Ressources utiles
- [Documentation officielle Docker](https://docs.docker.com/)
- [Docker Curriculum](https://docker-curriculum.com/)
- [Awesome Docker](https://github.com/veggiemonk/awesome-docker)
- [Tutoriel Docker Compose](https://docs.docker.com/compose/)
Voir aussi : [[Proxmox Setup]]
+10
View File
@@ -0,0 +1,10 @@
// Démonstration JavaScript pour le vault de test ObsiGate
const API_URL = "http://localhost:2020/api";
async function fetchVaults() {
const res = await fetch(`${API_URL}/vaults`);
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
}
fetchVaults().then((v) => console.log("vaults:", v));
+12
View File
@@ -0,0 +1,12 @@
// Démonstration TypeScript pour le vault de test ObsiGate
interface Vault {
name: string;
files: number;
}
function summarize(vaults: Vault[]): string {
const total = vaults.reduce((acc, v) => acc + v.files, 0);
return `${vaults.length} vaults, ${total} fichiers`;
}
export default summarize;
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Script de démonstration pour le vault de test ObsiGate."""
import os
import sys
import json
from pathlib import Path
def main(argv):
print("hello from ObsiGate test vault") # commentaire
data = {"vaults": ["IT", "Perso"], "count": 2}
print(json.dumps(data, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+13
View File
@@ -0,0 +1,13 @@
import unittest
class TestDemo(unittest.TestCase):
def test_true_is_true(self):
self.assertTrue(True) # trivial test
def test_number(self):
self.assertEqual(2 + 2, 4)
if __name__ == "__main__":
unittest.main()
+16 -1
View File
@@ -1,7 +1,22 @@
---
title: Pizza Maison
tags: [recette, pizza, rapide]
tags: [recette, pizza, rapide, cuisine maison]
date: 2025-03-15
modification_date: 2025-10-31T14:30:00+01:00
aliases: [pizza maison, recette pizza]
catégorie: Recettes
NomDeVoute: TestVault
Description: Recette détaillée pour préparer une pizza maison avec pâte, sauce tomate et garnitures variées.
auteur: Inconnu
creation_date: 2025-03-15
status: publié
publish: true
favoris: false
template: recette
task: []
archive: false
draft: false
private: false
---
# Pizza Maison 2

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