Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd9224e685 | ||
|
|
aeb7516445 | ||
|
|
bca0fdd941 | ||
|
|
60da957f13 | ||
|
|
f621620593 | ||
|
|
eff74cabe0 | ||
|
|
6f0a6f7fd8 | ||
|
|
ab7c227b97 | ||
|
|
b8054665bc | ||
|
|
99a5b735c8 | ||
|
|
fb2d83e9e3 | ||
|
|
82f6b4a791 | ||
|
|
7e1f5d6852 |
+5
-2
@@ -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
|
||||
|
||||
@@ -40,6 +40,7 @@ jobs:
|
||||
node tests/frontend/unit.test.mjs
|
||||
node tests/frontend/pdf-viewer.test.mjs
|
||||
node tests/frontend/forge-completion.test.mjs
|
||||
node tests/frontend/config-mobile.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
|
||||
run: |
|
||||
@@ -58,6 +59,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 +76,7 @@ jobs:
|
||||
node desktop.test.mjs
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
+219
-1
@@ -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.16.4**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,224 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [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
@@ -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/*
|
||||
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -57,6 +57,7 @@
|
||||
- **🤖 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))
|
||||
- **👥 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
|
||||
@@ -926,8 +927,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.16.4).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.12.0 | Dernière mise à jour : Juin 2026*
|
||||
*Projet : ObsiGate | Version : 2.16.4 | Dernière mise à jour : Juin 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -50,6 +50,7 @@
|
||||
- **🤖 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))
|
||||
- **👥 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
|
||||
@@ -1095,8 +1096,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.16.4).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.12.0 | Last updated: May 2026*
|
||||
*Project: ObsiGate | Version: 2.16.4 | Last updated: May 2026*
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
+175
-16
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
+97
-11
@@ -17,10 +17,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 (
|
||||
@@ -444,7 +448,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 +460,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 +576,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 +604,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 +693,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 +705,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 +721,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 +732,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 +892,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é"}
|
||||
|
||||
+157
-36
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
+132
-30
@@ -397,31 +397,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 +500,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 +543,13 @@ 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
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
@@ -528,6 +580,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 +594,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 +644,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 +684,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 +720,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 +789,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 +818,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 +863,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"])}
|
||||
|
||||
@@ -1747,6 +1747,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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.12.0"
|
||||
version = "2.16.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.12.0"
|
||||
version = "2.16.4"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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.16.4",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -174,6 +174,12 @@ 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 | 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 |
|
||||
| | | | | | | | | | | |
|
||||
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
@@ -244,6 +250,13 @@ 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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-13
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.12.0 | **Dernière mise à jour :** 2026-09-18
|
||||
> **Version :** 2.16.4 | **Dernière mise à jour :** 2026-09-22
|
||||
> **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,10 @@
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -194,11 +189,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–107, #92 | ~120 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-36 jours** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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": "***"}
|
||||
}}}
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
+298
-27
@@ -1486,6 +1486,18 @@
|
||||
<div class="editor-modal" id="config-modal">
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<button
|
||||
class="help-hamburger"
|
||||
id="config-hamburger"
|
||||
data-i18n-attr="title:config.toc_toggle;aria-label:config.toc_toggle"
|
||||
title="Afficher le sommaire"
|
||||
aria-label="Afficher le sommaire"
|
||||
>
|
||||
<i
|
||||
data-lucide="menu"
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
<div class="editor-title" data-i18n="header.menu_config">Configurations</div>
|
||||
<div class="editor-actions">
|
||||
<button
|
||||
@@ -1547,6 +1559,7 @@
|
||||
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
|
||||
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
|
||||
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
|
||||
<li><a href="#cfg-tokens" class="help-nav-link" data-i18n="config.nav_tokens">🔑 Clés API & MCP</a></li>
|
||||
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
|
||||
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
|
||||
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
|
||||
@@ -2335,6 +2348,46 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Clés API / MCP (#107) -->
|
||||
<section id="cfg-tokens" class="config-section help-section">
|
||||
<h2 data-i18n="config.section_tokens">🔑 Clés API & MCP</h2>
|
||||
<p class="config-description" data-i18n="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).
|
||||
</p>
|
||||
<div id="tokens-list"></div>
|
||||
<div class="config-add-row" id="tokens-create-row">
|
||||
<input
|
||||
type="text"
|
||||
id="token-name-input"
|
||||
data-i18n-placeholder="config.token_name_placeholder"
|
||||
placeholder="Nom (ex: Claude Desktop)"
|
||||
class="config-input"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<select id="token-expiry-select" class="config-input" style="width: 140px">
|
||||
<option value="1d" data-i18n="config.token_expiry_1d">1 jour</option>
|
||||
<option value="30d" selected data-i18n="config.token_expiry_30d">1 mois</option>
|
||||
<option value="180d" data-i18n="config.token_expiry_180d">6 mois</option>
|
||||
<option value="365d" data-i18n="config.token_expiry_365d">1 an</option>
|
||||
<option value="never" data-i18n="config.token_expiry_never">Sans fin</option>
|
||||
</select>
|
||||
<button class="config-btn-add" id="token-create-btn" data-i18n="config.token_create">Créer une clé</button>
|
||||
</div>
|
||||
<div id="token-secret-area" class="token-secret-area hidden">
|
||||
<p class="config-description" data-i18n="config.token_secret_warning">Copiez cette clé maintenant — elle ne sera plus jamais affichée.</p>
|
||||
<textarea id="token-secret-value" class="config-input token-secret-text" rows="3" readonly></textarea>
|
||||
<div class="config-add-row">
|
||||
<button class="config-btn-add" id="token-copy-btn" data-i18n="config.token_copy">Copier</button>
|
||||
<button class="config-btn-add" id="token-dismiss-btn" data-i18n="config.token_done">Terminé</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qh-tip" style="margin-top:10px">
|
||||
<strong data-i18n="config.token_usage">Utilisation</strong>
|
||||
<span data-i18n="config.token_usage_detail">: en-tête `Authorization: Bearer ` sur l'API, et config MCP (`claude_desktop_config.json`) : `{"mcpServers":{"obsigate":{"url":"<base>/mcp","headers":{"Authorization":"Bearer "}}}}`.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications push -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
@@ -2869,6 +2922,28 @@
|
||||
Guide d'utilisation ObsiGate
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button
|
||||
class="editor-btn"
|
||||
id="help-download-md"
|
||||
title="Télécharger ce guide en Markdown"
|
||||
data-i18n-attr="title:guide105.dl_md_title"
|
||||
>
|
||||
<i
|
||||
data-lucide="file-code-2"
|
||||
style="width: 16px; height: 16px"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
class="editor-btn"
|
||||
id="help-download-pdf"
|
||||
title="Télécharger ce guide en PDF"
|
||||
data-i18n-attr="title:guide105.dl_pdf_title"
|
||||
>
|
||||
<i
|
||||
data-lucide="file-down"
|
||||
style="width: 16px; height: 16px"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
class="editor-btn"
|
||||
id="help-close"
|
||||
@@ -2892,7 +2967,7 @@
|
||||
type="text"
|
||||
class="help-nav-search"
|
||||
id="help-nav-search"
|
||||
data-i18n-placeholder="help.search_placeholder" data-i18n-placeholder="help.search_placeholder" placeholder="Rechercher dans l'aide..."
|
||||
data-i18n-placeholder="help.search_placeholder" placeholder="Rechercher dans l'aide..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -2922,7 +2997,10 @@
|
||||
data-i18n="help.nav_intro">📘 Introduction</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<li>
|
||||
<a href="#help-architecture" class="help-nav-link" data-i18n="guide105.nav_architecture">🏗️ Architecture</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-interface" class="help-nav-link"
|
||||
data-i18n="help.nav_interface">🧭 Interface</a
|
||||
>
|
||||
@@ -2957,7 +3035,10 @@
|
||||
data-i18n="help.nav_excalidraw">🎨 Excalidraw</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<li>
|
||||
<a href="#help-diagrams" class="help-nav-link" data-i18n="guide105.nav_diagrams">📊 Diagrammes</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-edition" class="help-nav-link"
|
||||
data-i18n="help.nav_editing">✏️ Édition</a
|
||||
>
|
||||
@@ -2967,7 +3048,10 @@
|
||||
data-i18n="help.nav_mobile_editor">📱 Mobile</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<li>
|
||||
<a href="#help-library" class="help-nav-link" data-i18n="guide105.nav_library">⭐ Bibliothèque</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-graphe" class="help-nav-link"
|
||||
data-i18n="help.nav_graph">🗺️ Graphe</a
|
||||
>
|
||||
@@ -2986,8 +3070,17 @@
|
||||
<a href="#help-raccourcis" class="help-nav-link"
|
||||
data-i18n="help.nav_shortcuts">⌨️ Raccourcis</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-offline" class="help-nav-link" data-i18n="guide105.nav_offline">📴 Hors-ligne</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-collab" class="help-nav-link" data-i18n="guide105.nav_collab">👥 Collaboration</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-desktop" class="help-nav-link" data-i18n="guide105.nav_desktop">🖥️ Desktop</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-partage" class="help-nav-link"
|
||||
data-i18n="help.nav_sharing">🔗 Partage</a
|
||||
>
|
||||
@@ -3020,8 +3113,14 @@
|
||||
<a href="#help-astuces" class="help-nav-link"
|
||||
data-i18n="help.nav_tips">💡 Astuces</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-api" class="help-nav-link" data-i18n="guide105.nav_api">🔌 API</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-languages" class="help-nav-link" data-i18n="guide105.nav_languages">🌍 Multilingue</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#help-plugins" class="help-nav-link"
|
||||
data-i18n="help.nav_plugins">🧩 Plugins</a
|
||||
>
|
||||
@@ -3080,7 +3179,74 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-interface">
|
||||
<section class="help-section" id="help-architecture">
|
||||
<h2 data-i18n="guide105.nav_architecture">🏗️ Architecture</h2>
|
||||
<p data-i18n-html="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.</p>
|
||||
<pre class="mermaid-code"><code class="language-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</code></pre>
|
||||
<p data-i18n="guide105.arch_diagram_note">Le diagramme est interactif dans l'application : zoom, plein écran, copie SVG ou code.</p>
|
||||
<h3 data-i18n="guide105.arch_h3_layers">Les grandes composantes</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong data-i18n="guide105.arch_lbl_fe">Frontend</strong><span data-i18n-html="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.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong data-i18n="guide105.arch_lbl_be">Backend</strong><span data-i18n="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).</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong data-i18n="guide105.arch_lbl_realtime">Temps réel & MCP</strong><span data-i18n-html="guide105.arch_rt"> — passerelle WebSocket (collaboration Yjs, notifications SSE/push) et serveur MCP (Streamable HTTP, <code>/mcp</code>) pour les clients externes.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong data-i18n="guide105.arch_lbl_ai">Couche IA</strong><span data-i18n="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.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong data-i18n="guide105.arch_lbl_data">Données</strong><span data-i18n-html="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>.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong data-i18n="guide105.arch_lbl_deploy">Déploiement</strong><span data-i18n="guide105.arch_deploy"> — application desktop Tauri (Rust) embarquant le backend Python, conteneur Docker, ou PWA installable dans le navigateur (mode hors-ligne).</span>
|
||||
</li>
|
||||
</ul>
|
||||
<h3 data-i18n="guide105.arch_h3_flux">Flux typique</h3>
|
||||
<p data-i18n-html="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.</p>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-interface">
|
||||
<h2 data-i18n="help.nav_interface">🧭 Interface utilisateur</h2>
|
||||
|
||||
<h3 data-i18n="help.header_section">En-tête</h3>
|
||||
@@ -3170,7 +3336,10 @@
|
||||
avec wikilinks et images</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<h3 data-i18n="guide105.h3_push">Notifications web (push)</h3>
|
||||
<p data-i18n="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.</p>
|
||||
<p data-i18n="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.</p>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-navigation">
|
||||
<h2 data-i18n="help.733f0559">🗺️ Navigation</h2>
|
||||
@@ -3329,7 +3498,7 @@
|
||||
<h2 data-i18n="help.adf30d78">🔍 Recherche</h2>
|
||||
|
||||
<h3 data-i18n="help.simple_search">Recherche simple</h3>
|
||||
<p data-i18n="help.desc_search_intro">
|
||||
<p data-i18n-html="help.desc_search_intro">
|
||||
Tapez dans la barre de recherche en haut pour
|
||||
lancer une recherche fulltext :
|
||||
</p>
|
||||
@@ -3382,7 +3551,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Exemples</strong><span data-i18n="help.desc_examples">:
|
||||
<strong>Exemples</strong><span data-i18n-html="help.desc_examples">:
|
||||
<code>ext:sh</code> recherche dans les scripts
|
||||
bash, <code>ext:py</code> dans les scripts
|
||||
Python, <code>ext:md</code> dans les fichiers
|
||||
@@ -3458,13 +3627,16 @@
|
||||
<strong>Tri par date</strong><span data-i18n="help.desc_42a81347"> : Dernière
|
||||
modification</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
<h3 data-i18n="guide105.h3_semantic">Recherche sémantique (hybride)</h3>
|
||||
<p data-i18n="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.</p>
|
||||
<p data-i18n="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.</p>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-tags">
|
||||
<h2 data-i18n="help.2507242f">🏷️ Tags</h2>
|
||||
|
||||
<h3 data-i18n="help.5f87ea79">Tag cloud</h3>
|
||||
<p data-i18n="help.desc_tag_cloud_intro">
|
||||
<p data-i18n-html="help.desc_tag_cloud_intro">
|
||||
L'onglet Tags de la sidebar affiche un nuage de
|
||||
tags :
|
||||
</p>
|
||||
@@ -3486,15 +3658,15 @@
|
||||
</ul>
|
||||
|
||||
<h3 data-i18n="help.2c03a7ba">Tags inline vs frontmatter</h3>
|
||||
<p data-i18n="help.desc_two_tag_types">ObsiGate supporte deux types de tags :</p>
|
||||
<p data-i18n-html="help.desc_two_tag_types">ObsiGate supporte deux types de tags :</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Frontmatter YAML</strong><span data-i18n="help.desc_frontmatter">:
|
||||
<strong>Frontmatter YAML</strong><span data-i18n-html="help.desc_frontmatter">:
|
||||
<code>tags: [docker, linux]</code> ou
|
||||
<code>tags: docker, linux</code></span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Inline</strong><span data-i18n="help.desc_inline_tags">:
|
||||
<strong>Inline</strong><span data-i18n-html="help.desc_inline_tags">:
|
||||
<code>#docker</code> dans le contenu
|
||||
markdown</span>
|
||||
</li>
|
||||
@@ -3504,13 +3676,13 @@
|
||||
</ul>
|
||||
|
||||
<h3 data-i18n="help.63f024d4">Filtrage de tags template</h3>
|
||||
<p data-i18n="help.desc_template_intro">
|
||||
<p data-i18n-html="help.desc_template_intro">
|
||||
Dans les Configurations, vous pouvez masquer les
|
||||
tags de template :
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Patterns wildcards</strong><span data-i18n="help.desc_patterns">: Ex:
|
||||
<strong>Patterns wildcards</strong><span data-i18n-html="help.desc_patterns">: Ex:
|
||||
<code>#<% ... %></code> ou
|
||||
<code>#{{ ... }}</code></span>
|
||||
</li>
|
||||
@@ -3530,12 +3702,12 @@
|
||||
<p data-i18n="help.desc_md_render">Les fichiers markdown sont rendus avec :</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Wikilinks cliquables</strong><span data-i18n="help.desc_wikilinks">:
|
||||
<strong>Wikilinks cliquables</strong><span data-i18n-html="help.desc_wikilinks">:
|
||||
<code>[[lien]]</code> et
|
||||
<code>[[lien|texte]]</code></span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Images Obsidian</strong><span data-i18n="help.desc_images">: Support
|
||||
<strong>Images Obsidian</strong><span data-i18n-html="help.desc_images">: Support
|
||||
de <code>![[image.png]]</code></span>
|
||||
</li>
|
||||
<li>
|
||||
@@ -3635,7 +3807,13 @@
|
||||
.csv
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<h3 data-i18n="guide105.h3_pdf">Export PDF</h3>
|
||||
<p data-i18n-html="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.</p>
|
||||
<h3 data-i18n="guide105.h3_exports">Export HTML / ePub / ZIP</h3>
|
||||
<p data-i18n="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.</p>
|
||||
<h3 data-i18n="guide105.h3_dupe">Anti-doublons à l'upload</h3>
|
||||
<p data-i18n="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.</p>
|
||||
</section>
|
||||
|
||||
<!-- 🎨 Excalidraw -->
|
||||
<section class="help-section" id="help-excalidraw">
|
||||
@@ -3682,7 +3860,21 @@
|
||||
</section>
|
||||
|
||||
<!-- ✏️ Édition -->
|
||||
<section class="help-section" id="help-edition">
|
||||
<section class="help-section" id="help-diagrams">
|
||||
<h2 data-i18n="guide105.nav_diagrams">📊 Diagrammes</h2>
|
||||
<p data-i18n-html="guide105.dia_intro">Les blocs <code>```mermaid</code> de vos notes sont rendus en diagrammes interactifs (Mermaid v11, chargé depuis un CDN).</p>
|
||||
<ul>
|
||||
<li data-i18n="guide105.dia_zoom">Zoom : boutons + / − dans la barre d'outils du diagramme.</li>
|
||||
<li data-i18n="guide105.dia_fs">Plein écran : idéal pour les grandes matrices.</li>
|
||||
<li data-i18n="guide105.dia_copy">Copie : exportez le SVG ou le code source (boutons dédiés).</li>
|
||||
<li data-i18n="guide105.dia_toggle">Bascule Aperçu / Code pour éditer la source sans quitter la vue.</li>
|
||||
<li data-i18n="guide105.dia_theme">Thème : le diagramme suit le thème clair/sombre de l'application.</li>
|
||||
</ul>
|
||||
<p data-i18n="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.</p>
|
||||
<p data-i18n-html="guide105.dia_excalidraw_ref">Les dessins à main levée (<code>.excalidraw</code>, <code>.excalidraw.md</code>) sont couverts dans la section 🎨 Excalidraw.</p>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-edition">
|
||||
<h2 data-i18n="auto.f346076a">✏️ Édition avancée</h2>
|
||||
|
||||
<h3 data-i18n="auto.695d9e47">Éditeur CodeMirror 6</h3>
|
||||
@@ -3719,11 +3911,15 @@
|
||||
<li>
|
||||
<strong data-i18n="help.toolbar_section">Barre d'outils AI</strong><span data-i18n="help.desc_c8532869"> :
|
||||
Complétion, réécriture, traduction (voir
|
||||
section</span><a href="#help-ia" data-i18n="settings.ai">🤖 IA</a>)
|
||||
section</span><a href="#help-ai" data-i18n="settings.ai">🤖 IA</a>)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 data-i18n="help.mobile_editor_title">📱 Édition mobile</h3>
|
||||
</section>
|
||||
|
||||
<!-- 🗺️ Graphe -->
|
||||
<section class="help-section" id="help-mobile-editor">
|
||||
<h2 data-i18n="help.mobile_editor_title">📱 Édition mobile</h2>
|
||||
<p data-i18n="help.mobile_editor_intro">
|
||||
Sur téléphone et tablette, l'édition s'adapte au
|
||||
tactile : barre d'outils flottante, gestes et mode
|
||||
@@ -3760,8 +3956,21 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 🗺️ Graphe -->
|
||||
<section class="help-section" id="help-graphe">
|
||||
<section class="help-section" id="help-library">
|
||||
<h2 data-i18n="guide105.nav_library">⭐ Bibliothèque</h2>
|
||||
<h3 data-i18n="guide105.lib_h3_bookmarks">Signets & récents</h3>
|
||||
<p data-i18n="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é.</p>
|
||||
<h3 data-i18n="guide105.lib_h3_saved">Recherches sauvegardées</h3>
|
||||
<p data-i18n="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.</p>
|
||||
<h3 data-i18n="guide105.lib_h3_backlinks">Backlinks & graphe</h3>
|
||||
<p data-i18n="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.</p>
|
||||
<h3 data-i18n="guide105.lib_h3_conflicts">Conflits de synchronisation</h3>
|
||||
<p data-i18n="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.</p>
|
||||
<h3 data-i18n="guide105.lib_h3_attach">Fichiers joints & médias</h3>
|
||||
<p data-i18n-html="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 ; le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.</p>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-graphe">
|
||||
<h2 data-i18n="help.9c7a0a8d">🗺️ Vue Graphe</h2>
|
||||
<p data-i18n="help.desc_graph_intro">
|
||||
La vue graphe offre une visualisation
|
||||
@@ -3914,7 +4123,9 @@
|
||||
rescan des vaults
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<h3 data-i18n="guide105.h3_panes">Vue multi-panneaux (split view)</h3>
|
||||
<p data-i18n="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.</p>
|
||||
</section>
|
||||
|
||||
<!-- ⌨️ Palette de commandes -->
|
||||
<section class="help-section" id="help-palette">
|
||||
@@ -4373,7 +4584,37 @@
|
||||
</section>
|
||||
|
||||
<!-- 🔗 Partage -->
|
||||
<section class="help-section" id="help-partage">
|
||||
<section class="help-section" id="help-offline">
|
||||
<h2 data-i18n="guide105.nav_offline">📴 Hors-ligne</h2>
|
||||
<ul>
|
||||
<li data-i18n="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.</li>
|
||||
<li data-i18n="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.</li>
|
||||
<li data-i18n="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.</li>
|
||||
<li data-i18n="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.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-collab">
|
||||
<h2 data-i18n="guide105.nav_collab">👥 Collaboration</h2>
|
||||
<ul>
|
||||
<li data-i18n="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.</li>
|
||||
<li data-i18n="guide105.col_cursors">Les curseurs et sélections des collaborateurs apparaissent avec une couleur et un nom par personne (awareness).</li>
|
||||
<li data-i18n="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.</li>
|
||||
<li data-i18n="guide105.col_perm">Accès limité aux utilisateurs authentifiés disposant de la permission sur la vault.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-desktop">
|
||||
<h2 data-i18n="guide105.nav_desktop">🖥️ Desktop</h2>
|
||||
<ul>
|
||||
<li data-i18n="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é).</li>
|
||||
<li data-i18n="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.</li>
|
||||
<li data-i18n="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.</li>
|
||||
<li data-i18n="guide105.des_native">Menu système natif, raccourci global optionnel pour afficher/masquer la fenêtre et jumplist des vaults récents.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-partage">
|
||||
<h2 data-i18n="help.8c65b2f2">🔗 Partage et Webhooks</h2>
|
||||
|
||||
<h3 data-i18n="help.public_shares">Publication publique</h3>
|
||||
@@ -4956,7 +5197,11 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
tourne avec UID 1000
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<h3 data-i18n="guide105.h3_mfa">MFA : TOTP, WebAuthn, codes de secours</h3>
|
||||
<p data-i18n="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.</p>
|
||||
<h3 data-i18n="guide105.h3_admin">Tableau de bord administrateur</h3>
|
||||
<p data-i18n-html="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.</p>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-astuces">
|
||||
<h2 data-i18n="help.686f8313">💡 Astuces et bonnes pratiques</h2>
|
||||
@@ -5062,7 +5307,33 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
</section>
|
||||
|
||||
<!-- 🧩 Plugins -->
|
||||
<section class="help-section" id="help-plugins">
|
||||
<section class="help-section" id="help-api">
|
||||
<h2 data-i18n="guide105.nav_api">🔌 API</h2>
|
||||
<p data-i18n="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 :</p>
|
||||
<ul>
|
||||
<li data-i18n-html="guide105.api_docs_url"><code>/docs</code> — interface Swagger UI pour essayer les requêtes en direct.</li>
|
||||
<li data-i18n-html="guide105.api_redoc"><code>/redoc</code> — référence alternative plus compacte.</li>
|
||||
<li data-i18n-html="guide105.api_landing"><code>/api</code> — page de garde regroupant les endpoints par catégorie.</li>
|
||||
<li data-i18n-html="guide105.api_schema"><code>/openapi.json</code> — le schéma machine, à importer dans Postman ou Insomnia.</li>
|
||||
</ul>
|
||||
<h3 data-i18n="guide105.api_h3_auth">Authentification</h3>
|
||||
<p data-i18n-html="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.</p>
|
||||
<h3 data-i18n="guide105.api_h3_mcp">Serveur MCP</h3>
|
||||
<p data-i18n-html="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>.</p>
|
||||
<h3 data-i18n="guide105.api_h3_autom">Automatisation</h3>
|
||||
<p data-i18n-html="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.</p>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-languages">
|
||||
<h2 data-i18n="guide105.nav_languages">🌍 Multilingue</h2>
|
||||
<ul>
|
||||
<li data-i18n="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.</li>
|
||||
<li data-i18n="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.</li>
|
||||
<li data-i18n="guide105.lng_export">Les boutons Markdown / PDF de ce guide téléchargent la version dans votre langue.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="help-section" id="help-plugins">
|
||||
<h2 data-i18n="help.plugins_title">🧩 Plugins</h2>
|
||||
<p data-i18n="help.plugins_intro">
|
||||
Les plugins étendent ObsiGate : affichage personnalisé des
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
+123
-15
@@ -1,6 +1,6 @@
|
||||
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { safeCreateIcons, escapeHtml } from './utils.js';
|
||||
import { showToast, closeHeaderMenu } from './ui.js';
|
||||
import { t, getLocale, setLocale } from './i18n.js';
|
||||
import { showWelcome } from './viewer.js';
|
||||
@@ -266,6 +266,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();
|
||||
@@ -545,8 +552,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 +1079,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 +1174,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 +1200,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 +1237,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 +1305,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
@@ -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 = {}) {
|
||||
|
||||
@@ -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,17 @@ function initConfigModal() {
|
||||
openBtn.addEventListener("click", async () => {
|
||||
modal.classList.add("active");
|
||||
closeHeaderMenu();
|
||||
// BUG-071: reset the TOC to the CSS default (mobile: hidden, desktop:
|
||||
// visible) like the help modal does on open.
|
||||
var configNavOnOpen = document.getElementById("config-nav");
|
||||
if (configNavOnOpen) configNavOnOpen.style.display = '';
|
||||
renderConfigFilters();
|
||||
loadConfigFields();
|
||||
loadDiagnostics();
|
||||
loadAbout();
|
||||
await loadHiddenFilesSettings();
|
||||
loadWebhooksUI();
|
||||
loadTokensUI();
|
||||
loadSharesUI();
|
||||
loadToolKeys();
|
||||
safeCreateIcons();
|
||||
@@ -826,6 +890,44 @@ function initConfigModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// BUG-071: 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 top block; 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";
|
||||
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);
|
||||
});
|
||||
}
|
||||
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")) {
|
||||
closeConfigModal();
|
||||
@@ -1287,6 +1389,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");
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+206
-3
@@ -496,7 +496,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 +569,38 @@
|
||||
"config.test": "Test",
|
||||
"config.timeout_label": "Search timeout (ms)",
|
||||
"config.title": "Settings",
|
||||
"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",
|
||||
@@ -1479,6 +1508,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",
|
||||
@@ -1789,6 +1888,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",
|
||||
@@ -1961,5 +2073,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; 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."
|
||||
}
|
||||
|
||||
+207
-4
@@ -496,7 +496,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 +539,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 +569,38 @@
|
||||
"config.test": "Tester",
|
||||
"config.timeout_label": "Timeout recherche (ms)",
|
||||
"config.title": "Configuration",
|
||||
"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",
|
||||
@@ -1479,6 +1508,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",
|
||||
@@ -1789,6 +1888,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",
|
||||
@@ -1961,5 +2073,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 ; 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."
|
||||
}
|
||||
|
||||
+254
-3
@@ -4347,6 +4347,76 @@ body.resizing-v {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* BUG-068: .config-btn-primary / .config-btn-danger were used by the account
|
||||
security section (frontend/js/auth.js) but never defined — the buttons fell
|
||||
back to the browser default and ignored the theme. Defined here with the
|
||||
same conventions as .config-btn-save / .config-btn-secondary. */
|
||||
.config-btn-primary {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.config-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.config-btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.config-btn-danger {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--danger, #e74c3c);
|
||||
border-radius: 6px;
|
||||
background: var(--danger-bg, #3d1a18);
|
||||
color: var(--danger, #ff7b72);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.config-btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.config-btn-danger:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* BUG-068: local QR code (backend SVG data: URI) + password section share the
|
||||
security-tab card conventions. */
|
||||
.mfa-qr-code-img {
|
||||
max-width: 200px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
}
|
||||
.password-settings {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
}
|
||||
.password-settings .form-group {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.password-settings .form-group label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #aaa);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.password-settings .config-input {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* --- AI keys section: accordion redesign (#104) --- */
|
||||
.ai-keys-header {
|
||||
display: flex;
|
||||
@@ -4653,6 +4723,82 @@ body.resizing-v {
|
||||
}
|
||||
}
|
||||
|
||||
/* BUG-071: Configurations modal — mobile usability (viewport ≤ 768px).
|
||||
#config-nav shares the .help-nav rule that hides it, but the config modal
|
||||
had no toggle (unlike the help modal): the header hamburger
|
||||
(#config-hamburger, same .help-hamburger treatment) reveals it as a
|
||||
collapsible top block. Two-column grids and fixed-width add-rows are
|
||||
stacked/wrapped so nothing overflows a 360px viewport. */
|
||||
@media (max-width: 768px) {
|
||||
/* TOC as a collapsible top block (JS toggles inline display flex/none,
|
||||
which wins over the hiding rule); the list scrolls within a capped nav. */
|
||||
#config-modal #config-nav {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 46vh;
|
||||
}
|
||||
/* Two-column grids → single column. */
|
||||
#config-modal .ai-default-grid,
|
||||
#config-modal .ai-provider-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
/* Add-rows (tokens, webhooks, tag filters) wrap instead of overflowing. */
|
||||
#config-modal .config-add-row,
|
||||
#config-modal .config-add-pattern {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#config-modal .config-add-row .config-input,
|
||||
#config-modal .config-add-pattern .config-input,
|
||||
#config-modal .config-add-row .config-select {
|
||||
flex: 1 1 140px;
|
||||
width: auto !important; /* override fixed inline widths (180/140/100px) */
|
||||
min-width: 0;
|
||||
}
|
||||
#config-modal .config-add-row .config-btn-add,
|
||||
#config-modal .config-add-pattern .config-btn-add {
|
||||
flex: 1 1 auto;
|
||||
min-height: 44px;
|
||||
}
|
||||
/* Webhook / token / share rows wrap; long URLs and meta take their own
|
||||
line instead of squeezing the delete control off-screen. */
|
||||
#config-modal .webhook-item,
|
||||
#config-modal .token-item,
|
||||
#config-modal .share-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#config-modal .webhook-url,
|
||||
#config-modal .token-meta,
|
||||
#config-modal .share-url {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#config-modal .webhook-delete,
|
||||
#config-modal .token-delete,
|
||||
#config-modal .share-revoke {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
/* Sticky AI-keys footer: full-width touch-friendly buttons. */
|
||||
#config-modal .ai-keys-footer .config-btn-save,
|
||||
#config-modal .ai-keys-footer .config-btn-secondary {
|
||||
flex: 1 1 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
/* Long inline code in tips (ex. MCP usage snippet) must wrap. */
|
||||
#config-modal .qh-tip {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#config-modal .qh-tip span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Toast notifications --- */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
@@ -7607,6 +7753,27 @@ body.popup-mode .content-area {
|
||||
.config-add-row { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.config-btn-add { padding: 6px 14px; background: var(--accent); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
|
||||
|
||||
/* ── API/MCP tokens UI (#107) ── */
|
||||
.token-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-card, var(--bg-secondary));
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.token-name { font-weight: 500; min-width: 90px; }
|
||||
.token-badge { font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
|
||||
.token-badge-active { background: color-mix(in srgb, var(--success, #2e7d32) 18%, transparent); color: var(--success, #2e7d32); }
|
||||
.token-badge-expired { background: color-mix(in srgb, var(--text-error, #c62828) 15%, transparent); color: var(--text-error, #c62828); }
|
||||
.token-meta { color: var(--text-muted); font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.token-delete { background: none; border: none; color: var(--text-error); cursor: pointer; font-size: 1rem; padding: 2px 6px; }
|
||||
.token-secret-area { margin-top: 10px; padding: 10px; border: 1px dashed var(--accent); border-radius: 8px; }
|
||||
.token-secret-area.hidden { display: none; }
|
||||
.token-secret-text { width: 100%; font-family: monospace; font-size: 0.7rem; word-break: break-all; resize: none; }
|
||||
|
||||
/* ── Shares UI ── */
|
||||
.share-item {
|
||||
display: flex;
|
||||
@@ -8146,6 +8313,29 @@ body.popup-mode .content-area {
|
||||
.cp-browse-filter:focus { border-color: var(--accent); }
|
||||
.cp-browse-filter::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* ── Help Modal: desktop = wider reading (#105) ──
|
||||
The Tauri shell (body.desktop-mode) and wide web viewports get the full
|
||||
reading layout instead of the 1320px modal cap. */
|
||||
body.desktop-mode .help-container {
|
||||
max-width: 1760px;
|
||||
width: 96vw;
|
||||
}
|
||||
body.desktop-mode .help-content {
|
||||
max-width: 1440px;
|
||||
padding: 36px 56px 64px;
|
||||
}
|
||||
body.desktop-mode .editor-container {
|
||||
max-height: 94vh;
|
||||
}
|
||||
@media (min-width: 1400px) {
|
||||
.help-container {
|
||||
max-width: min(1640px, calc(100vw - 48px));
|
||||
}
|
||||
.help-content {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Help Modal: Mobile responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.help-container,
|
||||
@@ -9902,10 +10092,71 @@ details[open] > summary .bookslm-chevron::before { content: '▼'; }
|
||||
.bookslm-diff-add { color: #4ade80; display: block; }
|
||||
.bookslm-diff-del { color: #f87171; display: block; }
|
||||
.bookslm-diff-ctx { color: var(--text-secondary); display: block; }
|
||||
/* #106 — Actions instantanées contextuelles de l'assistant. */
|
||||
.bookslm-suggestions { display: flex; flex-direction: column; gap: 6px; padding: 8px 16px 0; }
|
||||
.bookslm-suggestion { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; }
|
||||
.bookslm-suggestion:hover { background: var(--surface2); color: var(--text-primary); border-color: var(--accent); }
|
||||
.bookslm-suggestions.hidden { display: none; }
|
||||
.bookslm-qa-badge { flex-shrink: 0; font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; font-weight: 600; color: var(--accent-text, var(--accent));
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; }
|
||||
.bookslm-qa-badge.hidden { display: none; }
|
||||
.bookslm-qa-hint { margin: 0 0 2px; font-size: 12px; color: var(--text-secondary); line-height: 1.45; }
|
||||
.bookslm-qa-head { display: flex; align-items: center; justify-content: space-between; padding: 0 2px; }
|
||||
.bookslm-qa-label { font-size: 10px; font-weight: 700; letter-spacing: 0.8px;
|
||||
text-transform: uppercase; color: var(--text-muted, var(--text-secondary)); opacity: 0.85; }
|
||||
.bookslm-qa-more { display: inline-flex; align-items: center; gap: 5px; background: none;
|
||||
border: none; color: var(--accent); font-size: 12px; cursor: pointer; padding: 2px 4px;
|
||||
border-radius: 6px; }
|
||||
.bookslm-qa-more:hover { background: var(--bg-hover); }
|
||||
.bookslm-qa-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bookslm-qa-btn { display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
text-align: left; padding: 9px 12px; border-radius: 10px; cursor: pointer;
|
||||
background: var(--surface); border: 1px solid var(--border); color: var(--text-secondary);
|
||||
font-size: 13px; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
|
||||
.bookslm-qa-btn:hover { background: var(--surface2); border-color: var(--accent); color: var(--text-primary); }
|
||||
.bookslm-qa-btn .bookslm-qa-icon { color: var(--text-muted, var(--text-secondary)); flex-shrink: 0; }
|
||||
.bookslm-qa-btn:hover .bookslm-qa-icon { color: var(--accent); }
|
||||
.bookslm-qa-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; font-weight: 500; }
|
||||
.bookslm-qa-go { opacity: 0; transform: translateX(-3px); transition: opacity 0.15s ease,
|
||||
transform 0.15s ease; color: var(--accent); flex-shrink: 0; }
|
||||
.bookslm-qa-btn:hover .bookslm-qa-go { opacity: 1; transform: translateX(0); }
|
||||
/* Tiroir catalogue ("Toutes les actions") : bottom-sheet dans le panneau. */
|
||||
.bookslm-qa-drawer { position: absolute; inset: 0; z-index: 60; background: rgba(0, 0, 0, 0.45);
|
||||
display: flex; align-items: flex-end; opacity: 0; transition: opacity 0.2s ease; }
|
||||
.bookslm-qa-drawer.hidden { display: none; }
|
||||
.bookslm-qa-drawer.open { opacity: 1; }
|
||||
.bookslm-qa-sheet { width: 100%; max-height: 82%; display: flex; flex-direction: column;
|
||||
background: var(--bg-primary); border-top: 1px solid var(--border);
|
||||
border-radius: 14px 14px 0 0; padding: 12px 14px 10px; box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.35);
|
||||
transform: translateY(100%); transition: transform 0.22s ease; }
|
||||
.bookslm-qa-drawer.open .bookslm-qa-sheet { transform: translateY(0); }
|
||||
.bookslm-qa-sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.bookslm-qa-sheet-title { display: inline-flex; align-items: center; gap: 7px; font-size: 14px;
|
||||
font-weight: 600; color: var(--text-primary); }
|
||||
.bookslm-qa-sheet-title i { color: var(--accent); }
|
||||
.bookslm-qa-sheet-close { display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
|
||||
background: var(--surface); color: var(--text-secondary); }
|
||||
.bookslm-qa-sheet-close:hover { background: var(--surface2); color: var(--text-primary); }
|
||||
.bookslm-qa-search { position: relative; margin-bottom: 10px; }
|
||||
.bookslm-qa-search i { position: absolute; left: 9px; top: 50%; transform: translateY(-50%);
|
||||
color: var(--text-secondary); pointer-events: none; }
|
||||
.bookslm-qa-search input { width: 100%; box-sizing: border-box; padding: 7px 10px 7px 30px;
|
||||
border-radius: 8px; border: 1px solid var(--border); background: var(--surface);
|
||||
color: var(--text-primary); font-size: 13px; outline: none; }
|
||||
.bookslm-qa-search input:focus { border-color: var(--accent); }
|
||||
.bookslm-qa-sheet-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 6px;
|
||||
padding-bottom: 8px; }
|
||||
.bookslm-qa-cat { margin: 8px 2px 2px; display: flex; align-items: center; gap: 6px;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase;
|
||||
color: var(--text-secondary); position: sticky; top: 0; background: var(--bg-primary);
|
||||
padding: 4px 0; z-index: 1; }
|
||||
.bookslm-qa-btn.compact { padding: 7px 10px; border-radius: 8px; font-size: 12.5px; }
|
||||
.bookslm-qa-empty { padding: 18px 8px; text-align: center; font-size: 12px; color: var(--text-secondary); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bookslm-qa-drawer, .bookslm-qa-sheet, .bookslm-qa-btn, .bookslm-qa-go { transition: none; }
|
||||
}
|
||||
/* Formatted markdown inside assistant answers. */
|
||||
.bookslm-bubble.assistant > *:first-child { margin-top: 0; }
|
||||
.bookslm-bubble.assistant > *:last-child { margin-bottom: 0; }
|
||||
|
||||
+1
-1
@@ -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}`;
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.12.0",
|
||||
"version": "2.16.4",
|
||||
"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": {
|
||||
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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();
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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
@@ -1,2 +0,0 @@
|
||||
# Nouveau fichier dans Dir
|
||||
|
||||
@@ -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)"
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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]]
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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:]))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"elements": [
|
||||
{
|
||||
"id": "app-rect-1",
|
||||
"type": "rectangle",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"width": 300,
|
||||
"height": 150,
|
||||
"angle": 0,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"groupIds": [],
|
||||
"frameId": null,
|
||||
"index": "a0",
|
||||
"roundness": { "type": 3 },
|
||||
"seed": 975148614,
|
||||
"version": 73,
|
||||
"versionNonce": 1826427930,
|
||||
"isDeleted": false,
|
||||
"boundElements": [],
|
||||
"updated": 1789695570496,
|
||||
"created": 1789695569357,
|
||||
"link": null,
|
||||
"locked": 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": "#1e1e1e",
|
||||
"currentItemRoundness": "round",
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemStrokeStyle": "solid",
|
||||
"currentItemStrokeWidth": 2,
|
||||
"currentItemTextAlign": "left",
|
||||
"cursorButton": "up",
|
||||
"activeTool": {
|
||||
"type": "selection",
|
||||
"customType": null,
|
||||
"locked": false,
|
||||
"lastActiveTool": null
|
||||
},
|
||||
"penMode": false,
|
||||
"penDetected": false,
|
||||
"errorMessage": null,
|
||||
"exportBackground": true,
|
||||
"exportScale": 1,
|
||||
"exportEmbedScene": false,
|
||||
"exportWithDarkMode": false,
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"gridModeEnabled": false,
|
||||
"isBindingEnabled": true,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
"name": "App Export Fixture",
|
||||
"previousSelectedElementIds": {},
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"selectedElementIds": {},
|
||||
"selectedGroupIds": {},
|
||||
"viewBackgroundColor": "#ffffff",
|
||||
"zenModeEnabled": false,
|
||||
"zoom": { "value": 1 },
|
||||
"viewModeEnabled": false
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
2026-09-14 08:12:01 INFO Starting ObsiGate test service on port 2020
|
||||
2026-09-14 08:12:02 DEBUG Loaded 2 vaults: "IT", "Perso"
|
||||
2026-09-14 08:12:05 WARN Disk usage at 82% on /var/data
|
||||
2026-09-14 08:13:11 ERROR Failed to open "/var/db/index.sqlite"
|
||||
2026-09-14 08:13:12 INFO Retrying in 5 seconds...
|
||||
2026-09-14 08:13:17 TRACE connection pool size=8
|
||||
2026-09-14 08:14:00 CRITICAL Backup aborted, code=42
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,91 @@
|
||||
# Vaccin Zona 2024 : Analyse Shingrix® vs Zostavax®
|
||||
|
||||
#médecine #vaccin #zona #2024 #santé
|
||||
|
||||
---
|
||||
|
||||
## 🔍 **Contexte 2024 : Shingrix® devient la référence**
|
||||
- **Nom** : **Shingrix®** (GSK) – Vaccin **sous-unitaire recombinant** (non vivant).
|
||||
- **Actualité 2024** :
|
||||
- **29 février 2024** : La **Haute Autorité de Santé (HAS)** recommande **préférentiellement Shingrix®** pour :
|
||||
- Adultes **immunocompétents ≥ 65 ans**.
|
||||
- Personnes **immunodéprimées ≥ 18 ans** (ex. chimiothérapie, VIH).
|
||||
- Personnes ayant **déjà eu un zona** ou vaccinées avec Zostavax® (délai d’1 an).
|
||||
- **Arrêt de Zostavax®** : Commercialisation stoppée en **juin 2024** en France (stocks jusqu’à péremption en **janvier 2025**).
|
||||
|
||||
**Sources** : [MesVaccins](https://www.mesvaccins.net/web/vaccines/161-zostavax), [HAS](https://www.has-sante.fr/jcms/p_3498915/fr/recommandations-vaccinales-contre-le-zona-place-du-vaccin-shingrix)
|
||||
|
||||
---
|
||||
|
||||
## ✅ **Bienfaits du vaccin Shingrix® (données 2024)**
|
||||
|
||||
| **Critère** | **Shingrix®** | **Zostavax® (ancien vaccin)** | **Source** |
|
||||
|--------------------------|----------------------------------------|-------------------------------|------------|
|
||||
| **Efficacité globale** | **92%** (≥60 ans) / **91%** (≥70 ans) | 51% (≥60 ans) | [Infovac](https://www.infovac.ch/fr/les-vaccins/par-maladie/zona-herpes-zoster) |
|
||||
| **Durée de protection** | **>10 ans** (études en cours) | 5-10 ans (diminue avec l’âge) | [HAS PDF 2024](https://www.has-sante.fr/upload/docs/application/pdf/2024-03/recommandation_vaccinales_contre_le_zona._place_du_vaccin_shingrix_2024-03-04_11-26-41_450.pdf) |
|
||||
| **Prévention des DPZ** | **88-91%** (douleurs post-zostériennes) | 67% | [AFA](https://www.afa.asso.fr/prevenir-le-zona-cest-possible-meme-sous-traitement/) |
|
||||
| **Public éligible** | **≥18 ans (immunodéprimés) / ≥65 ans** | ≥65 ans (immunocompétents) | [MesVaccins](https://www.mesvaccins.net/web/vaccines/567-shingrix) |
|
||||
| **Schéma vaccinal** | **2 doses** (à 2-6 mois d’intervalle) | 1 dose | [HAS 2024](https://www.has-sante.fr/jcms/p_3498915/fr/recommandations-vaccinales-contre-le-zona-place-du-vaccin-shingrix) |
|
||||
| **Sécurité** | Effets secondaires modérés (douleur locale, fatigue) | Risque de réactivation du virus (vaccin vivant) | [SFM 2024](https://www.sfm-microbiologie.org/2024/03/24/un-nouveau-vaccin-contre-le-zona-recommande-par-la-haute-autorite-de-sante-has/) |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Pourquoi Shingrix® est-il une avancée majeure en 2024 ?**
|
||||
1. **Efficacité supérieure** :
|
||||
- Réduction de **92% des cas de zona** (vs 51% pour Zostavax).
|
||||
- **Prévention des douleurs chroniques (DPZ)** dans **88-91% des cas** (vs 67%).
|
||||
2. **Durée de protection étendue** :
|
||||
- **>10 ans** (vs 5-10 ans pour Zostavax, avec efficacité décroissante après 5 ans).
|
||||
3. **Sécurité pour les populations fragiles** :
|
||||
- **Approuvé pour les immunodéprimés** (ex. patients sous traitement immunosuppresseur, VIH).
|
||||
4. **Recommandations officielles 2024** :
|
||||
- **HAS (France)** : Shingrix® devient le **vaccin de référence**.
|
||||
- **Arrêt de Zostavax®** : Plus commercialisé en France depuis **juin 2024**.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ **Limites et points de vigilance**
|
||||
- **Effets secondaires** :
|
||||
- **Fréquents mais bénins** : Douleur locale (78%), fatigue (45%), maux de tête (38%), fièvre (21%).
|
||||
- **Rares** : Réactions allergiques graves.
|
||||
- **Coût** :
|
||||
- **~150-200€ par dose** (non remboursé à 100% en France sauf pour certains cas d’immunodépression).
|
||||
- **Schéma à 2 doses** (espacées de 2 à 6 mois).
|
||||
- **Disponibilité** :
|
||||
- **Pénurie temporaire** signalée dans certains pays (demande accrue post-recommandations 2024).
|
||||
|
||||
---
|
||||
## 📌 **Recommandations pratiques (2024)**
|
||||
- **Pour qui ?**
|
||||
- Adultes **≥65 ans** (même sans antécédent de zona).
|
||||
- **Immunodéprimés ≥18 ans** (ex. cancer, VIH, greffés).
|
||||
- Personnes ayant **déjà eu un zona** (après guérison).
|
||||
|
||||
- **Quand ?**
|
||||
- **2 doses** à 2-6 mois d’intervalle.
|
||||
- **Pas de rappel recommandé** pour l’instant.
|
||||
|
||||
- **Où ?**
|
||||
- Pharmacies, médecins, centres de vaccination (sur ordonnance en France).
|
||||
|
||||
---
|
||||
## 🔗 **Sources officielles (2024)**
|
||||
1. **Haute Autorité de Santé (HAS)** :
|
||||
- [Recommandations vaccinales contre le zona (2024)](https://www.has-sante.fr/jcms/p_3498915/fr/recommandations-vaccinales-contre-le-zona-place-du-vaccin-shingrix)
|
||||
- [PDF détaillé (mars 2024)](https://www.has-sante.fr/upload/docs/application/pdf/2024-03/recommandation_vaccinales_contre_le_zona._place_du_vaccin_shingrix_2024-03-04_11-26-41_450.pdf)
|
||||
|
||||
2. **MesVaccins.net** :
|
||||
- [Fiche Shingrix®](https://www.mesvaccins.net/web/vaccines/567-shingrix)
|
||||
- [Fiche Zona](https://www.mesvaccins.net/web/diseases/33-zona)
|
||||
|
||||
3. **Infovac (Suisse)** :
|
||||
- [Comparatif Shingrix vs Zostavax](https://www.infovac.ch/fr/les-vaccins/par-maladie/zona-herpes-zoster)
|
||||
|
||||
4. **Société Française de Microbiologie (SFM)** :
|
||||
- [Article sur les nouvelles recommandations (2024)](https://www.sfm-microbiologie.org/2024/03/24/un-nouveau-vaccin-contre-le-zona-recommande-par-la-haute-autorite-de-sante-has/)
|
||||
|
||||
5. **Vaccination Info Service (France)** :
|
||||
- [Page Zona](https://www.vaccination-info-service.fr/Les-maladies-et-leurs-vaccins/Zona)
|
||||
|
||||
---
|
||||
*Dernière mise à jour : 2024*
|
||||
@@ -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)"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,156 @@
|
||||
# Saison 2026-2027 - Canadiens de Montréal (NHL)
|
||||
|
||||
Voici le calendrier complet des **84 matchs** de la saison régulière et des **matchs de pré-saison** des Canadiens de Montréal pour la saison 2026-2027.
|
||||
|
||||
---
|
||||
|
||||
## 📅 Calendrier des matchs de pré-saison 2026-2027
|
||||
|
||||
| **Date** | **Adversaire** | **Lieu** | **Heure (EST)** | **Résultat** | **Buts MTL** | **Buts Adv.** | **Gardien MTL** | **Notes** |
|
||||
|----------------|-------------------------|------------------------|-----------------|--------------|--------------|---------------|-----------------|--------------------|
|
||||
| 24 sept. 2026 | Sénateurs d'Ottawa | Centre Bell | 19:00 | | | | | |
|
||||
| 26 sept. 2026 | Maple Leafs de Toronto | Scotiabank Arena | 19:00 | | | | | |
|
||||
| 28 sept. 2026 | Bruins de Boston | Centre Bell | 19:00 | | | | | |
|
||||
| 30 sept. 2026 | Sabres de Buffalo | KeyBank Center | 19:00 | | | | | |
|
||||
| 2 oct. 2026 | Red Wings de Détroit | Centre Bell | 19:00 | | | | | |
|
||||
| 4 oct. 2026 | Lightning de Tampa Bay | Amalie Arena | 19:00 | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## 📅 Calendrier des matchs de saison régulière
|
||||
|
||||
| **Date** | **Adversaire** | **Lieu** | **Heure (EST)** | **Résultat** | **Buts MTL** | **Buts Adv.** | **Gardien MTL** | **Notes** |
|
||||
|----------------|-------------------------|----------------|-----------------|--------------|--------------|---------------|-----------------|--------------------|
|
||||
| 12 oct. 2026 | Maple Leafs de Toronto | Centre Bell | 19:00 | | | | | Ouverture |
|
||||
| 14 oct. 2026 | Sénateurs d'Ottawa | Centre Bell | 19:00 | | | | | |
|
||||
| 16 oct. 2026 | Bruins de Boston | TD Garden | 19:00 | | | | | |
|
||||
| 18 oct. 2026 | Sabres de Buffalo | KeyBank Center | 19:00 | | | | | |
|
||||
| 21 oct. 2026 | Red Wings de Détroit | Little Caesars | 19:30 | | | | | |
|
||||
| 23 oct. 2026 | Panthers de la Floride | Centre Bell | 19:00 | | | | | |
|
||||
| 25 oct. 2026 | Lightning de Tampa Bay | Amalie Arena | 19:00 | | | | | |
|
||||
| 28 oct. 2026 | Capitals de Washington | Capital One | 19:00 | | | | | |
|
||||
| 30 oct. 2026 | Hurricanes de la Caroline | PNC Arena | 19:00 | | | | | |
|
||||
| 1 nov. 2026 | Devils du New Jersey | Prudential | 19:00 | | | | | |
|
||||
| 3 nov. 2026 | Islanders de New York | UBS Arena | 19:00 | | | | | |
|
||||
| 5 nov. 2026 | Rangers de New York | Madison Square | 19:00 | | | | | |
|
||||
| 7 nov. 2026 | Flyers de Philadelphie | Wells Fargo | 19:00 | | | | | |
|
||||
| 9 nov. 2026 | Penguins de Pittsburgh | PPG Paints | 19:00 | | | | | |
|
||||
| 11 nov. 2026 | Blue Jackets de Columbus | Nationwide | 19:00 | | | | | |
|
||||
| 13 nov. 2026 | Predators de Nashville | Bridgestone | 20:00 | | | | | |
|
||||
| 15 nov. 2026 | Stars de Dallas | American Airlines | 20:30 | | | | | |
|
||||
| 17 nov. 2026 | Wild du Minnesota | Xcel Energy | 20:00 | | | | | |
|
||||
| 19 nov. 2026 | Jets de Winnipeg | Canada Life | 20:00 | | | | | |
|
||||
| 21 nov. 2026 | Avalanche du Colorado | Ball Arena | 21:00 | | | | | |
|
||||
| 23 nov. 2026 | Coyotes de l'Arizona | Mullett Arena | 22:00 | | | | | |
|
||||
| 25 nov. 2026 | Golden Knights de Vegas | T-Mobile Arena | 22:00 | | | | | |
|
||||
| 27 nov. 2026 | Ducks d'Anaheim | Honda Center | 22:00 | | | | | |
|
||||
| 29 nov. 2026 | Kings de Los Angeles | Crypto.com | 22:30 | | | | | |
|
||||
| 1 déc. 2026 | Sharks de San José | SAP Center | 22:30 | | | | | |
|
||||
| 3 déc. 2026 | Canucks de Vancouver | Rogers Arena | 22:00 | | | | | |
|
||||
| 5 déc. 2026 | Oilers d'Edmonton | Rogers Place | 21:00 | | | | | |
|
||||
| 7 déc. 2026 | Flames de Calgary | Scotiabank Saddledome | 21:00 | | | | | |
|
||||
| 10 déc. 2026 | Bruins de Boston | Centre Bell | 19:00 | | | | | |
|
||||
| 12 déc. 2026 | Sabres de Buffalo | Centre Bell | 19:00 | | | | | |
|
||||
| 14 déc. 2026 | Red Wings de Détroit | Centre Bell | 19:00 | | | | | |
|
||||
| 16 déc. 2026 | Panthers de la Floride | FLA Live Arena | 19:00 | | | | | |
|
||||
| 18 déc. 2026 | Lightning de Tampa Bay | Centre Bell | 19:00 | | | | | |
|
||||
| 20 déc. 2026 | Capitals de Washington | Centre Bell | 19:00 | | | | | |
|
||||
| 22 déc. 2026 | Hurricanes de la Caroline | Centre Bell | 19:00 | | | | | |
|
||||
| 27 déc. 2026 | Devils du New Jersey | Centre Bell | 19:00 | | | | | |
|
||||
| 29 déc. 2026 | Islanders de New York | Centre Bell | 19:00 | | | | | |
|
||||
| 31 déc. 2026 | Rangers de New York | Centre Bell | 19:00 | | | | | |
|
||||
| 2 janv. 2027 | Flyers de Philadelphie | Centre Bell | 19:00 | | | | | |
|
||||
| 4 janv. 2027 | Penguins de Pittsburgh | Centre Bell | 19:00 | | | | | |
|
||||
| 6 janv. 2027 | Blue Jackets de Columbus | Centre Bell | 19:00 | | | | | |
|
||||
| 8 janv. 2027 | Predators de Nashville | Centre Bell | 19:00 | | | | | |
|
||||
| 10 janv. 2027 | Stars de Dallas | Centre Bell | 19:00 | | | | | |
|
||||
| 12 janv. 2027 | Wild du Minnesota | Centre Bell | 19:00 | | | | | |
|
||||
| 14 janv. 2027 | Jets de Winnipeg | Centre Bell | 19:00 | | | | | |
|
||||
| 16 janv. 2027 | Avalanche du Colorado | Centre Bell | 19:00 | | | | | |
|
||||
| 18 janv. 2027 | Coyotes de l'Arizona | Centre Bell | 19:00 | | | | | |
|
||||
| 20 janv. 2027 | Golden Knights de Vegas | Centre Bell | 19:00 | | | | | |
|
||||
| 22 janv. 2027 | Ducks d'Anaheim | Centre Bell | 19:00 | | | | | |
|
||||
| 24 janv. 2027 | Kings de Los Angeles | Centre Bell | 19:00 | | | | | |
|
||||
| 26 janv. 2027 | Sharks de San José | Centre Bell | 19:00 | | | | | |
|
||||
| 28 janv. 2027 | Canucks de Vancouver | Centre Bell | 19:00 | | | | | |
|
||||
| 30 janv. 2027 | Oilers d'Edmonton | Centre Bell | 19:00 | | | | | |
|
||||
| 1 fév. 2027 | Flames de Calgary | Centre Bell | 19:00 | | | | | |
|
||||
| 3 fév. 2027 | Maple Leafs de Toronto | Scotiabank Arena | 19:00 | | | | | |
|
||||
| 5 fév. 2027 | Sénateurs d'Ottawa | Canadian Tire | 19:00 | | | | | |
|
||||
| 7 fév. 2027 | Bruins de Boston | Centre Bell | 19:00 | | | | | |
|
||||
| 9 fév. 2027 | Sabres de Buffalo | Centre Bell | 19:00 | | | | | |
|
||||
| 11 fév. 2027 | Red Wings de Détroit | Centre Bell | 19:00 | | | | | |
|
||||
| 13 fév. 2027 | Panthers de la Floride | Centre Bell | 19:00 | | | | | |
|
||||
| 15 fév. 2027 | Lightning de Tampa Bay | Amalie Arena | 19:00 | | | | | |
|
||||
| 17 fév. 2027 | Capitals de Washington | Centre Bell | 19:00 | | | | | |
|
||||
| 19 fév. 2027 | Hurricanes de la Caroline | Centre Bell | 19:00 | | | | | |
|
||||
| 21 fév. 2027 | Devils du New Jersey | Prudential | 19:00 | | | | | |
|
||||
| 23 fév. 2027 | Islanders de New York | UBS Arena | 19:00 | | | | | |
|
||||
| 25 fév. 2027 | Rangers de New York | Madison Square | 19:00 | | | | | |
|
||||
| 27 fév. 2027 | Flyers de Philadelphie | Wells Fargo | 19:00 | | | | | |
|
||||
| 1 mars 2027 | Penguins de Pittsburgh | PPG Paints | 19:00 | | | | | |
|
||||
| 3 mars 2027 | Blue Jackets de Columbus | Nationwide | 19:00 | | | | | |
|
||||
| 5 mars 2027 | Predators de Nashville | Bridgestone | 20:00 | | | | | |
|
||||
| 7 mars 2027 | Stars de Dallas | American Airlines | 20:30 | | | | | |
|
||||
| 9 mars 2027 | Wild du Minnesota | Xcel Energy | 20:00 | | | | | |
|
||||
| 11 mars 2027 | Jets de Winnipeg | Canada Life | 20:00 | | | | | |
|
||||
| 13 mars 2027 | Avalanche du Colorado | Ball Arena | 21:00 | | | | | |
|
||||
| 15 mars 2027 | Coyotes de l'Arizona | Mullett Arena | 22:00 | | | | | |
|
||||
| 17 mars 2027 | Golden Knights de Vegas | T-Mobile Arena | 22:00 | | | | | |
|
||||
| 19 mars 2027 | Ducks d'Anaheim | Honda Center | 22:00 | | | | | |
|
||||
| 21 mars 2027 | Kings de Los Angeles | Crypto.com | 22:30 | | | | | |
|
||||
| 23 mars 2027 | Sharks de San José | SAP Center | 22:30 | | | | | |
|
||||
| 25 mars 2027 | Canucks de Vancouver | Rogers Arena | 22:00 | | | | | |
|
||||
| 27 mars 2027 | Oilers d'Edmonton | Rogers Place | 21:00 | | | | | |
|
||||
| 29 mars 2027 | Flames de Calgary | Scotiabank Saddledome | 21:00 | | | | | |
|
||||
| 1 avr. 2027 | Maple Leafs de Toronto | Centre Bell | 19:00 | | | | | |
|
||||
| 3 avr. 2027 | Sénateurs d'Ottawa | Centre Bell | 19:00 | | | | | |
|
||||
| 5 avr. 2027 | Bruins de Boston | TD Garden | 19:00 | | | | | |
|
||||
| 7 avr. 2027 | Sabres de Buffalo | KeyBank Center | 19:00 | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## 📊 Statistiques de la saison
|
||||
|
||||
### Classement de l'équipe
|
||||
- **Victoires** :
|
||||
- **Défaites** :
|
||||
- **Défaites en prolongation** :
|
||||
- **Défaites en fusillade** :
|
||||
- **Points** :
|
||||
- **Buts pour** :
|
||||
- **Buts contre** :
|
||||
- **Différentiel** :
|
||||
|
||||
### Meilleurs buteurs
|
||||
| **Joueur** | **Buts** | **Passes** | **Points** | **±** | **Tirs** | **Temps de jeu** |
|
||||
|---------------------|----------|------------|------------|-------|----------|------------------|
|
||||
| | | | | | | |
|
||||
| | | | | | | |
|
||||
| | | | | | | |
|
||||
|
||||
### Meilleurs gardiens
|
||||
| **Joueur** | **Victoires** | **Défaites** | **Moyenne de buts alloués** | **% Arrêts** | **Blanchissages** |
|
||||
|---------------------|---------------|--------------|--------------------------------|--------------|-------------------|
|
||||
| | | | | | |
|
||||
| | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## 🏆 Matchs importants
|
||||
- **Match d'ouverture** : 12 octobre 2026 contre les Maple Leafs de Toronto.
|
||||
- **Classique hivernale** : À confirmer.
|
||||
- **Match des étoiles** : À confirmer.
|
||||
- **Date limite des transactions** : À confirmer.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes supplémentaires
|
||||
- **Blessures** :
|
||||
- **Transactions** :
|
||||
- **Performances remarquables** :
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : [Date]*
|
||||
*Source : [NHL.com](https://www.nhl.com)*
|
||||
@@ -0,0 +1,137 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 11 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Author (\(anonymous\)) /CreationDate (D:20260917170226+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260917170226+00'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Calendrier 2026-2027 - Canadiens de Montr\351al \(NHL\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Count 4 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1120
|
||||
>>
|
||||
stream
|
||||
Gb!Sk966RV&AI=/m&c:\E0(X7hVpYdR6!p/0^Zgq`E!UT%'C.Y1ZKS7'n5.EfZ)/^Ad4>t'&Tk+Dc\8nj$h,f?j+i9i8g%$Tb'?g@3#PL%kaTO?UnWbdLX,@@4)>t!,%t@/e.C9#j7AiEn#_TKPOF-JR474]b5HK[MK'[O+u"Bj,$<^1):M$9g&l&5$2dD;a-./R_qt;Fd=N1G?i"K@u,S!e-=p]N]gaEhn$A`ooJ8oMhGu'Yk]XXq,b5/kj9\m23[46%396Lb<#VsJ3DLA!iF]Q'/g#Q]5!\E3X,F5M[(s^+pZ(/'#BL@0ZZ`D6n"$PLN\$_e_mt@4t]]:3KNb_D7;g0foUa#E`Bna3b%&Q3UNe:@XQm-i(Q;Y'GD<rKj:ddN+8bD3g:JV@lrGSF(9/?bGtH:)]C0dHJ>jD((=[62Zs$'P=qh%DGA$Y5-+o<9[hBGm*[Y8[>qb/8*G+d%+L^g1X<PZqlO4$LVJO2<-$jCMa]@I]b6#5cu34F8,_INhjnp80+u7SXJ]8F.BfN'C(j\9kk9UEmIhn[26U%MhIK7>X.qQWJK"4#_T)G@=DPZQ*SW0L%?l4/ehT4m$e7_%KgpJF^T"po5T/WlX?%Y'ENeg'VPctR66_g^ga^;CE9oe&QlcHQ7?V&:KHG%MT8T`c\S!0HF%LH6?5<8na.s#)_&l;b7[_2ZQ>)ZiR>UeB:Og6\'IhoY5P'm0G7^YaIo+,APsmg[`SE(HQoPWaHB";.^[email protected]^SS/Nq[2Vdac1bkV5*c$[edQXI5SdLLYlag5lc0Yj]LA^;oJ:M>UUW?HFa!LP=LiN!a^?jpGZ7jLt?Q=euD;j>\6@3HY'))GWdbM+'4T46H+oYbhc8l>*lh(ff\a5gVHih6WihG$q?AK8)i[W41TDpJJN$B+uq?[T!3sQYoETntMq*[email protected]/C+2lNH66rJbt69p#a/qGH\9cP$&jS"m%@-!E3B=j6h-2^6*!SQHQtG,M6Q+W&9D2)eGm4&'5"=qKH>)!,s.F\?E=hji/ESa,CNTBTsM?A`$eVp+)k8'.R&7=WHo>#rbq`ja^g@6#P!1FL4!nAE^*ftp."JaamfInrW*hRG]3~>endstream
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1374
|
||||
>>
|
||||
stream
|
||||
GatUt?$#!`'Sc)P'tR&mRAQ;Jh)c`$D3e_cKJr3kDhP50'd4m/SK2nEj*P<MW+"4Z9>aGj5m`!fEh9%-`[H?RmX>ojKI.*!dhP?>TZ%S]d<UB\1N^/E]o;I[2q&23EIOKjGm8aQqK0(YiV018;:$oYC'7n%hld1?^"H[;kcR7r284E),X\SFc4L@RK&4k=54aMd)O1^odX&lDeL5]F5T_:>A'H9QcZ&l)&A\2?l[-#6)_HLmm%7#q_plhiF(bJ+kje]$C<[,9=a(+&!k23^"8!P$hb>-=EGS!<o$eNt,H5#,aY29s2g'&d*W(e5R',,n5jPjhZJeXV.D.H"'L'W+.sr8If=_FhCFuJ9J_=#YnGfK[)F"1<o&*R>,fNgL3-;$XHdqdh<i=0N[,a+:r520#2+,tp_`qN_Iq5rE$/KGmHi-rS]>pPI\=b,]cRWlorK.D"V(8Prjk;T1C24HlgR@m3k=eo.Qb,?;9iE]L?*2>fr#3g/QVHl"4b6Vc3:d]iDsTm!%,K9>c'n&9VFn4bAR!(#*Gr<LT6]Z\/E=r(D4J+/Mu2)N2(NC9p'/=>LC#5"s%G7&.osRK5OakE#_-=62ic\d`I34X)S0FnTL)IRp[Nu\iua8k2H.@SZp4&8hp*<Ioo4g?^:<Y#,8[0oppXK0WbP4qs!X7"Q]gTJgLD=%HYc\@b2(UeI<TW':&bLcr0^Pj7$+dOBZ?[3,C5SY]o>p6Db8j@nLK=LW`UuSH]V9B=t8"ocp`_5a$[@8X0,=dXt/c&3`TD7m"<R9=S"2G,J@q[g86llP9K"l<R`F=(R=V]]iN#:TcYP`<kGg9SQ25bM]ZlfOpgDC:TU!SfHV((28*[]2*ddn-!`7>0e$$mB_4ZQ%q6$MHA7j"StrRQ#70a'Tk=`+:l%,`R;u*rp:4&`L"cI6i86^7_osTDR.qrcnujae`F6'UW*X]G%ku4G<,IrGkMpikliY<BX.j%`'8)@e@0l;T"aV/CN+u]]epGpn9rBTS,HIR_n]AX<[email protected]_*gpQ5_,oa1a_2>gS'3SVeT!g)1)Y)>K$b9$kh#/5E1CW&D\nLI/hFBh@D)3iFS5/(J8$%?TAF.S2dHdbsm1-MlU:4AkeN]FA-VViM-0q4Zc0UfL&Ylldf<Z'6m6Y4.YKl%oc/aW4S#-)!imNK1R<=_Vp"DEqNq[72\'Gg_@J9*t<J]ZDo,SB>HO5PGHkh641GB7L5n0nc`3a!1fqXL@-D,P#Her%euf,a@3A^k-Hq.3u+!?h&,J2&.J[S*Gj0p?,p,n;5[s*-n@:'4:e.iKt.*AG$]OO7[cSkUhk:I*d^M=r_e*hu">Ip41A>I<UuMPHV3'polS7d@>lpY<O?Q7ZKt<!9PAbec~>endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1246
|
||||
>>
|
||||
stream
|
||||
GatUt>uTcA'Sc)J.s+;9$Yi94ls15>=r,cW-8!]7hAg"DejbsWCr=WQ+(*^Hd`&BC7[hQ:3.gpfnikJ^`[EMoh#H/1#V@sua%M@F6(8n3iH^5;2sB(0p,Kt1^J%Oi7+ljSAuYTefuMK:LgPoYB0ebTMjaBCr-Ba^g!t&.TYCm!md]*LoDWBa:ZmN:,XGeaV0HF[7QEi:Opf@!30]r$JL-M>An]#60NU72.1"/df@Tb1o,2Cokbp^@n7Z8Wa-e`/pm-<HAC5EXYk+`k+'Ms7T&_l:OiLVa9HLqp?\k#ACY8&Xe'ft8BWHosp1'.>C1U0bBD-GSlC-h]W$*p?:mXs_#g^0%$+?!ee=#)s^-Q*b0OS7<&E(1SU".GU]%`'3HJW_*UV'%)>Y8%t#!AlAe]^!l2\V8%]KQPshGEeG.m0L-X-?KH/gVQA3I_)2Ea,he][email protected];S'Y>lP7nt\eMeZA/io4dWq4C9*js5hd7Rj)`O^r"`PnH*@ke6%G3@*`G<qr+$;6-)snMiHtAmVk!]g4iO9.1[]4INJ)b:n#3P)R&$cXkKWo8Tct>S^!ecc$AMX.*PWmNrg^\Lp2:Hh'$bFKOgmBS8Oj'<@U2jR+?4]*WK`MTN<[>u^^>ZmU*/FYfi]DUOZ:+1UMB_m=Q4V.??s-T//4).JZu'(*7YHs=o`2/It>&-_86:[GkH-;E=8Ugraq31)2\L;C$*h^I<_H'eH2C,e>+gnVDlrP!a/"DDNbPfE-C7cOfV]1WG>C\uJ+BGu,M+odXF,OL5TYMf1MS2uGXW[\J6<DCJS=Qlse`'W#2M5R&t;bGh.bpFqO%sgeEU>>3%Gh+D[>=GB>LStkb5I%jO&;LAPU)RmTif,$CafN4>`1t,=#eL/kPfM'PJD#+1k.,+H6kZQ$,k<@*-aMTNop.FMhsWF!*<7[agF;;##h/`6m5-)o>"Y_`Yrg?T#m]=;(ci)SJXUk/b=\^5<e<J<d#"kh[p&AT8G4o$9@-:Mko62/C+[^nMVa2t(X+`6Fk7hnZ"MPLl'cb2=m=L:o&c_\r@Id`kG/FBe1*)24Y#BH\A-OjbKGt/G'!Fal+MBBf>ZGqpGfTbMaNO<G[g%f0GXJ<qplaj[>Fq[Yltf4/'qQpFm9r5X1OaD'Z1T.XcFFi:$?A7$+`s9#pNE(ET!NEWk-5?s7O`Y[CS,+!gfdDF\:'.jX'F]d&6[-U!jCoDs@;`_;cOm>fl0s%/[g;B_E6qV26r~>endstream
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 914
|
||||
>>
|
||||
stream
|
||||
GatUr?#SFN'Sc)P'u&ulBe7O7+hJ-S@qoFh8$q\n2lFb@G]iAu"=C#Fpt<an9d`Sn;'_6`6i:oq50'A8dhcjbo88W$7R!>@6@G"K#n;Y5a_'Tb)uL]&^N[\-&kC&_4ANom(PCHY9-N+Er>^(,Jgq$%Z#maEIWj90i\e@CArelH,U*u%2=Wgo-;W\l@0*dcK`]A/=Kf/lF4:R2`JP;[[1l/0K`ZA@pHEnprn_"#8bRdImgT'[IuH[0`M\PKIb_bHeBjq^!p?f=Q*`lsEY'L-MDKg!1.>'[email protected])(5>#Btr1nspa*9n-%@K#G8S)b-r%1E6c31.ff0On7k=8*O7?8[+F@nX]QWQ2;^XMQ9&PHDuJq*pDN%NEl4f29P-u_Ze3p>fD3/Ds9VY=$2V1M%&bj,f\_G9uILK7JHJ\.ti/*g1&A;s=8q+7U=DqU5t2d4nG5*5YiaF1+d,L9Xog@b))ncB!*'!,?GZ2'8gj(n@;0(0ZJ7s`KrBf("BP]eoIh;f(bqBQf[6<<?n-Rj/a%iOiR*0%(H7O*+?rN;_n9U&8HB6$^E;'JNZm]"o'eoI/$l?U9S58L#V<R<.9(Y%m\\laN0B]p7YP$cB)pE63-T&+RXhLdno$6&a.K).,'"joe.k?AH6\_/m:?Y##i-DuMeeEU\6\GPN7rOg\cRY17nXJhrAL]2l$L-puLk@'`[]aSCp0E&&?k8^LF*);'OTQVY+\Z?\(K6Ybr.12)hXSUtt<^jpiL_H#g<Xo)Vcf%`^]c"\mq$WTiVXl-'3oLFf"SHJ>Al0Z>aJrki\Eb#S^_b![!B"b`aB[=Sr.60(IB/q_4m$&E3VPpYQtUe/Om=pcfdXH3AWEY%^n+QL@!p2QD5Yd#YM+A"2TmCc'94Tu9h`(kA$EduJP$,r!*eRhbQ~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 16
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000112 00000 n
|
||||
0000000219 00000 n
|
||||
0000000331 00000 n
|
||||
0000000414 00000 n
|
||||
0000000619 00000 n
|
||||
0000000824 00000 n
|
||||
0000001029 00000 n
|
||||
0000001234 00000 n
|
||||
0000001303 00000 n
|
||||
0000001626 00000 n
|
||||
0000001704 00000 n
|
||||
0000002916 00000 n
|
||||
0000004382 00000 n
|
||||
0000005720 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<aba93085604206a813b8865dbf736fe6><aba93085604206a813b8865dbf736fe6>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 10 0 R
|
||||
/Root 9 0 R
|
||||
/Size 16
|
||||
>>
|
||||
startxref
|
||||
6725
|
||||
%%EOF
|
||||
@@ -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}
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Frontend unit tests for #106 (assistant contextual quick actions).
|
||||
*
|
||||
* Covers the pure logic of frontend/js/ai-quick-actions.js:
|
||||
* - detectContext precedence (selection > code > multi-doc > single-doc >
|
||||
* directory > general)
|
||||
* - every preset id resolves to a real action (label + prompt keys exist)
|
||||
* - i18n completeness: every qa.* key referenced by the catalogue exists in
|
||||
* BOTH fr.json and en.json (FR/EN contract from AGENTS.md)
|
||||
*
|
||||
* Usage: node tests/frontend/ai-quick-actions.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// Minimal browser shim: i18n.js exposes `window.t = t` at module top-level.
|
||||
// Strings are not loaded here, so t() falls back to the key — enough for the
|
||||
// pure-logic contract asserted below (keys existence is checked against the
|
||||
// real locale JSON files instead).
|
||||
globalThis.window = globalThis.window || globalThis;
|
||||
|
||||
const {
|
||||
detectContext,
|
||||
suggestionsFor,
|
||||
getAction,
|
||||
contextBadgeKey,
|
||||
ACTION_CATALOG,
|
||||
CATEGORIES,
|
||||
CONTEXT_PRESETS,
|
||||
} = await import(pathToFileURL(path.join(ROOT, "frontend", "js", "ai-quick-actions.js")).href);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── detectContext precedence ───────────────────────────────────────────────
|
||||
|
||||
await test("selection wins over every other context", () => {
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "a.md", hasSelection: true }), "selection");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 3, currentPath: "x.py", hasSelection: true }), "selection");
|
||||
});
|
||||
|
||||
await test("code file drives the code context", () => {
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "backend/main.py" }), "code");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 2, currentPath: "script.SH" }), "code");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "app.tsx" }), "code");
|
||||
});
|
||||
|
||||
await test("2+ documents drive the multi_doc context", () => {
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 2, currentPath: "a.md" }), "multi_doc");
|
||||
});
|
||||
|
||||
await test("one text document drives the single_doc context", () => {
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "note.md" }), "single_doc");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "plan.txt" }), "single_doc");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "image.png" }), "single_doc");
|
||||
});
|
||||
|
||||
await test("directory and general fall back correctly", () => {
|
||||
assert.equal(detectContext({ mode: "directory", docCount: 0 }), "directory");
|
||||
assert.equal(detectContext({ mode: "general", docCount: 0 }), "general");
|
||||
assert.equal(detectContext(), "general");
|
||||
});
|
||||
|
||||
// ── presets & catalogue integrity ──────────────────────────────────────────
|
||||
|
||||
await test("every context has 3-4 suggested actions, all resolvable", () => {
|
||||
for (const [ctx, ids] of Object.entries(CONTEXT_PRESETS)) {
|
||||
assert.ok(ids.length >= 3 && ids.length <= 4, `preset ${ctx} must hold 3-4 actions`);
|
||||
for (const id of ids) assert.ok(getAction(id), `preset ${ctx}: unknown action id ${id}`);
|
||||
assert.equal(suggestionsFor(ctx).length, ids.length);
|
||||
}
|
||||
});
|
||||
|
||||
await test("unknown context falls back to the general preset", () => {
|
||||
assert.deepEqual(suggestionsFor("nonsense").map((a) => a.id), suggestionsFor("general").map((a) => a.id));
|
||||
});
|
||||
|
||||
await test("catalogue actions have unique ids, a known category, icon and keys", () => {
|
||||
const catIds = new Set(CATEGORIES.map((c) => c.id));
|
||||
const seen = new Set();
|
||||
for (const a of ACTION_CATALOG) {
|
||||
assert.ok(!seen.has(a.id), `duplicate action id ${a.id}`);
|
||||
seen.add(a.id);
|
||||
assert.ok(catIds.has(a.cat), `action ${a.id}: unknown category ${a.cat}`);
|
||||
assert.ok(a.icon, `action ${a.id}: missing icon`);
|
||||
assert.ok(a.labelKey && a.promptKey, `action ${a.id}: missing i18n keys`);
|
||||
}
|
||||
});
|
||||
|
||||
await test("the 4 scenarios of the design table map to the expected actions", () => {
|
||||
assert.deepEqual(suggestionsFor("single_doc").map((a) => a.id),
|
||||
["summarize_3", "checklist", "frontmatter", "frontmatter_update"]);
|
||||
assert.deepEqual(suggestionsFor("multi_doc").map((a) => a.id), ["merge", "compare", "frictions"]);
|
||||
assert.deepEqual(suggestionsFor("code").map((a) => a.id), ["explain_code", "audit_code", "test_code"]);
|
||||
assert.deepEqual(suggestionsFor("selection").map((a) => a.id), ["concise", "fix_style", "explain_selection"]);
|
||||
});
|
||||
|
||||
await test("frontmatter actions are agent-mode (they mutate the document)", () => {
|
||||
assert.equal(getAction("frontmatter").agent, true);
|
||||
assert.equal(getAction("frontmatter_update").agent, true);
|
||||
assert.ok(!getAction("summarize_3").agent);
|
||||
});
|
||||
|
||||
// ── i18n completeness (FR + EN) ────────────────────────────────────────────
|
||||
|
||||
const FR = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
|
||||
const EN = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
|
||||
|
||||
await test("every catalogue/preset key exists in FR and EN (label + prompt)", () => {
|
||||
const keys = new Set();
|
||||
for (const a of ACTION_CATALOG) { keys.add(a.labelKey); keys.add(a.promptKey); }
|
||||
for (const id of Object.values(CONTEXT_PRESETS).flat()) {
|
||||
const a = getAction(id);
|
||||
keys.add(a.labelKey); keys.add(a.promptKey);
|
||||
}
|
||||
for (const c of CATEGORIES) keys.add(c.labelKey);
|
||||
for (const k of ["qa.header_suggested", "qa.all_actions", "qa.drawer_title",
|
||||
"qa.search_placeholder", "qa.no_match", "qa.empty_hint", "qa.badge_single",
|
||||
"qa.badge_multi", "qa.badge_code", "qa.badge_selection", "qa.badge_directory",
|
||||
"qa.badge_general"]) keys.add(k);
|
||||
for (const k of keys) {
|
||||
assert.ok(typeof FR[k] === "string" && FR[k].length > 0, `fr.json missing ${k}`);
|
||||
assert.ok(typeof EN[k] === "string" && EN[k].length > 0, `en.json missing ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test("context badge keys resolve per context", () => {
|
||||
assert.equal(contextBadgeKey("code"), "qa.badge_code");
|
||||
assert.equal(contextBadgeKey("selection"), "qa.badge_selection");
|
||||
assert.equal(contextBadgeKey("multi_doc"), "qa.badge_multi");
|
||||
assert.equal(contextBadgeKey("single_doc"), "qa.badge_single");
|
||||
assert.equal(contextBadgeKey("directory"), "qa.badge_directory");
|
||||
assert.equal(contextBadgeKey("general"), "qa.badge_general");
|
||||
});
|
||||
|
||||
await test("multi-doc badge carries the {count} param in both locales", () => {
|
||||
assert.ok(FR["qa.badge_multi"].includes("{count}"));
|
||||
assert.ok(EN["qa.badge_multi"].includes("{count}"));
|
||||
});
|
||||
|
||||
console.log(`\n${passCount} passed, ${testCount - passCount} failed`);
|
||||
if (passCount !== testCount) process.exit(1);
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Configurations modal mobile usability non-regression tests (BUG-071).
|
||||
*
|
||||
* Static checks (no jsdom needed — runs in the "Frontend unit tests" CI step):
|
||||
* - BUG-071a: #config-nav shared the .help-nav rule that hides it below
|
||||
* 768px, but the config modal had no toggle (the help modal has
|
||||
* #help-hamburger) → the table of contents was unreachable on mobile.
|
||||
* The header must carry #config-hamburger (same .help-hamburger
|
||||
* treatment: hidden on desktop, visible on mobile) wired in config.js.
|
||||
* - BUG-071b: the TOC links were bare anchors with no JS — no active state,
|
||||
* no auto-collapse on mobile, unreliable scrolling inside the modal.
|
||||
* config.js must smooth-scroll to the section, mark it active and collapse
|
||||
* the nav on mobile, and reset the nav on open.
|
||||
* - BUG-071c: two-column grids (.ai-default-grid, .ai-provider-fields),
|
||||
* fixed-width add-rows (.config-add-row, 180/140/100px inline widths) and
|
||||
* single-line webhook/token/share items overflowed a 360px viewport.
|
||||
* style.css must stack/wrap them below 768px with 44px touch targets.
|
||||
* - BUG-071d: every #config-nav link target must exist (dead-anchor guard,
|
||||
* same class of bug as BUG-067 for the help modal).
|
||||
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"-
|
||||
* separated) so the toggle carries translated title AND aria-label.
|
||||
*
|
||||
* Usage: node tests/frontend/config-mobile.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
|
||||
const configJs = readFileSync(path.join(ROOT, "frontend", "js", "config.js"), "utf8");
|
||||
const i18nJs = readFileSync(path.join(ROOT, "frontend", "js", "i18n.js"), "utf8");
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
|
||||
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
|
||||
|
||||
function test(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(" ✓ " + label);
|
||||
} catch (err) {
|
||||
console.error(" ✗ " + label + "\n " + err.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── BUG-071a: header TOC toggle ─────────────────────────────────────────────
|
||||
test("index.html — #config-hamburger exists in the config modal header", () => {
|
||||
const modal = indexHtml.match(/<div class="editor-modal" id="config-modal">([\s\S]*?)<div class="editor-body help-body" id="config-body">/);
|
||||
assert.ok(modal, "#config-modal with #config-body not found");
|
||||
const header = modal[1].match(/<div class="editor-header">([\s\S]*?)<\/div>\s*<\/div>/);
|
||||
assert.ok(header, "config modal .editor-header not found");
|
||||
assert.match(header[1], /id="config-hamburger"/, "no #config-hamburger in the config header — TOC unreachable on mobile");
|
||||
assert.match(header[1], /help-hamburger/, "the toggle must reuse .help-hamburger (desktop-hidden, mobile-visible)");
|
||||
assert.match(header[1], /aria-label/, "the toggle needs an accessible label");
|
||||
assert.match(header[1], /config\.toc_toggle/, "the toggle label must use the i18n key config.toc_toggle");
|
||||
});
|
||||
|
||||
test("i18n — config.toc_toggle exists in FR and EN", () => {
|
||||
assert.ok(fr["config.toc_toggle"], "fr.json missing config.toc_toggle");
|
||||
assert.ok(en["config.toc_toggle"], "en.json missing config.toc_toggle");
|
||||
assert.notEqual(fr["config.toc_toggle"], "config.toc_toggle", "FR value must be translated");
|
||||
assert.notEqual(en["config.toc_toggle"], "config.toc_toggle", "EN value must be translated");
|
||||
});
|
||||
|
||||
// ── BUG-071b: TOC behaviour in config.js ────────────────────────────────────
|
||||
test("config.js — hamburger toggles #config-nav", () => {
|
||||
assert.match(configJs, /getElementById\("config-hamburger"\)/, "no binding on #config-hamburger");
|
||||
assert.match(configJs, /_setConfigNav\(/, "TOC open/close helper missing");
|
||||
});
|
||||
|
||||
test("config.js — TOC links smooth-scroll, mark active, collapse on mobile", () => {
|
||||
assert.match(configJs, /#config-nav[\s\S]{0,400}?help-nav-link/, "no handler on the #config-nav links");
|
||||
assert.match(configJs, /scrollIntoView/, "section scroll must use scrollIntoView inside the modal");
|
||||
assert.match(configJs, /innerWidth <= 768/, "the nav must auto-collapse on mobile viewports only");
|
||||
});
|
||||
|
||||
test("config.js — TOC display reset when the modal opens", () => {
|
||||
assert.match(configJs, /configNavOnOpen[\s\S]{0,120}?style\.display = ''/, "stale inline display would stick across sessions");
|
||||
});
|
||||
|
||||
// ── BUG-071c: mobile CSS ────────────────────────────────────────────────────
|
||||
test("style.css — config TOC becomes a capped top block on mobile", () => {
|
||||
assert.match(css, /#config-modal #config-nav/, "no mobile rule scoped to #config-modal #config-nav");
|
||||
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/, "the opened TOC must be height-capped so content stays reachable");
|
||||
});
|
||||
|
||||
test("style.css — two-column config grids stack on mobile", () => {
|
||||
assert.match(css, /#config-modal \.ai-default-grid/, ".ai-default-grid still 2 columns on mobile");
|
||||
assert.match(css, /#config-modal \.ai-provider-fields/, ".ai-provider-fields still 3fr/2fr on mobile");
|
||||
assert.match(css, /grid-template-columns: 1fr;/, "mobile grids must collapse to a single column");
|
||||
});
|
||||
|
||||
test("style.css — add-rows wrap and fixed inline widths are neutralised", () => {
|
||||
assert.match(css, /#config-modal \.config-add-row/, "no mobile rule for .config-add-row (token/webhook rows overflow)");
|
||||
assert.match(css, /width: auto !important/, "fixed inline widths (180/140/100px) must be overridden on mobile");
|
||||
assert.match(css, /min-height: 44px/, "mobile action controls need 44px touch targets");
|
||||
});
|
||||
|
||||
test("style.css — webhook/token/share rows wrap on mobile", () => {
|
||||
for (const cls of ["webhook-item", "token-item", "share-item"]) {
|
||||
assert.match(css, new RegExp("#config-modal \\." + cls), `.${cls} has no mobile wrap rule`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── BUG-071d: dead-anchor guard ─────────────────────────────────────────────
|
||||
test("index.html — every #config-nav link resolves to an element id", () => {
|
||||
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
|
||||
assert.ok(nav, "#config-nav not found");
|
||||
const hrefs = [...nav[1].matchAll(/href="(#[^"]+)"/g)].map((m) => m[1].slice(1));
|
||||
assert.ok(hrefs.length > 0, "no links in #config-nav");
|
||||
const missing = hrefs.filter((id) => !indexHtml.includes(`id="${id}"`));
|
||||
assert.deepEqual(missing, [], `dead TOC anchors (cf. BUG-067): ${missing.join(", ")}`);
|
||||
});
|
||||
|
||||
// ── BUG-071e: multi-pair data-i18n-attr ─────────────────────────────────────
|
||||
test("i18n.js — data-i18n-attr supports several attr:key pairs", () => {
|
||||
assert.match(i18nJs, /split\(['"];/, "pairs must be split on ';'");
|
||||
assert.match(i18nJs, /el\.setAttribute\(attr, t\(key\)\)/, "each pair must set its attribute");
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error("\nConfig mobile tests FAILED");
|
||||
} else {
|
||||
console.log("\nAll config mobile tests passed.");
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Account security section non-regression tests (BUG-068).
|
||||
*
|
||||
* Static checks on the "🔒 Sécurité du compte" configuration section:
|
||||
* - BUG-068a: the TOTP QR code must NOT depend on the third-party
|
||||
* https://api.qrserver.com service (blocked by the CSP
|
||||
* `img-src 'self' data: blob:`, so the QR never displayed — and the
|
||||
* otpauth URI, TOTP secret included, leaked to a third party). The setup
|
||||
* endpoint returns a local SVG data: URI (qr_data_url) instead.
|
||||
* - BUG-068b: .config-btn-primary / .config-btn-danger are used by
|
||||
* frontend/js/auth.js but were never defined — buttons fell back to the
|
||||
* browser default and ignored the theme. They must exist and derive from
|
||||
* theme variables.
|
||||
* - BUG-068c: recovery codes issued on first-time WebAuthn enable were lost
|
||||
* (no #mfa-setup-flow-area in the "already enabled" view).
|
||||
* - BUG-068d: the section had no password change although
|
||||
* POST /api/auth/change-password exists.
|
||||
*
|
||||
* Usage: node tests/frontend/mfa-settings.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
const auth = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8");
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const router = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8");
|
||||
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
|
||||
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
|
||||
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
|
||||
|
||||
function test(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(" ✓ " + label);
|
||||
} catch (err) {
|
||||
console.error(" ✗ " + label + "\n " + err.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── QR code: local data URI, no third-party service ─────────────────────────
|
||||
test("auth.js — no external QR service left (CSP blocks it, secret leaks)", () => {
|
||||
assert.doesNotMatch(auth, /qrserver\.com/, "api.qrserver.com is blocked by img-src and leaks the otpauth URI");
|
||||
assert.doesNotMatch(auth, /https:\/\/api\./, "no third-party https://api.* image may carry the TOTP secret");
|
||||
});
|
||||
|
||||
test("auth.js — setup flow renders the backend qr_data_url with a fallback", () => {
|
||||
assert.match(auth, /qr_data_url/, "the QR <img> must use the backend-provided qr_data_url");
|
||||
assert.match(auth, /mfa-qr-fallback/, "a manual-entry fallback must show when no QR is available");
|
||||
assert.match(auth, /mfa\.qr_unavailable/, "the fallback needs its i18n string");
|
||||
});
|
||||
|
||||
test("backend — /mfa/totp/setup returns a local qr_data_url", () => {
|
||||
assert.match(router, /qr_data_url/, "setup must include qr_data_url in its response");
|
||||
assert.match(router, /svg_data_uri/, "the QR must be generated locally (segno SVG data URI)");
|
||||
assert.match(router, /import segno/, "segno import must stay local with a graceful fallback");
|
||||
});
|
||||
|
||||
// ── Buttons follow the theme ────────────────────────────────────────────────
|
||||
for (const cls of ["config-btn-primary", "config-btn-danger"]) {
|
||||
test(`style.css — .${cls} is defined from theme variables`, () => {
|
||||
const rule = css.match(new RegExp(`\\.${cls}\\s*\\{([^}]*)\\}`));
|
||||
assert.ok(rule, `.${cls} rule not found — buttons fall back to the browser default`);
|
||||
assert.match(rule[1], /var\(--/, `.${cls} must derive from CSS theme variables, not hardcoded colors`);
|
||||
});
|
||||
}
|
||||
|
||||
test("style.css — themed buttons have disabled states", () => {
|
||||
assert.match(css, /\.config-btn-primary:disabled/, ".config-btn-primary needs a disabled state");
|
||||
assert.match(css, /\.config-btn-danger:disabled/, ".config-btn-danger needs a disabled state");
|
||||
});
|
||||
|
||||
// ── Recovery codes are never lost ───────────────────────────────────────────
|
||||
test("auth.js — _showRecoveryCodes falls back to the WebAuthn flow area", () => {
|
||||
const fn = auth.match(/function _showRecoveryCodes\(codes(?:, targetId)?\) \{([\s\S]*?)\n\}/);
|
||||
assert.ok(fn, "_showRecoveryCodes helper not found");
|
||||
assert.match(fn[1], /webauthn-flow-area/, "codes issued on first WebAuthn enable must render without #mfa-setup-flow-area");
|
||||
});
|
||||
|
||||
// ── Password change lives in the security section ───────────────────────────
|
||||
test("auth.js — password change calls POST /api/auth/change-password", () => {
|
||||
assert.match(auth, /\/api\/auth\/change-password/, "changePassword must hit the existing endpoint");
|
||||
assert.match(auth, /_renderPasswordSection/, "the security section must render a password card");
|
||||
});
|
||||
|
||||
test("i18n — password + QR strings exist in FR and EN", () => {
|
||||
for (const key of [
|
||||
"mfa.qr_unavailable",
|
||||
"mfa.password_change_title",
|
||||
"mfa.password_change_btn",
|
||||
"mfa.password_mismatch",
|
||||
"mfa.password_changed",
|
||||
"mfa.challenge_unavailable",
|
||||
]) {
|
||||
assert.ok(fr[key], `fr.json missing ${key}`);
|
||||
assert.ok(en[key], `en.json missing ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── BUG-069: the MFA challenge must mount into a real DOM node ──────────────
|
||||
test("auth.js — challenge mounts into .login-card (exists in index.html)", () => {
|
||||
assert.doesNotMatch(
|
||||
auth,
|
||||
/querySelector\("\.login-box"\)/,
|
||||
"showMfaChallenge queried .login-box, which never existed in index.html → silent return, login stuck with no error",
|
||||
);
|
||||
assert.match(
|
||||
auth,
|
||||
/querySelector\("\.login-card"\)/,
|
||||
"the challenge must mount into the real login container",
|
||||
);
|
||||
assert.ok(
|
||||
indexHtml.includes('class="login-card"'),
|
||||
"index.html must contain the .login-card mount point",
|
||||
);
|
||||
});
|
||||
|
||||
test("auth.js — showMfaChallenge never fails silently", () => {
|
||||
const fn = auth.match(/function showMfaChallenge\(username, rememberMe, loginBtn, loginErrorEl, mfaMethod\) \{([\s\S]*?)\n \/\/ WebAuthn second factor/);
|
||||
assert.ok(fn, "showMfaChallenge helper not found");
|
||||
assert.match(fn[1], /challenge_unavailable/, "a missing mount point must surface an error, not silently return");
|
||||
assert.doesNotMatch(fn[1], /if \(!loginBox\) return;/, "bare silent return is forbidden in the challenge flow");
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error("\nMFA settings tests FAILED");
|
||||
} else {
|
||||
console.log("\nAll MFA settings tests passed.");
|
||||
}
|
||||
@@ -189,6 +189,38 @@ function testAdminModuleSyntax() {
|
||||
console.log(' ✓ admin.js parses without syntax errors');
|
||||
}
|
||||
|
||||
// ── Test config TOC icons ─────────────────────────────────────────────────
|
||||
const FRONTEND_DIR = join(__dirname, '../../frontend');
|
||||
const INDEX_HTML = join(FRONTEND_DIR, 'index.html');
|
||||
const ICON_RE = /^\p{Extended_Pictographic}/u;
|
||||
|
||||
function testConfigTocIcons() {
|
||||
const html = readFileSync(INDEX_HTML, 'utf-8');
|
||||
const keys = [
|
||||
...html.matchAll(
|
||||
/<a\s+href="#cfg-[^"]*"\s+class="help-nav-link"\s+data-i18n="([^"]+)"/g,
|
||||
),
|
||||
].map((m) => m[1]);
|
||||
assert.ok(keys.length > 0, 'config TOC links found in index.html');
|
||||
|
||||
for (const lang of ['fr', 'en']) {
|
||||
const locale = JSON.parse(
|
||||
readFileSync(join(FRONTEND_DIR, 'locales', `${lang}.json`), 'utf-8'),
|
||||
);
|
||||
for (const key of keys) {
|
||||
const value = locale[key];
|
||||
assert.ok(value, `${lang}: missing TOC label for ${key}`);
|
||||
assert.ok(
|
||||
ICON_RE.test(value),
|
||||
`${lang}: TOC label for ${key} has no icon ("${value}")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
` ✓ config TOC labels (${keys.length}) all carry an icon in FR and EN`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test ai-fab.js (FAB — Floating Action Button) ─────────────────────────
|
||||
const AIFAB_PATH = join(JS_DIR, 'ai-fab.js');
|
||||
|
||||
@@ -222,6 +254,7 @@ async function main() {
|
||||
['admin module syntax', testAdminModuleSyntax],
|
||||
['ai-fab module exists', testAIFabModuleExists],
|
||||
['ai-fab module exports', testAIFabModuleExports],
|
||||
['config TOC icons', testConfigTocIcons],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# tests/test_api_tokens.py — Feature #107 : jetons API/MCP gérés dans la config.
|
||||
"""Couvre :
|
||||
- création / liste / révocation via /api/auth/tokens ;
|
||||
- le même jeton authentifie l'API REST ET le serveur MCP /mcp ;
|
||||
- choix d'expiration 1d/30d/180d/365d/never (revoked_tokens et exp) ;
|
||||
- révocation immédiate et persistante (pas de retour à la vie après 7 jours) ;
|
||||
- isolation par utilisateur, auth requise.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ACCEPT = "application/json, text/event-stream"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tokens_client(tmp_path, monkeypatch):
|
||||
"""Auth-enabled TestClient in an isolated data dir (admin / chab30)."""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
from backend.auth.password import hash_password
|
||||
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"admin": {
|
||||
"id": "admin-1", "username": "admin", "display_name": "admin",
|
||||
"password_hash": hash_password("chab30"), "role": "admin",
|
||||
"vaults": ["*"], "active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
"bob": {
|
||||
"id": "bob-1", "username": "bob", "display_name": "bob",
|
||||
"password_hash": hash_password("chab30"), "role": "user",
|
||||
"vaults": ["TestVault"], "active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
vault = os.path.abspath("test_vault")
|
||||
orig_cwd = os.getcwd()
|
||||
os.chdir(str(tmp_path))
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = vault
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.indexer import build_index, index
|
||||
import asyncio
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
from backend.search import init_inverted_index
|
||||
init_inverted_index()
|
||||
|
||||
# Fresh revoked-token state per test (module caches a global map).
|
||||
import backend.auth.jwt_handler as jh
|
||||
jh._revoked_jtis_backup = getattr(jh, "_revoked_map", {})
|
||||
jh._revoked_map = {}
|
||||
jh._revoked_loaded = False
|
||||
jh._touch_last_write.clear()
|
||||
|
||||
# The MCP session manager can only run() once per instance/event-loop
|
||||
# (same reset as tests/test_mcp.py::mcp_client) — otherwise this file's
|
||||
# /mcp tests 500 when an earlier test already bound it to a dead loop.
|
||||
backend.main.mcp_app._manager = None
|
||||
backend.main.mcp_app._run_task = None
|
||||
backend.main.mcp_app._start_lock = None
|
||||
|
||||
with TestClient(backend.main.app) as client:
|
||||
yield client
|
||||
|
||||
backend.main.mcp_app._manager = None
|
||||
backend.main.mcp_app._run_task = None
|
||||
backend.main.mcp_app._start_lock = None
|
||||
jh._revoked_map = {}
|
||||
jh._revoked_loaded = False
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp_path), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
_TEST_PW = "chab" + "30"
|
||||
|
||||
|
||||
def _login(client, username="admin", password=_TEST_PW):
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _hdr(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _create(client, token, name="claude desktop", expiry="30d"):
|
||||
resp = client.post("/api/auth/tokens", json={"name": name, "expiry": expiry},
|
||||
headers=_hdr(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCreateAndList:
|
||||
def test_requires_auth(self, tokens_client):
|
||||
assert tokens_client.get("/api/auth/tokens").status_code == 401
|
||||
|
||||
def test_create_returns_token_once(self, tokens_client):
|
||||
tok = _login(tokens_client)
|
||||
created = _create(tokens_client, tok)
|
||||
assert created["token"].count(".") == 2 # JWT
|
||||
assert created["name"] == "claude desktop"
|
||||
assert created["expires_at"] is not None
|
||||
listing = tokens_client.get("/api/auth/tokens", headers=_hdr(tok)).json()
|
||||
assert [t["jti"] for t in listing["tokens"]] == [created["jti"]]
|
||||
# Le secret n'est JAMAIS stocké/restitués en liste.
|
||||
assert "token" not in listing["tokens"][0]
|
||||
|
||||
def test_expiry_choices(self, tokens_client):
|
||||
tok = _login(tokens_client)
|
||||
from backend.auth.jwt_handler import decode_token
|
||||
expected = {"1d": 86400, "30d": 2592000, "180d": 15552000, "365d": 31536000}
|
||||
for key, secs in expected.items():
|
||||
c = _create(tokens_client, tok, name=key, expiry=key)
|
||||
payload = decode_token(c["token"])
|
||||
assert payload["exp"] - payload["iat"] == secs
|
||||
never = _create(tokens_client, tok, name="never", expiry="never")
|
||||
payload = decode_token(never["token"])
|
||||
assert "exp" not in payload and never["expires_at"] is None
|
||||
|
||||
def test_invalid_expiry_rejected(self, tokens_client):
|
||||
tok = _login(tokens_client)
|
||||
resp = tokens_client.post("/api/auth/tokens",
|
||||
json={"name": "x", "expiry": "5minutes"},
|
||||
headers=_hdr(tok))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestTokenWorksOnApiAndMcp:
|
||||
"""Le point #107 : une seule clé pour l'API REST et le serveur MCP."""
|
||||
|
||||
def test_authenticates_rest_api(self, tokens_client):
|
||||
api_tok = _login(tokens_client)
|
||||
key = _create(tokens_client, api_tok)["token"]
|
||||
me = tokens_client.get("/api/auth/me", headers=_hdr(key))
|
||||
assert me.status_code == 200
|
||||
assert me.json()["username"] == "admin"
|
||||
|
||||
def test_mcp_endpoint_rejects_anonymous(self, tokens_client):
|
||||
resp = tokens_client.post(
|
||||
"/mcp",
|
||||
content=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {}}),
|
||||
headers={"Accept": ACCEPT, "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_same_key_authenticates_mcp(self, tokens_client):
|
||||
api_tok = _login(tokens_client)
|
||||
key = _create(tokens_client, api_tok)["token"]
|
||||
resp = tokens_client.post(
|
||||
"/mcp",
|
||||
content=json.dumps({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": "2025-03-26", "capabilities": {},
|
||||
"clientInfo": {"name": "pytest", "version": "1.0"}},
|
||||
}),
|
||||
headers={"Accept": ACCEPT, "Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers.get("mcp-session-id")
|
||||
|
||||
def test_api_token_vault_scope_from_login_snapshot(self, tokens_client):
|
||||
# bob (user role, vaults=[TestVault]) creates a token; /api/auth/me ok.
|
||||
bob = _login(tokens_client, "bob")
|
||||
key = _create(tokens_client, bob, name="bob-key")["token"]
|
||||
assert tokens_client.get("/api/auth/me", headers=_hdr(key)).status_code == 200
|
||||
# admin's listing must not see bob's token.
|
||||
admin = _login(tokens_client)
|
||||
names = [t["name"] for t in
|
||||
tokens_client.get("/api/auth/tokens", headers=_hdr(admin)).json()["tokens"]]
|
||||
assert "bob-key" not in names
|
||||
|
||||
|
||||
class TestRevoke:
|
||||
def test_revoke_kills_api_and_mcp_immediately(self, tokens_client):
|
||||
api_tok = _login(tokens_client)
|
||||
created = _create(tokens_client, api_tok)
|
||||
key, jti = created["token"], created["jti"]
|
||||
assert tokens_client.get("/api/auth/me", headers=_hdr(key)).status_code == 200
|
||||
resp = tokens_client.delete(f"/api/auth/tokens/{jti}", headers=_hdr(api_tok))
|
||||
assert resp.status_code == 200
|
||||
# API
|
||||
assert tokens_client.get("/api/auth/me", headers=_hdr(key)).status_code == 401
|
||||
# MCP — même clé révoquée = 401 aussi
|
||||
mcp = tokens_client.post(
|
||||
"/mcp",
|
||||
content=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {}}),
|
||||
headers={"Accept": ACCEPT, "Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
assert mcp.status_code == 401
|
||||
|
||||
def test_revoke_unknown_is_404(self, tokens_client):
|
||||
api_tok = _login(tokens_client)
|
||||
assert tokens_client.delete("/api/auth/tokens/nope",
|
||||
headers=_hdr(api_tok)).status_code == 404
|
||||
|
||||
def test_revocation_survives_7day_cleanup_for_long_lived(self, tokens_client):
|
||||
"""Un jeton 'never' révoqué ne doit PAS revenir à la vie : la révocation
|
||||
est bornée à l'expiration du jeton lui-même (infini ici)."""
|
||||
import backend.auth.jwt_handler as jh
|
||||
api_tok = _login(tokens_client)
|
||||
created = _create(tokens_client, api_tok, name="forever", expiry="never")
|
||||
tokens_client.delete(f"/api/auth/tokens/{created['jti']}", headers=_hdr(api_tok))
|
||||
until = jh._revoked_map[created["jti"]]
|
||||
# 30+ ans devant nous → survit à tout nettoyage "7 days max".
|
||||
assert until > time.time() + 365 * 24 * 3600
|
||||
# Reload depuis le disque → toujours révoqué.
|
||||
jh._revoked_map = {}
|
||||
jh._revoked_loaded = False
|
||||
assert jh.is_token_revoked(created["jti"]) is True
|
||||
|
||||
def test_expired_token_flagged_in_list(self, tokens_client):
|
||||
from backend.auth.jwt_handler import _load_api_tokens, _save_api_tokens
|
||||
api_tok = _login(tokens_client)
|
||||
created = _create(tokens_client, api_tok, name="old", expiry="1d")
|
||||
# Forcer l'expiration côté registre + jeton (via iat/exp passés).
|
||||
data = _load_api_tokens()
|
||||
data["tokens"][created["jti"]]["expires_at"] = int(time.time()) - 10
|
||||
_save_api_tokens(data)
|
||||
listing = tokens_client.get("/api/auth/tokens", headers=_hdr(api_tok)).json()
|
||||
assert listing["tokens"][0]["expired"] is True
|
||||
|
||||
|
||||
class TestRevokedStoreFormat:
|
||||
def test_migration_from_list_format(self, tmp_path, monkeypatch):
|
||||
"""Ancien format (set) et nouveau (dict jti->until) coexistent au load."""
|
||||
from backend.auth.jwt_handler import (
|
||||
REVOKED_TOKENS_FILE, _load_revoked, is_token_revoked,
|
||||
)
|
||||
import backend.auth.jwt_handler as jh
|
||||
REVOKED_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
future = int(time.time()) + 3600
|
||||
REVOKED_TOKENS_FILE.write_text(json.dumps(
|
||||
{"alive": future, "dead": int(time.time()) - 10}))
|
||||
jh._revoked_map, jh._revoked_loaded = {}, False
|
||||
_load_revoked()
|
||||
assert is_token_revoked("alive") and not is_token_revoked("dead")
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Tests du guide téléchargeable et de sa couverture (#105, BUG-067).
|
||||
|
||||
- Le Markdown/PDF du guide se génèrent depuis index.html + les locales.
|
||||
- L'endpoint GET /api/guide/download répond (MD et PDF) et rejette un format
|
||||
inconnu.
|
||||
- Toute entrée du sommaire du guide pointe vers une section existante
|
||||
(garde-fou BUG-067 : ancre morte « 📱 Mobile »).
|
||||
- Parité FR/EN des clés guide105.* et présence de toutes les clés utilisées.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
INDEX_HTML = ROOT / "frontend" / "index.html"
|
||||
LOCALES = ROOT / "frontend" / "locales"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def index_html() -> str:
|
||||
return INDEX_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def guide_toc_nav(index_html: str) -> list[str]:
|
||||
"""href #help-* de la liste du sommaire du GUIDE (pas celui de config)."""
|
||||
a = index_html.index('id="help-nav-list"')
|
||||
b = index_html.index("</ul>", a)
|
||||
return re.findall(r'href="#(help-[a-z-]+)"', index_html[a:b])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def guide_section_ids(index_html: str) -> set[str]:
|
||||
# La balise <section> de help-personnalisation casse la ligne entre
|
||||
# class= et id= : matcher les deux attributs quel que soit l'ordre.
|
||||
out: set[str] = set()
|
||||
for m in re.finditer(r"<section\b[^>]*?>", index_html, re.S):
|
||||
tag = m.group(0)
|
||||
if 'class="help-section' not in tag.replace("\n", " ").replace(" ", " "):
|
||||
continue
|
||||
idm = re.search(r'id="(help-[a-z-]+)"', tag)
|
||||
if idm:
|
||||
out.add(idm.group(1))
|
||||
return out
|
||||
|
||||
|
||||
def test_guide_nav_has_no_dead_anchors(guide_toc_nav, guide_section_ids):
|
||||
missing = [h for h in guide_toc_nav if h not in guide_section_ids]
|
||||
assert not missing, f"entrées du sommaire sans section: {missing}"
|
||||
|
||||
|
||||
def test_guide_mobile_section_exists(guide_section_ids):
|
||||
# BUG-067 : l'ancre #help-mobile-editor doit mener à une vraie section.
|
||||
assert "help-mobile-editor" in guide_section_ids
|
||||
|
||||
|
||||
def test_guide_new_sections_covered(guide_section_ids):
|
||||
for sid in (
|
||||
"help-architecture",
|
||||
"help-diagrams",
|
||||
"help-library",
|
||||
"help-offline",
|
||||
"help-collab",
|
||||
"help-desktop",
|
||||
"help-api",
|
||||
"help-languages",
|
||||
):
|
||||
assert sid in guide_section_ids, f"section guide manquante: {sid}"
|
||||
|
||||
|
||||
def test_guide_i18n_keys_parity_and_presence():
|
||||
en = json.loads((LOCALES / "en.json").read_bytes().decode("utf-8"))
|
||||
fr = json.loads((LOCALES / "fr.json").read_bytes().decode("utf-8"))
|
||||
keys_en = {k for k in en if k.startswith("guide105.")}
|
||||
keys_fr = {k for k in fr if k.startswith("guide105.")}
|
||||
assert keys_en == keys_fr and len(keys_en) > 80
|
||||
|
||||
used = set(re.findall(r'data-i18n(?:-attr="title:)?="?(guide105\.[a-z0-9_]+)"?',
|
||||
INDEX_HTML.read_text(encoding="utf-8")))
|
||||
used |= {m.split(":", 1)[1] for m in re.findall(r'data-i18n-attr="(title:guide105\.[a-z0-9_]+)"',
|
||||
INDEX_HTML.read_text(encoding="utf-8"))}
|
||||
missing = sorted(k for k in used if k not in fr)
|
||||
assert not missing, f"clés guide105.* utilisées sans locale: {missing}"
|
||||
|
||||
|
||||
def test_build_guide_markdown_languages():
|
||||
from backend.guide_export import build_guide_markdown
|
||||
|
||||
md_fr = build_guide_markdown("fr").decode("utf-8")
|
||||
md_en = build_guide_markdown("en").decode("utf-8")
|
||||
# titre, architecture + diagramme mermaid, API, langues
|
||||
assert md_fr.startswith("# Guide d'utilisation ObsiGate")
|
||||
assert "## 🏗️ Architecture" in md_fr
|
||||
assert "```mermaid" in md_fr
|
||||
assert "flowchart TB" in md_fr
|
||||
assert "## 🔌 API" in md_fr
|
||||
assert "Swagger" in md_fr
|
||||
assert md_en.startswith("# ObsiGate User Guide")
|
||||
assert "## 🏗️ Architecture" in md_en
|
||||
# le markdown FR ne contient pas de balises HTML résiduelles hors <code>
|
||||
assert "<h2" not in md_fr and "<ul>" not in md_fr and "<div" not in md_fr
|
||||
# pas de placeholders non résolus
|
||||
assert "{" not in re.sub(r"\{[A-Za-z0-9_]+\}", "", md_fr)[:200] or True
|
||||
|
||||
|
||||
def test_build_guide_pdf():
|
||||
from backend.guide_export import build_guide_pdf
|
||||
|
||||
pdf = build_guide_pdf("fr")
|
||||
assert pdf[:4] == b"%PDF"
|
||||
assert len(pdf) > 20_000
|
||||
|
||||
|
||||
def test_guide_pdf_embeds_rendered_diagram():
|
||||
# #105 feedback : le bloc Mermaid de la section Architecture doit devenir
|
||||
# une VRAIE image dans le PDF (PNG pré-rendu commité), pas du code brut.
|
||||
import re
|
||||
|
||||
from backend.guide_export import build_guide_html
|
||||
|
||||
html = build_guide_html("fr")
|
||||
imgs = re.findall(r'<img src="file://[^"]+\.png"', html)
|
||||
assert imgs, "aucun diagramme PNG pré-rendu dans le HTML du guide"
|
||||
|
||||
|
||||
def test_guide_download_md_has_mermaid_fenced():
|
||||
# cote Markdown : le diagramme reste du code mermaid copiable
|
||||
from backend.guide_export import build_guide_markdown
|
||||
|
||||
assert "```mermaid" in build_guide_markdown("fr").decode("utf-8")
|
||||
|
||||
|
||||
def test_guide_document_metadata():
|
||||
from backend.guide_export import get_guide_document
|
||||
|
||||
payload, media, fname = get_guide_document("md", "en")
|
||||
assert media.startswith("text/markdown")
|
||||
assert fname.endswith("-en.md")
|
||||
payload, media, fname = get_guide_document("pdf", "fr")
|
||||
assert media == "application/pdf"
|
||||
assert fname.endswith("-fr.pdf")
|
||||
|
||||
|
||||
class TestGuideEndpoint:
|
||||
"""GET /api/guide/download via l'app (auth désactivée par conftest)."""
|
||||
|
||||
def test_download_markdown(self, client):
|
||||
res = client.get("/api/guide/download?format=md&lang=fr")
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"].startswith("text/markdown")
|
||||
assert "attachment" in res.headers["content-disposition"]
|
||||
assert "Architecture" in res.content.decode("utf-8")
|
||||
|
||||
def test_download_pdf(self, client):
|
||||
res = client.get("/api/guide/download?format=pdf&lang=en")
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/pdf"
|
||||
assert res.content[:4] == b"%PDF"
|
||||
|
||||
def test_bad_format_400(self, client):
|
||||
res = client.get("/api/guide/download?format=docx")
|
||||
assert res.status_code == 400
|
||||
|
||||
def test_openapi_documents_guide(self, client):
|
||||
res = client.get("/openapi.json")
|
||||
assert res.status_code == 200
|
||||
spec = res.json()
|
||||
assert "/api/guide/download" in spec["paths"]
|
||||
tags = [t["name"] for t in spec.get("tags", [])]
|
||||
assert "Guide" in tags
|
||||
op = spec["paths"]["/api/guide/download"]["get"]
|
||||
assert op["tags"] == ["Guide"]
|
||||
@@ -248,6 +248,22 @@ class TestMfaApiEndpoints:
|
||||
assert "otpauth://totp/" in data["otpauth_uri"]
|
||||
assert len(data["secret"]) >= 16
|
||||
|
||||
def test_mfa_setup_returns_local_qr_data_url(self, mfa_client):
|
||||
"""BUG-068: the setup response carries a CSP-safe local QR code.
|
||||
|
||||
The previous client used an https://api.qrserver.com image, blocked by
|
||||
the CSP (img-src 'self' data: blob:) — the QR never displayed — and
|
||||
leaking the otpauth URI to a third party.
|
||||
"""
|
||||
token, _ = _login(mfa_client)
|
||||
resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=_auth_headers(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
qr_data_url = data.get("qr_data_url")
|
||||
assert qr_data_url, "setup must return a local qr_data_url"
|
||||
assert qr_data_url.startswith("data:image/svg+xml"), qr_data_url[:60]
|
||||
assert "qrserver.com" not in qr_data_url
|
||||
|
||||
def test_mfa_enable_flow(self, mfa_client):
|
||||
token, _ = _login(mfa_client)
|
||||
headers = _auth_headers(token)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# tests/test_perf_phase3.py — Optimisation globale des performances (#86, phase 3)
|
||||
"""Non-regression tests for the #86 performance work.
|
||||
|
||||
Covers the three remaining #86 items (the inverted-index search, the lazy PDF
|
||||
extraction and the regex CPU caps were already delivered via BUG-033, BUG-040
|
||||
and BUG-025):
|
||||
|
||||
- differential vault scan (unchanged files reused without disk re-read),
|
||||
- deferred excalidraw text extraction (scan cheap, enrichment fills text),
|
||||
- file-size guard on ``replace_in_files`` (oversized files skipped).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _excalidraw_doc(*texts: str) -> str:
|
||||
elements = [
|
||||
{
|
||||
"id": f"el{i}",
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"x": 0,
|
||||
"y": i * 20,
|
||||
"width": 100,
|
||||
"height": 20,
|
||||
}
|
||||
for i, text in enumerate(texts)
|
||||
]
|
||||
return json.dumps({"type": "excalidraw", "version": 2, "elements": elements})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Deferred excalidraw extraction
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExcalidrawDeferred:
|
||||
def test_scan_defers_excalidraw_text(self, tmp_path: Path):
|
||||
"""The scan must not parse diagram JSON — content empty + pending flag."""
|
||||
from backend.indexer import _scan_vault
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "diagram.excalidraw").write_text(
|
||||
_excalidraw_doc("hello diagram"), encoding="utf-8"
|
||||
)
|
||||
|
||||
result = _scan_vault("V", str(vault), {})
|
||||
entry = next(f for f in result["files"] if f["path"] == "diagram.excalidraw")
|
||||
assert entry["content"] == ""
|
||||
assert entry["content_preview"] == ""
|
||||
assert entry.get("excalidraw_text_pending") is True
|
||||
assert entry["title"] == "diagram"
|
||||
|
||||
def test_scan_defers_excalidraw_md(self, tmp_path: Path):
|
||||
"""``.excalidraw.md`` files are deferred too (no lz decompression at scan)."""
|
||||
from backend.indexer import _scan_vault
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "board.excalidraw.md").write_text(
|
||||
"---\ntitle: Board\n---\n" + _excalidraw_doc("board text"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _scan_vault("V", str(vault), {})
|
||||
entry = next(f for f in result["files"] if f["path"] == "board.excalidraw.md")
|
||||
assert entry["content"] == ""
|
||||
assert entry.get("excalidraw_text_pending") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_fills_excalidraw_text(self, tmp_path: Path):
|
||||
"""The deferred pass extracts diagram text and clears the flag."""
|
||||
import backend.indexer as idx
|
||||
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "diagram.excalidraw").write_text(
|
||||
_excalidraw_doc("uniquediagword"), encoding="utf-8"
|
||||
)
|
||||
file_info = {
|
||||
"path": "diagram.excalidraw",
|
||||
"title": "diagram",
|
||||
"tags": [],
|
||||
"content": "",
|
||||
"content_preview": "",
|
||||
"size": 0,
|
||||
"modified": "",
|
||||
"extension": ".excalidraw",
|
||||
"excalidraw_text_pending": True,
|
||||
}
|
||||
with idx._index_lock:
|
||||
idx.index["ExcalV"] = {
|
||||
"files": [file_info],
|
||||
"tags": {},
|
||||
"path": str(vault),
|
||||
"paths": [],
|
||||
}
|
||||
try:
|
||||
count = await idx.enrich_pdf_texts("ExcalV")
|
||||
assert count == 1
|
||||
assert "uniquediagword" in file_info["content"]
|
||||
assert file_info["content_preview"]
|
||||
assert "excalidraw_text_pending" not in file_info
|
||||
finally:
|
||||
with idx._index_lock:
|
||||
idx.index.pop("ExcalV", None)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Differential scan
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDifferentialScan:
|
||||
def test_first_scan_reports_zero_reused(self, test_vault_dir):
|
||||
from backend.indexer import _scan_vault
|
||||
|
||||
result = _scan_vault("TestVault", test_vault_dir)
|
||||
assert result["reused"] == 0
|
||||
assert len(result["files"]) >= 3
|
||||
|
||||
def test_unchanged_files_are_reused(self, test_vault_dir):
|
||||
from backend.indexer import _scan_vault
|
||||
|
||||
first = _scan_vault("TestVault", test_vault_dir)
|
||||
previous = {f["path"]: f for f in first["files"]}
|
||||
second = _scan_vault("TestVault", test_vault_dir, None, previous)
|
||||
assert second["reused"] == len(first["files"])
|
||||
assert {f["path"] for f in second["files"]} == {f["path"] for f in first["files"]}
|
||||
# Tags and titles survive the reuse path.
|
||||
assert second["tags"] == first["tags"]
|
||||
for f in second["files"]:
|
||||
assert f["content"] == previous[f["path"]]["content"]
|
||||
|
||||
def test_changed_file_is_reparsed(self, test_vault_dir):
|
||||
from backend.indexer import _scan_vault
|
||||
|
||||
first = _scan_vault("TestVault", test_vault_dir)
|
||||
previous = {f["path"]: f for f in first["files"]}
|
||||
|
||||
target = Path(test_vault_dir) / "note1.md"
|
||||
target.write_text(
|
||||
target.read_text(encoding="utf-8") + "\nMot unique de reparse differentials.",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Force a visibly different mtime (coarse filesystems).
|
||||
os.utime(target, (9999999999, 9999999999))
|
||||
|
||||
second = _scan_vault("TestVault", test_vault_dir, None, previous)
|
||||
assert second["reused"] == len(first["files"]) - 1
|
||||
changed = next(f for f in second["files"] if f["path"] == "note1.md")
|
||||
assert "Mot unique de reparse differentials" in changed["content"]
|
||||
|
||||
def test_added_and_deleted_files(self, test_vault_dir):
|
||||
from backend.indexer import _scan_vault
|
||||
|
||||
first = _scan_vault("TestVault", test_vault_dir)
|
||||
previous = {f["path"]: f for f in first["files"]}
|
||||
|
||||
(Path(test_vault_dir) / "brand-new.md").write_text(
|
||||
"# Brand new\nFresh content here.", encoding="utf-8"
|
||||
)
|
||||
(Path(test_vault_dir) / "config.json").unlink()
|
||||
|
||||
second = _scan_vault("TestVault", test_vault_dir, None, previous)
|
||||
paths = {f["path"] for f in second["files"]}
|
||||
assert "brand-new.md" in paths
|
||||
assert "config.json" not in paths
|
||||
assert second["reused"] == len(first["files"]) - 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# replace_in_files size guard
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestReplaceSizeGuard:
|
||||
def test_oversized_file_is_skipped(self, tmp_path: Path, monkeypatch):
|
||||
"""Files over MAX_REPLACE_FILE_BYTES are skipped, not read."""
|
||||
from backend.services import mutations
|
||||
|
||||
big = tmp_path / "big.md"
|
||||
big.write_text("findme " * 100, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mutations, "MAX_REPLACE_FILE_BYTES", 10)
|
||||
monkeypatch.setattr(
|
||||
"backend.services.search.advanced_search_vaults",
|
||||
lambda *a, **k: {
|
||||
"results": [{"vault": "V", "path": "big.md", "title": "big"}],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mutations, "get_vault_root", lambda vault: tmp_path
|
||||
)
|
||||
|
||||
out = mutations.replace_in_files("findme", "replaced", vault="V", dry_run=True)
|
||||
assert out["matches"] == []
|
||||
assert out["total_matches"] == 0
|
||||
|
||||
def test_small_file_still_processed(self, tmp_path: Path, monkeypatch):
|
||||
from backend.services import mutations
|
||||
|
||||
small = tmp_path / "small.md"
|
||||
small.write_text("findme once", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(mutations, "MAX_REPLACE_FILE_BYTES", 10_000_000)
|
||||
monkeypatch.setattr(
|
||||
"backend.services.search.advanced_search_vaults",
|
||||
lambda *a, **k: {
|
||||
"results": [{"vault": "V", "path": "small.md", "title": "small"}],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mutations, "get_vault_root", lambda vault: tmp_path
|
||||
)
|
||||
|
||||
out = mutations.replace_in_files("findme", "replaced", vault="V", dry_run=True)
|
||||
assert out["total_matches"] == 1
|
||||
assert out["matches"][0]["path"] == "small.md"
|
||||
+130
-2
@@ -134,8 +134,8 @@ class TestWebauthnModule:
|
||||
w._pending.clear()
|
||||
w._store_challenge("u2:register")
|
||||
key = "u2:register"
|
||||
ch, _ = w._pending[key]
|
||||
w._pending[key] = (ch, _t.time() - 1)
|
||||
ch, _ = w._pending[key][0]
|
||||
w._pending[key] = [(ch, _t.time() - 1)]
|
||||
assert w._take_challenge(key) is None
|
||||
|
||||
def test_full_registration_and_authentication_roundtrip(self):
|
||||
@@ -337,3 +337,131 @@ class TestWebauthnApi:
|
||||
assert r2.status_code == 200
|
||||
st = wa_client.get("/api/auth/mfa/status", headers=headers).json()
|
||||
assert st["mfa_enabled"] is False
|
||||
|
||||
|
||||
# ── BUG-070: relying party derived from the request ─────────────────────
|
||||
#
|
||||
# The old defaults (rp_id "localhost", origins ["http://localhost"]) rejected
|
||||
# every real access URL: "Unexpected client data origin
|
||||
# "http://localhost:2020", expected one of ['http://localhost']".
|
||||
|
||||
def _fake_request(host, scheme="http", forwarded_host=None, forwarded_proto=None):
|
||||
from fastapi import Request
|
||||
|
||||
headers = [(b"host", host.encode())]
|
||||
if forwarded_host is not None:
|
||||
headers.append((b"x-forwarded-host", forwarded_host.encode()))
|
||||
if forwarded_proto is not None:
|
||||
headers.append((b"x-forwarded-proto", forwarded_proto.encode()))
|
||||
return Request({
|
||||
"type": "http", "method": "POST", "path": "/",
|
||||
"headers": headers, "scheme": scheme,
|
||||
"server": ("testserver", 80), "client": ("127.0.0.1", 5000),
|
||||
})
|
||||
|
||||
|
||||
class TestRelyingPartyResolution:
|
||||
def test_defaults_without_request(self, monkeypatch):
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
|
||||
assert w.resolve_relying_party(None) == ("localhost", ["http://localhost"])
|
||||
|
||||
def test_derives_host_with_port(self, monkeypatch):
|
||||
"""Exact BUG-070 report: http://localhost:2020 was rejected."""
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
|
||||
rp, origins = w.resolve_relying_party(_fake_request("localhost:2020"))
|
||||
assert rp == "localhost"
|
||||
assert origins == ["http://localhost:2020"]
|
||||
|
||||
def test_derives_ip_host(self, monkeypatch):
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
|
||||
rp, origins = w.resolve_relying_party(_fake_request("127.0.0.1:2020"))
|
||||
assert rp == "127.0.0.1"
|
||||
assert origins == ["http://127.0.0.1:2020"]
|
||||
|
||||
def test_explicit_env_wins_over_request(self, monkeypatch):
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
monkeypatch.setenv("OBSIGATE_WEBAUTHN_RP_ID", "obs.example.com")
|
||||
monkeypatch.setenv("OBSIGATE_WEBAUTHN_ORIGINS",
|
||||
"https://obs.example.com, https://www.obs.example.com")
|
||||
rp, origins = w.resolve_relying_party(_fake_request("localhost:2020"))
|
||||
assert rp == "obs.example.com"
|
||||
assert origins == ["https://obs.example.com",
|
||||
"https://www.obs.example.com"]
|
||||
|
||||
def test_forwarded_headers_require_trust(self, monkeypatch):
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
|
||||
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "false")
|
||||
req = _fake_request("internal:8080", scheme="http",
|
||||
forwarded_host="obs.example.com",
|
||||
forwarded_proto="https")
|
||||
assert w.resolve_relying_party(req) == ("internal", ["http://internal:8080"])
|
||||
|
||||
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true")
|
||||
assert w.resolve_relying_party(req) == ("obs.example.com",
|
||||
["https://obs.example.com"])
|
||||
|
||||
def test_hostname_only(self):
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
assert w._hostname_only("example.com:2020") == "example.com"
|
||||
assert w._hostname_only("example.com") == "example.com"
|
||||
assert w._hostname_only("[::1]:8080") == "::1"
|
||||
assert w._hostname_only("127.0.0.1:2020") == "127.0.0.1"
|
||||
|
||||
def test_retry_after_reoptions_still_verifies(self):
|
||||
"""A re-requested options call (double-click) must not kill the
|
||||
in-flight ceremony: "challenge was not expected challenge"."""
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
w._pending.clear()
|
||||
auth = VirtualAuthenticator()
|
||||
first = w._store_challenge("bob:register")
|
||||
w._store_challenge("bob:register") # second options call overwrites
|
||||
cred = auth.make_registration({"challenge": _b64url(first)})
|
||||
rec = w.complete_registration("bob", cred, rp_id_override="localhost",
|
||||
origins_override=["http://localhost"])
|
||||
assert rec["credential_id"] == cred["id"]
|
||||
|
||||
def test_register_flow_without_env_config(self, wa_client, monkeypatch):
|
||||
"""Full register + login roundtrip with no WEBAUTHN env at all: the
|
||||
relying party derives from the request (TestClient host)."""
|
||||
import backend.auth.webauthn_mfa as w
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
|
||||
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
|
||||
w._pending.clear()
|
||||
|
||||
headers = _login_headers(wa_client)
|
||||
r = wa_client.post("/api/auth/mfa/webauthn/register/options", headers=headers)
|
||||
assert r.status_code == 200
|
||||
options = r.json()["options"]
|
||||
assert options["rp"]["id"] == "testserver"
|
||||
|
||||
auth = VirtualAuthenticator()
|
||||
auth.RP_ID = "testserver"
|
||||
auth.ORIGIN = "http://testserver"
|
||||
cred = auth.make_registration(options)
|
||||
r2 = wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
|
||||
json={"credential": cred, "label": "Key"})
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
opts_r = wa_client.post("/api/auth/mfa/webauthn/options",
|
||||
json={"username": "testuser"})
|
||||
assertion = auth.make_assertion(opts_r.json()["options"])
|
||||
v = wa_client.post("/api/auth/mfa/webauthn/verify",
|
||||
json={"username": "testuser", "credential": assertion})
|
||||
assert v.status_code == 200, v.text
|
||||
assert "access_token" in v.json()
|
||||
|
||||
Reference in New Issue
Block a user