Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
562290d922 | ||
|
|
66a5505965 | ||
|
|
c5c225a68e | ||
|
|
1bacfd69d9 | ||
|
|
383ffa6a65 | ||
|
|
69927176df | ||
|
|
5c2ae26a74 | ||
|
|
3f52b56251 | ||
|
|
72da123a51 | ||
|
|
e94af0369b | ||
|
|
8da65611cb | ||
|
|
605060c51d | ||
|
|
856e654306 | ||
|
|
4de9ee038c | ||
|
|
290d62da4e | ||
|
|
140e9a679d | ||
|
|
267a33d43b | ||
|
|
435a0687d7 | ||
|
|
dbf935bec0 | ||
|
|
d6d081c0e9 | ||
|
|
c72f852a55 | ||
|
|
99779ecc08 | ||
|
|
c4b8e66206 | ||
|
|
ca6407e0c0 | ||
|
|
dff32a97ee | ||
|
|
d5c528fead | ||
|
|
38f39a10ae | ||
|
|
48e023ba25 | ||
|
|
011ec84f23 | ||
|
|
472ea9d309 | ||
|
|
6ba04c4381 | ||
|
|
06f8e63d06 | ||
|
|
31d4616baf | ||
|
|
4c4b1222d5 | ||
|
|
a3973b981c | ||
|
|
b6e2029770 | ||
|
|
6b878caff3 | ||
|
|
e9b7a317c1 | ||
|
|
14b8032635 | ||
|
|
7dfe26c83d | ||
|
|
24229316c7 | ||
|
|
7d70e0fb75 | ||
|
|
d70ecd0968 | ||
|
|
36a4030c09 | ||
|
|
330462e7a5 | ||
|
|
922dfa2e79 | ||
|
|
34fce932cb | ||
|
|
18b1e13f34 | ||
|
|
7bee4a237d | ||
|
|
d6cca2b1af | ||
|
|
58312e64da | ||
|
|
3b0927a8c9 | ||
|
|
6e527c371d | ||
|
|
6cccdc1f34 | ||
|
|
0abc17e9f2 | ||
|
|
b83d8dacdf | ||
|
|
dadc055429 | ||
|
|
750114a923 | ||
|
|
83a81da319 | ||
|
|
3eb0256127 | ||
|
|
9d8b3cc854 | ||
|
|
0ab402aa73 | ||
|
|
c36c299466 | ||
|
|
8611416670 | ||
|
|
e20fd6bf97 | ||
|
|
943005328c | ||
|
|
e1842043d8 | ||
|
|
9fb094f505 | ||
|
|
d142049216 | ||
|
|
33fe1a3439 | ||
|
|
a726ad8511 | ||
|
|
b926f01b85 | ||
|
|
8264e7ffae | ||
|
|
80852374a8 | ||
|
|
e3c6789776 | ||
|
|
e2417cb5ab | ||
|
|
eccbf7474e | ||
|
|
69cee4d93a | ||
|
|
705f755b6b | ||
|
|
8ad8eaac71 | ||
|
|
dd9224e685 |
@@ -12,8 +12,12 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local.
|
||||
# OBSIGATE_ALLOW_INSECURE=false
|
||||
|
||||
# Sécurité des cookies (activer si derrière HTTPS)
|
||||
# OBSIGATE_SECURE_COOKIES=false
|
||||
# Sécurité des cookies : true|false|auto (défaut : auto — Secure si la
|
||||
# requête arrive en https, sinon pas de flag ; les navigateurs ignorent les
|
||||
# cookies `Secure` en HTTP, ce qui casserait les logins en local).
|
||||
# Derrière un reverse proxy qui termine TLS, auto suffit avec
|
||||
# OBSIGATE_TRUST_PROXY=true (X-Forwarded-Proto honoré).
|
||||
# OBSIGATE_SECURE_COOKIES=auto
|
||||
|
||||
# Tokens TTL en secondes
|
||||
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
|
||||
@@ -23,6 +27,8 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# OBSIGATE_LOGIN_MAX_ATTEMPTS=10
|
||||
# OBSIGATE_ACCOUNT_MAX_ATTEMPTS=10
|
||||
# OBSIGATE_LOGIN_WINDOW_SECONDS=900
|
||||
# Compteurs partagés/persistants (SQLite WAL, multi-workers) — défaut : mémoire.
|
||||
# OBSIGATE_RATELIMIT_DB=data/ratelimit.db
|
||||
|
||||
# IP client derrière un reverse proxy (fait confiance à X-Forwarded-For)
|
||||
# OBSIGATE_TRUST_PROXY=false
|
||||
|
||||
@@ -38,10 +38,17 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: |
|
||||
node tests/frontend/unit.test.mjs
|
||||
node tests/frontend/image-viewer.test.mjs
|
||||
node tests/frontend/pdf-viewer.test.mjs
|
||||
node tests/frontend/forge-completion.test.mjs
|
||||
node tests/frontend/config-mobile.test.mjs
|
||||
node tests/frontend/settings-order-avatar.test.mjs
|
||||
node tests/frontend/mobile-toolbar.test.mjs
|
||||
node tests/frontend/pretty.test.mjs
|
||||
node tests/frontend/media-viewer.test.mjs
|
||||
node tests/frontend/mfa-settings.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload + XLSX)
|
||||
run: |
|
||||
cd tests/frontend
|
||||
if [ -d node_modules ]; then
|
||||
@@ -59,6 +66,9 @@ jobs:
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -76,6 +86,9 @@ jobs:
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
@@ -118,15 +131,59 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
# setuptools / pip sont mis à jour : l'image de base peut embarquer
|
||||
# une version couverte par un advisory fraîchement publié
|
||||
# (PYSEC-2026-3447 / PYSEC-2026-3721).
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: |
|
||||
pip install -U pip setuptools
|
||||
pip install bandit pip-audit
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Bandit (SAST)
|
||||
run: bandit -r backend/ --skip B101,B110,B310 || echo "bandit found issues (non-blocking)"
|
||||
- name: Bandit (SAST, bloquant — #87)
|
||||
# B105 est exclu (aligné avec [tool.bandit] de pyproject.toml :
|
||||
# faux positifs systématiques sur les noms de variables) ; les rares
|
||||
# vrais positifs restants portent un `# nosec` justifié inline.
|
||||
run: bandit -r backend/ --skip B101,B105,B110,B310
|
||||
|
||||
- name: Pip-audit (dependency vulnerabilities)
|
||||
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking)"
|
||||
- name: Semgrep (SAST local) — DÉSACTIVÉ (BUG-091)
|
||||
# Les règles locales (semgrep-rules/, 8 règles) ne sont plus exécutées
|
||||
# en CI : semgrep-core est un exécutable natif que le runner actuel ne
|
||||
# peut pas lancer (exit 127, sans message exploitable) — les releases
|
||||
# récentes exigent un CPU x86-64-v2, et la dernière version compatible
|
||||
# (1.157.0, core statique vérifié en baseline v1) échoue aussi. Les
|
||||
# règles restent applicables en local : `semgrep --config semgrep-rules/
|
||||
# backend/`. À réactiver dès que le runner dispose d'un CPU x86-64-v2
|
||||
# (ou d'une image de runner plus récente). Bandit et pip-audit, eux,
|
||||
# restent bloquants dans ce job.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "::warning::SAST semgrep non exécutée (runner incompatible — BUG-091). Bandit et pip-audit restent bloquants."
|
||||
|
||||
- name: Pip-audit (bloquant — #87)
|
||||
# Bloquant depuis T6 (#87) : dépendances qualifiées (mistune 3.3.3,
|
||||
# python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1
|
||||
# + starlette 1.7.0, setuptools 84 — suite complète verte + 0 vuln).
|
||||
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
|
||||
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
|
||||
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
|
||||
# s'exécutent jamais. Les advisories pyjwt (PYSEC-2026-178 puis
|
||||
# CVE-2026-102274) sont corrigées par le plancher pyjwt>=2.14.0 de
|
||||
# backend/requirements.txt (BUG-091, BUG-095).
|
||||
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
||||
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
||||
# via backend/pdf_reader.py) sont corrigés par le plancher
|
||||
# pypdf>=6.16.1 (BUG-093).
|
||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||
# corrigés par le plancher urllib3>=2.8.0.
|
||||
# Ces planchers doivent rester *au-dessus* des versions préinstallées
|
||||
# dans la toolcache de l'image du runner : en dessous, pip répond
|
||||
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
|
||||
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
|
||||
# TestDependencySecurityFloors verrouille ces planchers.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
@@ -188,6 +245,9 @@ jobs:
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Npm audit (bloquant — #87, 0 dépendance prod hors Playwright)
|
||||
run: npm audit --omit=dev
|
||||
|
||||
- name: Start ObsiGate
|
||||
run: |
|
||||
docker rm -f obsigate-e2e 2>/dev/null || true
|
||||
|
||||
@@ -31,6 +31,20 @@ desktop/backend/
|
||||
desktop/frontend/
|
||||
backend/VERSION
|
||||
|
||||
# Artefacts générés par les runs E2E (excalidraw crée ces diagrammes)
|
||||
test_vault/IT/e2e-diagram-*.excalidraw
|
||||
|
||||
# Fixtures de test locales non versionnées (~200 Mo, pas de fixture CI).
|
||||
# Aucun test/CI ne les référence : les tests unitaires génèrent leurs fixtures
|
||||
# dans tmp_path (tests/conftest.py), et l'E2E n'utilise que les fixtures
|
||||
# committées (test_vault/sample-*.{mp3,png,svg,webm,pdf}, test_dir/*.md).
|
||||
# → à committer volontairement : `git add -f <chemin>`.
|
||||
test_dir/music/
|
||||
test_dir/video/
|
||||
test_vault/images/
|
||||
test_vault/markdown/
|
||||
test_vault/budget.xlsx
|
||||
|
||||
# Tauri updater signing keys (private key — never commit)
|
||||
desktop/*.key
|
||||
desktop/*.key.pub
|
||||
|
||||
@@ -44,9 +44,13 @@ node tests/frontend/unit.test.mjs
|
||||
# Tests JSDOM : node_modules dans tests/frontend/ (npm install là-bas si absent), ex :
|
||||
node tests/frontend/pane-manager.test.mjs
|
||||
|
||||
# E2E (si UI touchée, ~5 min) : reproduit le job CI e2e (port 2029, auth désactivée)
|
||||
# E2E (si UI touchée, ~10 min) : reproduit le job CI e2e (port 2029, auth désactivée)
|
||||
npm run test:e2e # prérequis : uv, Node >= 20, npx playwright install chromium
|
||||
bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
|
||||
|
||||
# Windows sans bash exploitable (WSL HS, git-bash bloqué par App Control) :
|
||||
npm run test:e2e:ps # équivalent PowerShell, mêmes conditions que le CI
|
||||
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','nom du test')
|
||||
```
|
||||
|
||||
- Un seul test backend : `.\.venv\Scripts\python.exe -m pytest tests/test_search.py -q`.
|
||||
@@ -91,6 +95,7 @@ bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
|
||||
| Travail à venir + index | `docs/ROADMAP.md` |
|
||||
| Historique des versions | `CHANGELOG.md` |
|
||||
| Conception par feature | `docs/features/<slug>.md` |
|
||||
| Guides d'utilisation | `docs/GUIDES/` |
|
||||
| Archive du complété | `docs/archive/COMPLETED_v1-v2.md` |
|
||||
| Bugs / TODO | `docs/ISSUES_TODOLIST.md` |
|
||||
| Build & releases | `docs/DEVELOPMENT_AND_RELEASES.md` |
|
||||
|
||||
@@ -1,61 +1,75 @@
|
||||
# ObsiGate
|
||||
|
||||
> **Version française** — ce document est le miroir synchronisé de [README.md](README.md) (référence complète). Dernière synchronisation : juin 2026.
|
||||
> **Version française** — ce document est le miroir synchronisé de [README.md](README.md) (référence complète). Dernière synchronisation : septembre 2026.
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
[](https://git.dracodev.net/Projets/ObsiGate/actions)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Recherche...] [☀/🌙 Thème] ObsiGate │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ SIDEBAR │ CONTENT AREA │
|
||||
│ ▼ Recettes │ 📄 Titre du fichier │
|
||||
│ 📁 Soupes │ Tags: #recette #rapide │
|
||||
│ 📄 Pizza │ [Contenu Markdown rendu] │
|
||||
│ ▼ IT │ │
|
||||
│ 📁 Docker │ │
|
||||
│ Tags Cloud │ │
|
||||
└──────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||

|
||||
|
||||
> Interface web d'ObsiGate : sidebar multi-vault, recherche globale, statistiques et raccourcis.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Guides
|
||||
|
||||
Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/GUIDES/) :
|
||||
|
||||
| Guide | Contenu |
|
||||
|---|---|
|
||||
| 🚀 [Prise en main](docs/GUIDES/PRISE_EN_MAIN.md) | Premier lancement, interface, navigation, vaults, raccourcis |
|
||||
| 🔍 [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteurs PDF/Excel, diagrammes |
|
||||
| 🤖 [Assistant IA & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
|
||||
| 📝 [Édition & collaboration](docs/GUIDES/COLLABORATION.md) | Édition simultanée, curseurs distants, persistance |
|
||||
| 📱 [PWA & hors-ligne](docs/GUIDES/PWA_HORS_LIGNE.md) | Installation, cache hors-ligne, file de synchro, notifications |
|
||||
| 🔌 [API REST](docs/GUIDES/API_REST.md) | Authentification, clés API, endpoints, exemples `curl`, SSE |
|
||||
| 🧩 [Serveur MCP](docs/GUIDES/MCP.md) | Brancher Claude Desktop, Cursor, Cline… sur vos vaults |
|
||||
| 🔒 [Authentification & sécurité](docs/GUIDES/AUTHENTIFICATION_SECURITE.md) | Utilisateurs, MFA, permissions par vault, durcissement |
|
||||
| 🐳 [Déploiement Docker](docs/GUIDES/DEPLOIEMENT_DOCKER.md) | `docker-compose`, volumes, reverse proxy, mises à jour |
|
||||
| 🖥️ [Desktop (Tauri)](docs/GUIDES/DESKTOP.md) | Installation, premier lancement, build depuis les sources, dépannage |
|
||||
|
||||
> Index complet : [`docs/GUIDES/README.md`](docs/GUIDES/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table des matières
|
||||
|
||||
- [Fonctionnalités](#fonctionnalites)
|
||||
- [Prérequis](#prerequis)
|
||||
- [Installation rapide](#installation-rapide)
|
||||
- [Configuration détaillée](#configuration-detaillee)
|
||||
- [Variables d'environnement](#variables-denvironnement)
|
||||
- [🔒 Authentification](#authentification)
|
||||
- [Ajouter une nouvelle vault](#ajouter-une-nouvelle-vault)
|
||||
- [Build & déploiement avec build.sh](#build-deploiement-avec-buildsh)
|
||||
- [Rendu d'images Obsidian](#rendu-dimages-obsidian)
|
||||
- [Desktop (Tauri) — Application native](#desktop-tauri-application-native)
|
||||
- [Utilisation](#utilisation)
|
||||
- [API](#api)
|
||||
- [Recherche avancée](#recherche-avancee)
|
||||
- [Dépannage](#depannage)
|
||||
- [Performance](#performance)
|
||||
- [Sécurité](#securite)
|
||||
- [Stack technique](#stack-technique)
|
||||
- [Architecture](#architecture)
|
||||
- [Développement](#developpement)
|
||||
- [Licence](#licence)
|
||||
- [Changelog](#changelog)
|
||||
- ✨ [Fonctionnalités](#fonctionnalites)
|
||||
- 📚 [Guides](#guides)
|
||||
- 🚀 [Prérequis](#prerequis)
|
||||
- ⚡ [Installation rapide](#installation-rapide)
|
||||
- ⚙️ [Configuration détaillée](#configuration-detaillee)
|
||||
- 🌍 [Variables d'environnement](#variables-denvironnement)
|
||||
- 🔒 [Authentification](#authentification)
|
||||
- ➕ [Ajouter une nouvelle vault](#ajouter-une-nouvelle-vault)
|
||||
- 🔨 [Build & déploiement avec build.sh](#build-deploiement-avec-buildsh)
|
||||
- 🖼️ [Rendu d'images Obsidian](#rendu-dimages-obsidian)
|
||||
- 🖥️ [Desktop (Tauri) — Application native](#desktop-tauri-application-native)
|
||||
- 📖 [Utilisation](#utilisation)
|
||||
- 👥 [Collaboration temps réel](#collaboration-temps-reel)
|
||||
- 🔌 [API](#api)
|
||||
- 🔍 [Recherche avancée](#recherche-avancee)
|
||||
- 🔧 [Dépannage](#depannage)
|
||||
- ⚡ [Performance](#performance)
|
||||
- 🛡️ [Sécurité](#securite)
|
||||
- 🏗️ [Stack technique](#stack-technique)
|
||||
- 🏠 [Architecture](#architecture)
|
||||
- 📝 [Développement](#developpement)
|
||||
- 📄 [Licence](#licence)
|
||||
- 🤝 [Support](#support)
|
||||
- 📝 [Changelog](#changelog)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
- **🤖 AI Editor intégré** — Éditeur CodeMirror 6 avec toolbar IA : amélioration, correction, traduction, génération, réécriture personnalisée, toolbox (liste, tableau, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
|
||||
- **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/MCP_GUIDE.md))
|
||||
- **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/GUIDES/MCP.md))
|
||||
- **👥 Collaboration temps réel** — Édition simultanée d'un même document (Yjs/CRDT) : curseurs distants colorés, indicateur de présence, fusion sans conflit, reconnexion automatique et persistance serveur ([détail](docs/features/collaboration.md))
|
||||
- **📖 Guide d'utilisation intégré** — Aide complète en FR/EN accessible depuis le menu Options : interface, navigation, recherche, fichiers, IA, sécurité, API & intégrations (OpenAPI, MCP), hors-ligne, collaboration, desktop, plus une section **Architecture** avec diagramme Mermaid ; téléchargeable en **Markdown** et **PDF** dans la langue courante ([détail](docs/features/guide-coverage-105.md))
|
||||
- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md))
|
||||
@@ -69,7 +83,9 @@
|
||||
- **🏷️ Tag cloud** : Filtrage par tags extraits des frontmatters YAML
|
||||
- **🔗 Wikilinks** : Les `[[liens internes]]` Obsidian sont cliquables
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
@@ -282,6 +298,7 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Autoriser les webhooks non HTTPS | `false` |
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Autoriser les webhooks vers des adresses privées/boucle | `false` |
|
||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
|
||||
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Taille max pour la lecture audio/vidéo intégrée (au-delà : téléchargement) | `500` |
|
||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
|
||||
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Fournisseurs de recherche web à clé (essayés avant SearXNG) | — |
|
||||
| `OBSIGATE_WEB_PROVIDERS` | Ordre des fournisseurs de recherche (ex. `brave,searxng`) | — |
|
||||
@@ -392,6 +409,18 @@ ObsiGate supporte **toutes les syntaxes d'images Obsidian** avec résolution int
|
||||
6. Index de démarrage (match le plus proche)
|
||||
7. Fallback : placeholder stylisé `[image not found: filename.ext]`
|
||||
|
||||
### Visionneuse & arborescence
|
||||
|
||||
Les images sont de plein droit des fichiers du vault : elles apparaissent dans
|
||||
l'arborescence, sont indexées (nom + métadonnées, **jamais les octets**) et
|
||||
s'ouvrent dans une **visionneuse dédiée** — zoom molette 0,1×–8×, pan au
|
||||
glisser, double-clic pour réinitialiser, navigation ←/→ entre les images du
|
||||
dossier (avec pellicule de miniatures WebP), panneau de métadonnées, lightbox
|
||||
plein écran, ouverture de l'original et téléchargement. Le filtre de recherche
|
||||
`ext:png`/`ext:jpg` est disponible. Formats décodables : PNG, JPEG, GIF, WebP,
|
||||
BMP, ICO, SVG (SVG servi avec une politique CSP `sandbox`). **HEIC/HEIF**
|
||||
(iPhone) n'est pas décodable par les navigateurs et n'est pas pris en charge.
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
@@ -412,6 +441,8 @@ curl -X POST http://localhost:2020/api/attachments/rescan/MonVault
|
||||
|
||||
## 🖥️ Desktop (Tauri) — Application native
|
||||
|
||||
> 📖 Guide complet : [Desktop (Tauri)](docs/GUIDES/DESKTOP.md)
|
||||
|
||||
ObsiGate Desktop est une application native construite avec [Tauri](https://tauri.app/) (Rust + webview système). Elle embarque le backend Python et le frontend dans un exécutable standalone — zéro Docker, zéro ligne de commande.
|
||||
|
||||
> 🚧 **Version 2.0.0 — binaires en cours de stabilisation.** Pour l'instant, le build depuis les sources est recommandé.
|
||||
@@ -571,6 +602,8 @@ Cycle de vie : Tauri spawn le backend Python → health check → splash de dém
|
||||
|
||||
## 👥 Collaboration temps réel
|
||||
|
||||
> 📖 Guide complet : [Édition & collaboration](docs/GUIDES/COLLABORATION.md)
|
||||
|
||||
Plusieurs utilisateurs peuvent éditer le même document markdown simultanément (façon Google Docs) :
|
||||
|
||||
- **Fusion sans conflit** grâce à Yjs (CRDT) : deux personnes peuvent taper au même endroit, aucune
|
||||
@@ -590,6 +623,8 @@ fenêtres) pour voir la collaboration en action.
|
||||
|
||||
## 🔌 API
|
||||
|
||||
> 📖 Guide complet : [API REST](docs/GUIDES/API_REST.md) · [Serveur MCP](docs/GUIDES/MCP.md)
|
||||
|
||||
ObsiGate expose une API REST complète :
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
@@ -617,6 +652,7 @@ ObsiGate expose une API REST complète :
|
||||
| `/api/events` | Flux SSE temps réel | GET | Oui |
|
||||
| `/api/vaults/add` / `/api/vaults/{name}` | Gestion dynamique des vaults | POST/DELETE | Admin |
|
||||
| `/api/image/{vault}?path=` | Servir une image | GET | Oui |
|
||||
| `/api/media/{vault}/thumb?path=&size=` | Miniature WebP (cache disque) | GET | Oui |
|
||||
| `/api/config` | Lire / écrire la configuration | GET/POST | Oui/Admin |
|
||||
| `/api/diagnostics` | Statistiques index et mémoire | GET | Admin |
|
||||
|
||||
@@ -637,6 +673,8 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
|
||||
|
||||
## 🔍 Recherche avancée
|
||||
|
||||
> 📖 Guide complet : [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Syntaxe de requête
|
||||
|
||||
| Opérateur | Description | Exemple |
|
||||
@@ -758,6 +796,8 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
|
||||
|
||||
## 🛡️ Sécurité
|
||||
|
||||
> 📖 Guide complet : [Authentification & sécurité](docs/GUIDES/AUTHENTIFICATION_SECURITE.md)
|
||||
|
||||
- **Path traversal** : tous les endpoints fichier valident que le chemin résolu reste dans la vault
|
||||
- **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives)
|
||||
- **Audit log** : écritures/suppressions/config journalisées dans `data/audit.log` (JSON lines, rotation 10 MB)
|
||||
@@ -833,7 +873,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
|
||||
| Validation des imports frontend | `node tests/frontend/validate-imports.mjs` | `lint` |
|
||||
| Tests unitaires frontend | `node tests/frontend/unit.test.mjs` | `lint` |
|
||||
| Tests backend | `pytest tests/ -q` | `test` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
|
||||
|
||||
#### Tests E2E locaux (`npm run test:e2e`)
|
||||
|
||||
@@ -856,6 +896,15 @@ bash scripts/run-e2e-local.sh --headed # navigateur visible
|
||||
bash scripts/run-e2e-local.sh -g "reset panes" # filtre sur un test
|
||||
```
|
||||
|
||||
Sous Windows, si `bash` n'est pas exploitable (WSL indisponible, git-bash
|
||||
bloqué par une politique de contrôle d'application), utiliser le lanceur
|
||||
PowerShell équivalent :
|
||||
|
||||
```powershell
|
||||
npm run test:e2e:ps
|
||||
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
|
||||
```
|
||||
|
||||
La suite doit se terminer sur **tous les tests passant** (60 actuellement),
|
||||
sans échec ni dépendance aux retries. En cas d'échec : corriger et relancer
|
||||
localement jusqu'à 100 %, puis seulement commiter.
|
||||
@@ -927,8 +976,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.16.3).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.45.2).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.16.3 | Dernière mise à jour : Juin 2026*
|
||||
*Projet : ObsiGate | Version : 2.45.2 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,53 +2,73 @@
|
||||
|
||||
**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/)
|
||||
[](https://git.dracodev.net/Projets/ObsiGate/actions)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Search...] [☀/🌙 Theme] ObsiGate │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ SIDEBAR │ CONTENT AREA │
|
||||
│ ▼ Recipes │ 📄 File Title │
|
||||
│ 📁 Soups │ Tags: #recipe #quick │
|
||||
│ 📄 Pizza │ [Rendered Markdown Content] │
|
||||
│ ▼ IT │ │
|
||||
│ 📁 Docker │ │
|
||||
│ Tags Cloud │ │
|
||||
└──────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||

|
||||
|
||||
> ObsiGate web interface: multi-vault sidebar, global search, dashboard stats and shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Guides
|
||||
|
||||
Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
|
||||
| Guide | What it covers |
|
||||
|---|---|
|
||||
| 🚀 [Getting Started](docs/GUIDES/PRISE_EN_MAIN.md) | First run, interface, navigation, vaults, shortcuts |
|
||||
| 🔍 [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF/Excel viewers, diagrams |
|
||||
| 🤖 [AI Assistant & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Providers, AI editor, BooksLM, Forge, `@` / `/` commands |
|
||||
| 📝 [Editing & Collaboration](docs/GUIDES/COLLABORATION.md) | Simultaneous editing, remote cursors, persistence |
|
||||
| 📱 [PWA & Offline](docs/GUIDES/PWA_HORS_LIGNE.md) | Install as an app, offline cache, sync queue, push |
|
||||
| 🔌 [REST API](docs/GUIDES/API_REST.md) | Authentication, API keys, endpoints, `curl` examples, SSE |
|
||||
| 🧩 [MCP Server](docs/GUIDES/MCP.md) | Connect Claude Desktop, Cursor, Cline… to your vaults |
|
||||
| 🔒 [Auth & Security](docs/GUIDES/AUTHENTIFICATION_SECURITE.md) | Users, MFA, per-vault permissions, hardening |
|
||||
| 🐳 [Docker Deployment](docs/GUIDES/DEPLOIEMENT_DOCKER.md) | `docker-compose`, volumes, reverse proxy, updates |
|
||||
| 🖥️ [Desktop (Tauri)](docs/GUIDES/DESKTOP.md) | Install, first run, build from source, troubleshooting |
|
||||
|
||||
> All guides are currently written in **French**. See the full index:
|
||||
> [`docs/GUIDES/README.md`](docs/GUIDES/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Architecture](#architecture)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Installation](#quick-installation)
|
||||
- [Detailed Configuration](#detailed-configuration)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [🔒 Authentication](#authentication)
|
||||
- [Adding a New Vault](#adding-a-new-vault)
|
||||
- [Build & Deployment with build.sh](#build--deployment-with-buildsh)
|
||||
- [Desktop (Tauri) — Native Application](#desktop-tauri--native-application)
|
||||
- [Usage](#usage)
|
||||
- [API](#api)
|
||||
- [Performance](#performance)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Changelog](#changelog)
|
||||
- ✨ [Features](#features)
|
||||
- 📚 [Guides](#guides)
|
||||
- 🚀 [Prerequisites](#prerequisites)
|
||||
- ⚡ [Quick Installation](#quick-installation)
|
||||
- ⚙️ [Detailed Configuration](#detailed-configuration)
|
||||
- 🌍 [Environment Variables](#environment-variables)
|
||||
- 🔒 [Authentication](#authentication)
|
||||
- ➕ [Adding a New Vault](#adding-a-new-vault)
|
||||
- 🔨 [Build & Deployment with build.sh](#build--deployment-with-buildsh)
|
||||
- 🖼️ [Obsidian Image Rendering](#obsidian-image-rendering)
|
||||
- 🖥️ [Desktop (Tauri) — Native Application](#desktop-tauri--native-application)
|
||||
- 📖 [Usage](#usage)
|
||||
- 👥 [Real-time Collaboration](#real-time-collaboration)
|
||||
- 🔌 [API](#api)
|
||||
- 🔍 [Advanced Search](#advanced-search)
|
||||
- 🛡️ [Security](#security)
|
||||
- ⚡ [Performance](#performance)
|
||||
- 🔧 [Troubleshooting](#troubleshooting)
|
||||
- 🏗️ [Tech Stack](#tech-stack)
|
||||
- 🏠 [Architecture](#architecture)
|
||||
- 📝 [Development](#development)
|
||||
- 📄 [License](#license)
|
||||
- 🤝 [Support](#support)
|
||||
- 📝 [Changelog](#changelog)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🤖 Integrated AI Editor** — CodeMirror 6 editor with AI toolbar: improve, correct, translate, generate, custom rewrite, toolbox (list, table, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
|
||||
- **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/MCP_GUIDE.md))
|
||||
- **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/GUIDES/MCP.md))
|
||||
- **👥 Real-time Collaboration** — Simultaneous editing of the same document (Yjs/CRDT): colored remote cursors, presence indicator, conflict-free merge, automatic reconnection and server-side persistence ([details](docs/features/collaboration.md))
|
||||
- **📖 Built-in User Guide** — Complete FR/EN help from the Options menu: interface, navigation, search, files, AI, security, API & integrations (OpenAPI, MCP), offline, collaboration, desktop, plus an **Architecture** section with a Mermaid diagram; downloadable as **Markdown** and **PDF** in the current language ([details](docs/features/guide-coverage-105.md))
|
||||
- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md))
|
||||
@@ -62,7 +82,9 @@
|
||||
- **🏷️ Tag Cloud** : Filtering by tags extracted from YAML frontmatters
|
||||
- **🔗 Wikilinks** : `[[internal links]]` from Obsidian are clickable
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -320,6 +342,7 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Allow non-HTTPS webhook targets | `false` |
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Allow webhooks to private/loopback addresses | `false` |
|
||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
|
||||
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Max size for inline audio/video playback (above: download) | `500` |
|
||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
|
||||
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Keyed web-search providers (tried before SearXNG) | — |
|
||||
| `OBSIGATE_WEB_PROVIDERS` | Search provider order (e.g. `brave,searxng`) | — |
|
||||
@@ -496,6 +519,17 @@ ObsiGate uses 7 resolution strategies in order of priority:
|
||||
6. **Startup index (closest match)** : If multiple files have the same name
|
||||
7. **Fallback** : Display a styled placeholder `[image not found: filename.ext]`
|
||||
|
||||
### Viewer & file tree
|
||||
|
||||
Images are first-class vault files: they appear in the tree, are indexed (name +
|
||||
metadata, **never the bytes**) and open in a **dedicated viewer** — wheel zoom
|
||||
0.1×–8×, drag pan, double-click to reset, ←/→ navigation between images in the
|
||||
same folder (WebP thumbnail filmstrip), metadata panel, full-screen lightbox,
|
||||
open original and download. The `ext:png`/`ext:jpg` search filter is available.
|
||||
Decodable formats: PNG, JPEG, GIF, WebP, BMP, ICO, SVG (SVG served with a
|
||||
`sandbox` CSP). **HEIC/HEIF** (iPhone) is not decodable by browsers and is not
|
||||
supported.
|
||||
|
||||
### Configuration
|
||||
|
||||
To optimize resolution, configure the attachments folder for each vault:
|
||||
@@ -520,6 +554,8 @@ curl -X POST http://localhost:2020/api/attachments/rescan/MyVault
|
||||
|
||||
## 🖥️ Desktop (Tauri) — Native Application
|
||||
|
||||
> 📖 Full guide: [Desktop (Tauri)](docs/GUIDES/DESKTOP.md)
|
||||
|
||||
ObsiGate Desktop is a native application built with [Tauri](https://tauri.app/) (Rust + system webview). It embeds the Python backend and frontend in a standalone executable — zero Docker, zero command line.
|
||||
|
||||
> 🚧 **Version 2.0.0 — binaries are being stabilized.** For now, building from source is recommended.
|
||||
@@ -687,6 +723,8 @@ Lifecycle: Tauri spawns the Python backend → health check → opens the webvie
|
||||
|
||||
## 👥 Real-time Collaboration
|
||||
|
||||
> 📖 Full guide: [Editing & Collaboration](docs/GUIDES/COLLABORATION.md)
|
||||
|
||||
Multiple users can edit the same markdown document simultaneously (Google Docs style):
|
||||
|
||||
- **Conflict-free merge** via Yjs (CRDT): two people can type in the same place, no change is lost.
|
||||
@@ -703,6 +741,8 @@ No configuration is required: open the same file in two browsers (or two windows
|
||||
|
||||
## 🔌 API
|
||||
|
||||
> 📖 Full guide: [REST API](docs/GUIDES/API_REST.md) · [MCP Server](docs/GUIDES/MCP.md)
|
||||
|
||||
ObsiGate exposes a complete REST API :
|
||||
|
||||
| Endpoint | Description | Method | Auth |
|
||||
@@ -730,6 +770,7 @@ ObsiGate exposes a complete REST API :
|
||||
| `/api/events` | Real-time SSE stream | GET | Yes |
|
||||
| `/api/vaults/add` / `/api/vaults/{name}` | Dynamic vault management | POST/DELETE | Admin |
|
||||
| `/api/image/{vault}?path=` | Serve an image | GET | Yes |
|
||||
| `/api/media/{vault}/thumb?path=&size=` | WebP thumbnail (disk cache) | GET | Yes |
|
||||
| `/api/config` | Read / write configuration | GET/POST | Yes/Admin |
|
||||
| `/api/diagnostics` | Index and memory statistics | GET | Admin |
|
||||
|
||||
@@ -763,6 +804,8 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
|
||||
|
||||
## 🔍 Advanced Search
|
||||
|
||||
> 📖 Full guide: [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Query Syntax
|
||||
|
||||
| Operator | Description | Example |
|
||||
@@ -915,6 +958,8 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
|
||||
|
||||
## 🛡️ Security
|
||||
|
||||
> 📖 Full guide: [Auth & Security](docs/GUIDES/AUTHENTIFICATION_SECURITE.md)
|
||||
|
||||
- **Path traversal** : All file endpoints validate that the resolved path stays within the vault
|
||||
- **Rate limiting** : 10 login attempts max per IP over 15 minutes + per-account lockout (5 attempts)
|
||||
- **Audit log** : All writes, deletions, and config changes are logged in `data/audit.log` (JSON lines, 10 MB rotation)
|
||||
@@ -998,7 +1043,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
|
||||
| Frontend import validation | `node tests/frontend/validate-imports.mjs` | `lint` |
|
||||
| Frontend unit tests | `node tests/frontend/unit.test.mjs` | `lint` |
|
||||
| Backend tests | `pytest tests/ -q` | `test` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
|
||||
|
||||
#### Local E2E Tests (`npm run test:e2e`)
|
||||
|
||||
@@ -1021,6 +1066,14 @@ bash scripts/run-e2e-local.sh --headed # visible browser
|
||||
bash scripts/run-e2e-local.sh -g "reset panes" # filter on a test
|
||||
```
|
||||
|
||||
On Windows, when `bash` is unusable (WSL unavailable, git-bash blocked by an
|
||||
Application Control policy), use the equivalent PowerShell launcher:
|
||||
|
||||
```powershell
|
||||
npm run test:e2e:ps
|
||||
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
|
||||
```
|
||||
|
||||
The suite must end with **all tests passing** (60 currently), with no failure
|
||||
or reliance on retries. In case of failure: fix and re-run locally until 100 %,
|
||||
then only commit.
|
||||
@@ -1070,7 +1123,9 @@ ObsiGate/
|
||||
├── Dockerfile # Multi-stage, healthcheck, non-root
|
||||
├── docker-compose.yml # Deployment with healthcheck and auth env vars
|
||||
├── build.sh # Automated build & deployment (docker compose build + up)
|
||||
└── docs/CONTRIBUTING.md # Contribution guide
|
||||
└── docs/
|
||||
├── GUIDES/ # User guides (getting started, API, MCP, desktop…)
|
||||
└── CONTRIBUTING.md # Contribution guide
|
||||
```
|
||||
|
||||
### Contributing
|
||||
@@ -1096,8 +1151,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.16.3).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.45.2).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.16.3 | Last updated: May 2026*
|
||||
*Project: ObsiGate | Version: 2.45.2 | Last updated: September 2026*
|
||||
|
||||
@@ -28,6 +28,7 @@ from backend.tools.api import (
|
||||
ToolError,
|
||||
ToolScope,
|
||||
call_tool,
|
||||
get_tool,
|
||||
get_tool_schemas,
|
||||
)
|
||||
from backend.tools.labels import thought_step_label, tool_step_label
|
||||
@@ -121,12 +122,15 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
|
||||
def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]:
|
||||
"""Answer a tool call that was not reached because the run stopped early.
|
||||
|
||||
A single LLM response may carry several tool calls. When one of them is
|
||||
mutating and pauses the run for confirmation, the assistant message already
|
||||
lists *all* of them, so every ``tool_call_id`` must get a tool result before
|
||||
the next LLM call (the OpenAI tool protocol rejects dangling ids). The calls
|
||||
that were not reached get a synthetic ``deferred`` result; the model
|
||||
re-issues them once the confirmed call has been applied (BUG-050).
|
||||
A single LLM response may carry several tool calls; when the run stops
|
||||
before reaching some of them (tool-call quota), the assistant message still
|
||||
lists *all* of them, so every ``tool_call_id`` must get a tool result
|
||||
before the next LLM call (the OpenAI tool protocol rejects dangling ids).
|
||||
The calls that were not reached get a synthetic ``deferred`` result.
|
||||
|
||||
Note: mutating calls that pause the run for confirmation are no longer
|
||||
deferred — they are batched and applied together on resume (BUG-075); this
|
||||
helper remains for budget stops (BUG-050/BUG-052).
|
||||
"""
|
||||
return {
|
||||
"role": "tool",
|
||||
@@ -135,13 +139,29 @@ def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, An
|
||||
"content": json.dumps({
|
||||
"status": "deferred",
|
||||
"reason": reason or (
|
||||
"Not executed: the run paused to confirm an earlier tool call. "
|
||||
"Not executed: the run stopped before reaching this tool call. "
|
||||
"Re-issue this call if it is still needed."
|
||||
),
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
|
||||
def _action_descriptor(call: Any) -> dict[str, Any]:
|
||||
"""Describe one paused mutating tool call for the confirmation payload.
|
||||
|
||||
A single LLM response may request several mutations (create a folder and
|
||||
the files inside it…). They are batched into one confirmation so the user
|
||||
approves the whole plan in one click (BUG-075). ``step`` reuses the
|
||||
Notion-style label, so the confirmation card reads like the steps block.
|
||||
"""
|
||||
return {
|
||||
"id": call.id,
|
||||
"tool": call.name,
|
||||
"arguments": call.arguments,
|
||||
"step": tool_step_label(call.name, call.arguments),
|
||||
}
|
||||
|
||||
|
||||
def _fallback_summary(executed: list[ToolCallRecord]) -> str:
|
||||
"""Deterministic non-empty answer built from the gathered tool results.
|
||||
|
||||
@@ -212,53 +232,66 @@ def _execute_confirmed(
|
||||
executed: list[ToolCallRecord],
|
||||
on_tool_call: Callable[[ToolCallRecord], None] | None,
|
||||
) -> None:
|
||||
"""Apply a previously-paused mutating tool call and feed its result back.
|
||||
"""Apply previously-paused mutating tool calls and feed their results back.
|
||||
|
||||
The pending payload is the ``error`` object emitted by a ``confirmation``
|
||||
event. The assistant tool-call message is expected to already be in
|
||||
event, optionally carrying an ``actions`` list with every mutating call of
|
||||
the LLM turn (BUG-075). Each action is applied with a one-shot confirmation
|
||||
and its ``tool_call_id`` answered, keeping the conversation valid for the
|
||||
resumed turn. The assistant tool-call message is expected to already be in
|
||||
``convo`` (it is part of the snapshot returned with the confirmation).
|
||||
"""
|
||||
from backend.ai_chat import ToolCall
|
||||
|
||||
error = confirm_pending.get("error", confirm_pending)
|
||||
name = error.get("tool")
|
||||
arguments = error.get("arguments") or {}
|
||||
call_id = error.get("id") or "call_pending"
|
||||
error = confirm_pending.get("error", confirm_pending) or {}
|
||||
actions = confirm_pending.get("actions")
|
||||
if not isinstance(actions, list) or not actions:
|
||||
# Legacy single-action payload (no ``actions`` list).
|
||||
actions = [{
|
||||
"id": error.get("id") or "call_pending",
|
||||
"tool": error.get("tool"),
|
||||
"arguments": error.get("arguments") or {},
|
||||
}]
|
||||
|
||||
if not name:
|
||||
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
|
||||
for action in actions:
|
||||
name = action.get("tool")
|
||||
arguments = action.get("arguments") or {}
|
||||
call_id = action.get("id") or "call_pending"
|
||||
|
||||
# Make sure the assistant tool-call message is present in the snapshot.
|
||||
if not any(
|
||||
m.get("role") == "assistant" and any(
|
||||
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
|
||||
if not name:
|
||||
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
|
||||
|
||||
# Make sure the assistant tool-call message is present in the snapshot.
|
||||
if not any(
|
||||
m.get("role") == "assistant" and any(
|
||||
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
|
||||
)
|
||||
for m in convo
|
||||
):
|
||||
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
|
||||
|
||||
try:
|
||||
result = call_tool(name, ctx, arguments, confirm=True)
|
||||
payload = result.data
|
||||
ok = True
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(
|
||||
name=name, arguments=arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(name, arguments),
|
||||
)
|
||||
for m in convo
|
||||
):
|
||||
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
|
||||
executed.append(record)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
try:
|
||||
result = call_tool(name, ctx, arguments, confirm=True)
|
||||
payload = result.data
|
||||
ok = True
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(
|
||||
name=name, arguments=arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(name, arguments),
|
||||
)
|
||||
executed.append(record)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
|
||||
async def run_agent(
|
||||
@@ -315,6 +348,36 @@ async def run_agent(
|
||||
convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)]
|
||||
executed: list[ToolCallRecord] = []
|
||||
|
||||
def _run_call(call: Any) -> None:
|
||||
"""Execute one tool call, record it and answer its ``tool_call_id``.
|
||||
|
||||
``ToolConfirmationRequired`` propagates to the caller so the loop can
|
||||
pause and batch the mutating calls of the turn (BUG-075).
|
||||
"""
|
||||
try:
|
||||
result = call_tool(call.name, ctx, call.arguments)
|
||||
payload: Any = result.data
|
||||
ok = True
|
||||
except ToolConfirmationRequired:
|
||||
raise
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
record = ToolCallRecord(
|
||||
name=call.name, arguments=call.arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(call.name, call.arguments),
|
||||
)
|
||||
executed.append(record)
|
||||
steps.append(record.step)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.id,
|
||||
"name": call.name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
if confirm_pending:
|
||||
if quota is not None and len(executed) >= quota:
|
||||
return AgentResult(
|
||||
@@ -357,19 +420,25 @@ async def run_agent(
|
||||
llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED
|
||||
)
|
||||
try:
|
||||
result = call_tool(call.name, ctx, call.arguments)
|
||||
payload = result.data
|
||||
ok = True
|
||||
_run_call(call)
|
||||
except ToolConfirmationRequired as e:
|
||||
logger.info(f"Agent paused: confirmation required for '{call.name}'")
|
||||
pending = e.to_dict()
|
||||
# Include the tool-call id so the client can echo it back.
|
||||
pending["error"]["id"] = call.id
|
||||
# BUG-050: the assistant message lists every tool call of this
|
||||
# batch, so answer the ones we did not reach to keep the
|
||||
# conversation valid for the resumed turn.
|
||||
for skipped in response.tool_calls[index + 1:]:
|
||||
convo.append(_deferred_tool_message(skipped))
|
||||
# BUG-075: batch every mutating call of this LLM turn so the
|
||||
# user approves the whole plan at once (one resume applies them
|
||||
# all) instead of approving one action after another. Read-only
|
||||
# calls of the batch run immediately and answer their
|
||||
# ``tool_call_id`` so the resumed turn stays valid.
|
||||
actions = [_action_descriptor(call)]
|
||||
for after in response.tool_calls[index + 1:]:
|
||||
spec = get_tool(after.name)
|
||||
if spec is not None and spec.requires_confirmation:
|
||||
actions.append(_action_descriptor(after))
|
||||
else:
|
||||
_run_call(after)
|
||||
pending["actions"] = actions
|
||||
return AgentResult(
|
||||
content=response.content or "",
|
||||
messages=convo,
|
||||
@@ -379,25 +448,6 @@ async def run_agent(
|
||||
stopped=STOP_CONFIRMATION_REQUIRED,
|
||||
pending=pending,
|
||||
)
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(
|
||||
name=call.name, arguments=call.arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(call.name, call.arguments),
|
||||
)
|
||||
executed.append(record)
|
||||
steps.append(record.step)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.id,
|
||||
"name": call.name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
logger.warning(f"Agent reached max iterations ({max_iterations})")
|
||||
return await _finalize_answer(
|
||||
|
||||
@@ -4,10 +4,9 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.attachment_indexer")
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
|
||||
# Image file extensions to index
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
|
||||
logger = logging.getLogger("obsigate.attachment_indexer")
|
||||
|
||||
# Global attachment index: {vault_name: {filename_lower: [absolute_path, ...]}}
|
||||
attachment_index: dict[str, dict[str, list[Path]]] = {}
|
||||
|
||||
@@ -119,25 +119,30 @@ def decode_token(token: str) -> dict | None:
|
||||
_revoked_map: dict[str, int] = {}
|
||||
_revoked_loaded = False
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour du read-modify-write du store de
|
||||
# révocation (perte de révocations en cas de logouts concurrents).
|
||||
_revoked_lock = threading.RLock()
|
||||
|
||||
|
||||
def _load_revoked():
|
||||
"""Load revoked token JTIs from disk into memory (once)."""
|
||||
global _revoked_loaded, _revoked_map
|
||||
if _revoked_loaded:
|
||||
return
|
||||
if REVOKED_TOKENS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(REVOKED_TOKENS_FILE.read_text())
|
||||
# Drop entries whose underlying token has itself expired.
|
||||
now = int(time.time())
|
||||
_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_map = {}
|
||||
_revoked_loaded = True
|
||||
with _revoked_lock:
|
||||
if _revoked_loaded:
|
||||
return
|
||||
if REVOKED_TOKENS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(REVOKED_TOKENS_FILE.read_text())
|
||||
# Drop entries whose underlying token has itself expired.
|
||||
now = int(time.time())
|
||||
_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_map = {}
|
||||
_revoked_loaded = True
|
||||
|
||||
|
||||
def _save_revoked():
|
||||
@@ -154,24 +159,26 @@ def revoke_token(jti: str, expires_at: int | None = None):
|
||||
``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
|
||||
"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()
|
||||
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()
|
||||
with _revoked_lock:
|
||||
_load_revoked()
|
||||
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]}...")
|
||||
|
||||
|
||||
def is_token_revoked(jti: str) -> bool:
|
||||
"""Check if a token JTI has been revoked."""
|
||||
_load_revoked()
|
||||
return jti in _revoked_map
|
||||
with _revoked_lock:
|
||||
_load_revoked()
|
||||
return jti in _revoked_map
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
|
||||
# and admin user CRUD.
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
@@ -13,7 +16,7 @@ from backend.ratelimit import record_account_failure as rl_record_account_failur
|
||||
from backend.ratelimit import record_account_success as rl_record_account_success
|
||||
from backend.ratelimit import record_failure as rl_record_failure
|
||||
from backend.ratelimit import record_success as rl_record_success
|
||||
from backend.services.net import get_client_ip
|
||||
from backend.services.net import get_client_ip, is_trusted_proxy
|
||||
|
||||
from .jwt_handler import (
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
@@ -54,6 +57,34 @@ logger = logging.getLogger("obsigate.auth.router")
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def is_secure_cookies(request: Request | None = None) -> bool:
|
||||
"""True when auth cookies must carry the ``Secure`` flag (#87 T3/T8).
|
||||
|
||||
``OBSIGATE_SECURE_COOKIES=true|false|auto`` (défaut : ``auto``) :
|
||||
``true``/``false`` forcent le comportement ; ``auto`` met ``Secure``
|
||||
si la requête arrive en https (production derrière TLS) et l'omet
|
||||
sinon (dev local en http — les navigateurs jettent les cookies
|
||||
``Secure`` sur http, ce qui casserait silencieusement les logins
|
||||
localhost). Derrière un reverse proxy qui termine TLS, le schéma perçu
|
||||
est http : avec ``OBSIGATE_TRUST_PROXY=true``, ``X-Forwarded-Proto``
|
||||
est honoré (même garde que ``get_client_ip``, BUG-030).
|
||||
"""
|
||||
forced = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
|
||||
if forced in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if forced in ("0", "false", "no", "off"):
|
||||
return False
|
||||
if request is None:
|
||||
return False
|
||||
if request.url.scheme == "https":
|
||||
return True
|
||||
if is_trusted_proxy():
|
||||
proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
|
||||
if proto == "https":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Pydantic request models ──────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -109,6 +140,43 @@ class UpdateUserRequest(BaseModel):
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
# ── Profile avatar (#113) ───────────────────────────────────────────
|
||||
|
||||
#: Avatar data-URL pattern — PNG/JPEG/WebP only (no SVG: XSS surface).
|
||||
_AVATAR_DATA_URL_RE = re.compile(
|
||||
r"^data:image/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$"
|
||||
)
|
||||
#: ~300 KB of base64 payload (a 256px JPEG is ~15 KB; generous headroom).
|
||||
_AVATAR_MAX_CHARS = 400_000
|
||||
|
||||
|
||||
def _validate_avatar(data_url: str) -> str | None:
|
||||
"""Validate an avatar data-URL for storage on the user profile.
|
||||
|
||||
Returns the normalized data-URL, or ``None`` when clearing the avatar
|
||||
(empty string). Raises ``HTTPException(400)`` on anything else.
|
||||
"""
|
||||
if data_url == "":
|
||||
return None
|
||||
if len(data_url) > _AVATAR_MAX_CHARS:
|
||||
raise HTTPException(400, "Avatar image too large")
|
||||
if not _AVATAR_DATA_URL_RE.match(data_url):
|
||||
raise HTTPException(400, "Avatar must be a PNG, JPEG or WebP data URL")
|
||||
try:
|
||||
raw = base64.b64decode(data_url.split(",", 1)[1], validate=True)
|
||||
except (ValueError, binascii.Error) as exc: # pragma: no cover — regex guards
|
||||
raise HTTPException(400, "Avatar payload is not valid base64") from exc
|
||||
# Confirm the decoded bytes really are a supported image (magic numbers).
|
||||
is_png = raw.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
is_jpeg = raw.startswith(b"\xff\xd8\xff")
|
||||
is_webp = (
|
||||
len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP"
|
||||
)
|
||||
if not (is_png or is_jpeg or is_webp):
|
||||
raise HTTPException(400, "Avatar payload is not a PNG, JPEG or WebP image")
|
||||
return data_url
|
||||
|
||||
|
||||
# ── Public endpoints ──────────────────────────────────────────────────
|
||||
|
||||
@router.get("/status")
|
||||
@@ -179,10 +247,11 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
"remember_me": body.remember_me,
|
||||
}
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
return _issue_tokens(user, body.username, body.remember_me, response, request)
|
||||
|
||||
|
||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
|
||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response,
|
||||
request: Request | None = None) -> dict:
|
||||
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
|
||||
record_login_success(username)
|
||||
rl_record_account_success(username)
|
||||
@@ -190,9 +259,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
access_token = create_access_token(user)
|
||||
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
|
||||
|
||||
import os
|
||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
secure = is_secure_cookies(request)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
@@ -214,13 +282,15 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
"token_type": "bearer", # nosec B105
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
"user": {
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"role": user["role"],
|
||||
"vaults": user["vaults"],
|
||||
"avatar": user.get("avatar"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -259,9 +329,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
if stale:
|
||||
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
|
||||
|
||||
import os
|
||||
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
secure = is_secure_cookies(request)
|
||||
remember_me = bool(payload.get("remember", False))
|
||||
|
||||
# BUG-027: rotate the refresh token — the old one is now single-use.
|
||||
@@ -292,7 +360,8 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
|
||||
return {
|
||||
"access_token": new_access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
"token_type": "bearer", # nosec B105
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
}
|
||||
|
||||
@@ -343,6 +412,7 @@ async def get_me(current_user=Depends(require_auth)):
|
||||
"vaults": current_user["vaults"],
|
||||
"language": current_user.get("language", "fr"),
|
||||
"last_login": current_user.get("last_login"),
|
||||
"avatar": current_user.get("avatar"),
|
||||
}
|
||||
|
||||
|
||||
@@ -350,19 +420,23 @@ class UpdateMeRequest(BaseModel):
|
||||
"""Fields the user can update on their own profile."""
|
||||
display_name: str | None = None
|
||||
language: str | None = None
|
||||
#: Image data-URL (PNG/JPEG/WebP), or ``""`` to remove the avatar (#113).
|
||||
avatar: str | None = None
|
||||
|
||||
|
||||
@router.patch("/me")
|
||||
async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||
"""Update current user's profile fields (display_name, language)."""
|
||||
"""Update current user's profile fields (display_name, language, avatar)."""
|
||||
from .user_store import update_user
|
||||
updates = {}
|
||||
updates: dict[str, object] = {}
|
||||
if req.display_name is not None:
|
||||
updates["display_name"] = req.display_name
|
||||
if req.language is not None:
|
||||
if req.language not in ("fr", "en"):
|
||||
raise HTTPException(400, "language must be 'fr' or 'en'")
|
||||
updates["language"] = req.language
|
||||
if req.avatar is not None:
|
||||
updates["avatar"] = _validate_avatar(req.avatar)
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
updated = update_user(current_user["username"], updates)
|
||||
@@ -373,6 +447,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||
"vaults": updated["vaults"],
|
||||
"language": updated.get("language", "fr"),
|
||||
"last_login": updated.get("last_login"),
|
||||
"avatar": updated.get("avatar"),
|
||||
}
|
||||
|
||||
|
||||
@@ -380,6 +455,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||
async def change_password(
|
||||
req: ChangePasswordRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Change own password.
|
||||
@@ -395,7 +471,7 @@ async def change_password(
|
||||
updated = get_user(current_user["username"])
|
||||
result: dict = {"message": "Mot de passe mis à jour"}
|
||||
if updated is not None:
|
||||
result.update(_issue_tokens(updated, updated["username"], False, response))
|
||||
result.update(_issue_tokens(updated, updated["username"], False, response, request))
|
||||
return result
|
||||
|
||||
|
||||
@@ -758,7 +834,7 @@ async def mfa_webauthn_verify(
|
||||
|
||||
rl_record_success(client_ip)
|
||||
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
return _issue_tokens(user, body.username, body.remember_me, response, request)
|
||||
|
||||
|
||||
@router.get("/mfa/status")
|
||||
@@ -766,6 +842,16 @@ async def mfa_status(current_user=Depends(require_auth)):
|
||||
"""Return current user's MFA status."""
|
||||
from .user_store import get_user
|
||||
user = get_user(current_user["username"])
|
||||
if user is None:
|
||||
# BUG-081 : auth désactivée (OBSIGATE_AUTH_ENABLED=false) → le
|
||||
# pseudo-user "anonymous" n'a aucune entrée en store : pas de MFA,
|
||||
# et surtout pas de 500 (`AttributeError` sur `user.get`).
|
||||
return {
|
||||
"mfa_enabled": False,
|
||||
"mfa_method": None,
|
||||
"totp_enabled": False,
|
||||
"webauthn_credentials": 0,
|
||||
}
|
||||
return {
|
||||
"mfa_enabled": user.get("mfa_enabled", False),
|
||||
"mfa_method": user.get("mfa_method"),
|
||||
@@ -801,7 +887,7 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
|
||||
# Clear IP rate limit on success
|
||||
rl_record_success(client_ip)
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
return _issue_tokens(user, body.username, body.remember_me, response, request)
|
||||
|
||||
|
||||
@router.post("/mfa/recovery")
|
||||
@@ -839,7 +925,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
||||
rl_record_success(client_ip)
|
||||
|
||||
logger.info(f"User '{body.username}' logged in via recovery code")
|
||||
return _issue_tokens(user, body.username, False, response)
|
||||
return _issue_tokens(user, body.username, False, response, request)
|
||||
|
||||
|
||||
# ── Admin endpoints ───────────────────────────────────────────────────
|
||||
|
||||
@@ -96,6 +96,7 @@ def create_user(
|
||||
"vaults": vaults or [],
|
||||
"active": True,
|
||||
"language": "fr", # default UI language
|
||||
"avatar": None, # profile picture data-URL (#113)
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"password_changed_at": datetime.now(timezone.utc).timestamp(),
|
||||
"last_login": None,
|
||||
|
||||
@@ -15,6 +15,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.media_types import is_media
|
||||
from backend.secret_redactor import redact_file_content
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm")
|
||||
@@ -185,6 +186,10 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any
|
||||
def _file_entry(target: Path, rel_path: str, remaining: int) -> dict[str, Any] | None:
|
||||
"""Read, redact and truncate a single file into a context entry."""
|
||||
suffix = target.suffix.lower()
|
||||
# #109-D3 — audio/video (and images) carry no extractable text; never feed
|
||||
# raw bytes to the model. Images are handled separately via vision data URLs.
|
||||
if is_media(suffix):
|
||||
return None
|
||||
try:
|
||||
if suffix == ".pdf":
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
@@ -110,6 +110,12 @@ class BooksLMChatRequest(BaseModel):
|
||||
description="Conversation snapshot returned alongside a ``confirmation`` event, "
|
||||
"echoed back to resume the agent run.",
|
||||
)
|
||||
confirm_all: bool = Field(
|
||||
default=False,
|
||||
description="Global approval (BUG-075): apply every pending action of the batch "
|
||||
"and auto-approve the remaining mutating calls of the same run, "
|
||||
"so the run does not pause on each action.",
|
||||
)
|
||||
app_context: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Live client UI state for the General assistant: open_documents, "
|
||||
@@ -506,9 +512,11 @@ async def api_bookslm_agent(
|
||||
Same context as ``/chat`` but the model may call tools (read/search the
|
||||
vault) through the shared tool layer. Emits one ``tool`` event per executed
|
||||
tool call, then a final ``message`` event. Mutating tools pause the run with
|
||||
a ``confirmation`` event (two-step propose/apply) carrying the pending call
|
||||
and the conversation snapshot; the client resumes by echoing them back in
|
||||
``confirm`` / ``confirm_messages``.
|
||||
a ``confirmation`` event (two-step propose/apply) carrying the pending
|
||||
``actions`` (every mutating call of the turn) and the conversation snapshot;
|
||||
the client resumes by echoing them back in ``confirm`` / ``confirm_messages``,
|
||||
optionally with ``confirm_all`` to apply the whole batch and auto-approve the
|
||||
rest of the run (BUG-075).
|
||||
"""
|
||||
_validate_vision_support(req)
|
||||
system_prompt = _resolve_system_prompt(req, current_user, agent=True)
|
||||
@@ -523,6 +531,10 @@ async def api_bookslm_agent(
|
||||
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
|
||||
|
||||
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
|
||||
if req.confirm_all:
|
||||
# BUG-075: a single global approval authorizes the whole plan, so the
|
||||
# run no longer pauses on every subsequent mutating call.
|
||||
ctx.confirmed = True
|
||||
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Content-Security-Policy nonces (ROADMAP #87, tranche 5b).
|
||||
|
||||
Chaque réponse HTTP reçoit un nonce frais (``request.state.csp_nonce``)
|
||||
injecté dans ``script-src``. Les routes servant du HTML avec des scripts
|
||||
inline (index, popout, admin, editor-poc, excalidraw, page de partage)
|
||||
l'injectent dans le balisage via :func:`inject_csp_nonce` — mêmes
|
||||
emplacements, aucun script déplacé.
|
||||
|
||||
Tant que ``'unsafe-inline'`` reste dans la politique (retrait en T5c),
|
||||
l'injection est inerte : elle prépare la bascule sans changer le
|
||||
comportement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
|
||||
# Balises <script> exécutables sans `src` et sans nonce existant :
|
||||
# `<script>`, `<script type="module">`, `<script type="importmap">`.
|
||||
# Les blocs non-JS (ex. `type="text/plain"`) et les scripts externes
|
||||
# (`src=…`, couverts par 'self'/hôtes CDN) sont laissés intacts.
|
||||
_SCRIPT_TAG_RE = re.compile(
|
||||
r"<script(?=>|\s+type=\"(?:module|importmap)\"\s*>)",
|
||||
)
|
||||
|
||||
|
||||
def new_nonce() -> str:
|
||||
"""Generate a fresh per-response CSP nonce."""
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def inject_csp_nonce(html: str, nonce: str) -> str:
|
||||
"""Add ``nonce="…"`` to bare executable inline ``<script>`` tags."""
|
||||
return _SCRIPT_TAG_RE.sub(f'<script nonce="{nonce}"', html)
|
||||
@@ -23,6 +23,7 @@ import re
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
@@ -246,7 +247,9 @@ def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> st
|
||||
"""Render raw markdown to an HTML fragment (images inlined, wikilinks resolved)."""
|
||||
md = _inline_images(md, file_dir, vault_path)
|
||||
md = _convert_wikilinks(md, vault_path, current)
|
||||
return _markdown(md)
|
||||
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le renderer
|
||||
# HTML renvoie toujours `str` à l'exécution).
|
||||
return cast(str, _markdown(md))
|
||||
|
||||
|
||||
def _build_nav(vault_path: Path, current: Path) -> str:
|
||||
|
||||
@@ -35,7 +35,8 @@ def diagram_png_for(code: str) -> Path | None:
|
||||
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]
|
||||
# Identifiant de cache déterministe (pas un usage sécurité).
|
||||
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16] # nosec B324
|
||||
png = DIAGRAMS_DIR / (sha + ".png")
|
||||
return png if png.exists() else None
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from typing import Any
|
||||
|
||||
import frontmatter
|
||||
|
||||
from backend.media_types import AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, is_media
|
||||
|
||||
logger = logging.getLogger("obsigate.indexer")
|
||||
|
||||
# Global in-memory index
|
||||
@@ -63,13 +65,14 @@ SUPPORTED_EXTENSIONS = {
|
||||
".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1",
|
||||
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
|
||||
".cfg", ".ini", ".conf", ".env", ".pdf",
|
||||
".xlsx",
|
||||
".html", ".css", ".scss", ".less",
|
||||
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
|
||||
".php", ".sql", ".r", ".m", ".swift", ".kt",
|
||||
".dockerfile", ".makefile", ".cmake",
|
||||
".excalidraw",
|
||||
".excalidraw.md",
|
||||
}
|
||||
} | set(IMAGE_EXTENSIONS) | set(AUDIO_EXTENSIONS) | set(VIDEO_EXTENSIONS)
|
||||
|
||||
|
||||
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
|
||||
@@ -348,6 +351,23 @@ def _decompress_excalidraw(compressed: str) -> dict[str, Any] | None:
|
||||
return data
|
||||
|
||||
|
||||
def extract_xlsx_indexable(file_path: Path) -> str:
|
||||
"""Return searchable text for a workbook (#153 A5).
|
||||
|
||||
Lazy wrapper: ``openpyxl`` is only imported when a spreadsheet is actually
|
||||
indexed, so a vault without workbooks never pays the import. Errors are
|
||||
swallowed — a corrupt or encrypted file still gets indexed by name.
|
||||
"""
|
||||
try:
|
||||
from backend.xlsx_reader import extract_indexable_text
|
||||
except Exception: # pragma: no cover - openpyxl missing
|
||||
return ""
|
||||
try:
|
||||
return extract_indexable_text(file_path)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return ""
|
||||
|
||||
|
||||
def extract_excalidraw_indexable(raw: str) -> str:
|
||||
"""Return indexable text content for a raw .excalidraw / .excalidraw.md file.
|
||||
|
||||
@@ -550,6 +570,20 @@ def _scan_vault(
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
excalidraw_text_pending = True
|
||||
elif is_media(ext):
|
||||
# #108 — images (and future media, #109) are binary: index
|
||||
# name/size/mtime only and never read the bytes. ``content``
|
||||
# stays empty so the TF-IDF index remains clean.
|
||||
raw = ""
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
elif ext == ".xlsx":
|
||||
# #153 A5 — a workbook stays rendered by the viewer, but its
|
||||
# cell values are now indexed as text so a spreadsheet is
|
||||
# findable by its content (parity with _index_single_file_sync).
|
||||
raw = extract_xlsx_indexable(fpath)
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
@@ -791,6 +825,13 @@ async def reload_index() -> dict[str, Any]:
|
||||
await build_index()
|
||||
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
|
||||
await enrich_pdf_texts()
|
||||
# The inverted index is NOT updated by the hooks here: the rebuild above
|
||||
# replaces whole vault entries, so the incremental notifications are not
|
||||
# emitted for the files that only changed content. Without this, a manual
|
||||
# reindex left TF-IDF search serving a stale index (BUG-089).
|
||||
from backend.search import init_inverted_index
|
||||
|
||||
init_inverted_index()
|
||||
stats = {}
|
||||
for name, data in index.items():
|
||||
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
|
||||
@@ -866,6 +907,13 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
|
||||
await enrich_pdf_texts(vault_name)
|
||||
|
||||
# Same as reload_index: the vault entry was replaced wholesale, so rebuild
|
||||
# the inverted index or TF-IDF search keeps serving stale postings
|
||||
# (BUG-089).
|
||||
from backend.search import init_inverted_index
|
||||
|
||||
init_inverted_index()
|
||||
|
||||
stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])}
|
||||
logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags")
|
||||
return stats
|
||||
@@ -934,6 +982,14 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
raw = extract_excalidraw_indexable(raw)
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
elif is_media(ext):
|
||||
# #108 — binary media: metadata only, never read the bytes.
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
elif ext == ".xlsx":
|
||||
# #153 A5 — index sheet names + header rows as text (see _scan_vault).
|
||||
raw = extract_xlsx_indexable(fpath)
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
content_preview = raw[:200].strip()
|
||||
@@ -1202,6 +1258,12 @@ async def remove_vault_from_index(vault_name: str):
|
||||
if not _file_lookup[key]:
|
||||
_file_lookup.pop(key, None)
|
||||
|
||||
# Notify the inverted index, otherwise every document of the vault
|
||||
# stays in it as a ghost (postings, doc_info, doc_vault, vault_docs)
|
||||
# and keeps matching searches for a vault that no longer exists.
|
||||
if _on_index_change:
|
||||
_on_index_change('remove', vault_name, rel_path, f) # type: ignore[misc]
|
||||
|
||||
# Clean path_index
|
||||
path_index.pop(vault_name, None)
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Image thumbnail generation and disk cache (roadmap #108-C).
|
||||
|
||||
Thumbnails are generated on demand with Pillow and cached under
|
||||
``<OBSIGATE_DATA_DIR>/.obsigate-cache/thumbs/<sha1>.webp``. The cache key
|
||||
embeds the source path, mtime (ns) and size, so an edited image naturally
|
||||
invalidates its stale thumbnail without any explicit cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_THUMB_SIZE = 256
|
||||
|
||||
# Extensions Pillow cannot decode without extra native libraries: served as-is.
|
||||
_UNDECODABLE = {".svg"}
|
||||
|
||||
|
||||
def thumbs_cache_dir() -> Path:
|
||||
"""Return (and create) the thumbnail cache directory."""
|
||||
base = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / ".obsigate-cache" / "thumbs"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def thumb_cache_path(file_path: Path, size: int) -> Path:
|
||||
"""Compute the deterministic cache path for *file_path* at *size*."""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
stamp = f"{st.st_mtime_ns}:{st.st_size}"
|
||||
except OSError:
|
||||
stamp = "0:0"
|
||||
# Clé de cache miniature (pas un usage sécurité).
|
||||
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest() # nosec B324
|
||||
return thumbs_cache_dir() / f"{key}.webp"
|
||||
|
||||
|
||||
def is_decodable(file_path: Path) -> bool:
|
||||
"""True when Pillow can be expected to decode *file_path*."""
|
||||
return file_path.suffix.lower() not in _UNDECODABLE
|
||||
|
||||
|
||||
def generate_thumbnail(file_path: Path, size: int = DEFAULT_THUMB_SIZE) -> Path | None:
|
||||
"""Generate (or reuse) a WebP thumbnail and return its path.
|
||||
|
||||
Returns ``None`` when the file cannot be decoded (e.g. SVG) or Pillow is
|
||||
unavailable, so the caller can fall back to serving the original.
|
||||
"""
|
||||
cache_path = thumb_cache_path(file_path, size)
|
||||
if cache_path.exists():
|
||||
return cache_path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
except Exception: # pragma: no cover - Pillow is an optional runtime dep
|
||||
return None
|
||||
|
||||
try:
|
||||
with Image.open(file_path) as opened:
|
||||
# Animated formats: keep only the first frame.
|
||||
if getattr(opened, "is_animated", False):
|
||||
opened.seek(0)
|
||||
img = ImageOps.exif_transpose(opened) or opened
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGBA")
|
||||
img.thumbnail((size, size))
|
||||
|
||||
tmp = cache_path.with_suffix(".tmp")
|
||||
img.save(tmp, "WEBP", quality=80)
|
||||
os.replace(tmp, cache_path)
|
||||
return cache_path
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Shared media type constants and helpers.
|
||||
|
||||
Single source of truth for the file extensions and MIME types handled by the
|
||||
image support (roadmap #108) and reused by the audio/video players (#109).
|
||||
Keeping these sets here avoids the previous duplication (``indexer.py``,
|
||||
``attachment_indexer.py`` and ``main.py`` each carried their own copy).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
|
||||
# Image extensions viewable in the browser (HEIC/HEIF deliberately excluded —
|
||||
# no browser decodes them natively; see roadmap #108).
|
||||
IMAGE_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico",
|
||||
})
|
||||
|
||||
# Audio extensions (socle for #109, not wired into the index yet).
|
||||
AUDIO_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".mp3", ".m4a", ".aac", ".wav", ".ogg", ".oga", ".opus", ".flac",
|
||||
})
|
||||
|
||||
# Video extensions (socle for #109, not wired into the index yet).
|
||||
VIDEO_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".mp4", ".webm", ".mov", ".m4v",
|
||||
})
|
||||
|
||||
MEDIA_EXTENSIONS: frozenset[str] = IMAGE_EXTENSIONS | AUDIO_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
|
||||
# Explicit MIME types for extensions ``mimetypes`` gets wrong or does not know.
|
||||
_MIME_OVERRIDES: dict[str, str] = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".webp": "image/webp",
|
||||
".m4a": "audio/mp4",
|
||||
".oga": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/mp4",
|
||||
}
|
||||
|
||||
|
||||
def is_image(ext: str) -> bool:
|
||||
"""Return True when *ext* (with leading dot, any case) is an image."""
|
||||
return ext.lower() in IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def is_audio(ext: str) -> bool:
|
||||
"""Return True when *ext* is an audio extension."""
|
||||
return ext.lower() in AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
def is_video(ext: str) -> bool:
|
||||
"""Return True when *ext* is a video extension."""
|
||||
return ext.lower() in VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
def is_media(ext: str) -> bool:
|
||||
"""Return True when *ext* is any supported image/audio/video extension."""
|
||||
return ext.lower() in MEDIA_EXTENSIONS
|
||||
|
||||
|
||||
def media_mime_type(path: str) -> str:
|
||||
"""Return the best MIME type for *path* (extension based).
|
||||
|
||||
Falls back to ``application/octet-stream`` when the type is unknown.
|
||||
"""
|
||||
lower = path.lower()
|
||||
for ext, mime in _MIME_OVERRIDES.items():
|
||||
if lower.endswith(ext):
|
||||
return mime
|
||||
guessed, _ = mimetypes.guess_type(path)
|
||||
return guessed or "application/octet-stream"
|
||||
@@ -181,6 +181,41 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/save"): {
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/style"): {
|
||||
"request": {
|
||||
"ops": [
|
||||
{"op": "cell", "sheet": "Budget", "range": "A1:B1", "style": {"bold": True, "fill_color": "#ffe08a"}},
|
||||
{"op": "col_width", "sheet": "Budget", "col": "A", "width": 24},
|
||||
],
|
||||
"force": False,
|
||||
"if_match": "18f2c0ab-1f4",
|
||||
},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 2, "revision": "18f2c0ab-1f6"},
|
||||
},
|
||||
# GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI
|
||||
# invalide) — les paramètres sont documentés par leurs Query().
|
||||
("get", "/api/file/{vault_name}/xlsx/sheet"): {
|
||||
"response": {
|
||||
"vault": "TestVault",
|
||||
"path": "data/budget.xlsx",
|
||||
"sheet": "Budget",
|
||||
"offset": 0,
|
||||
"limit": 200,
|
||||
"rows": 2,
|
||||
"cols": 2,
|
||||
"total_rows": 640,
|
||||
"total_cols": 12,
|
||||
"max_rows": 500,
|
||||
"max_cols": 40,
|
||||
"truncated": True,
|
||||
"has_more": True,
|
||||
"html": "<table>…</table>",
|
||||
},
|
||||
},
|
||||
("post", "/api/search/replace"): {
|
||||
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
|
||||
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
|
||||
|
||||
@@ -12,14 +12,24 @@ the per-account lockout in ``user_store.py``.
|
||||
deployment, front this service with a shared store (Redis) or a single
|
||||
worker. This limitation is intentional and documented (BUG-031).
|
||||
|
||||
Opt-in persistence (ROADMAP #85 T10b) : if ``OBSIGATE_RATELIMIT_DB`` points
|
||||
to a SQLite file, counters are stored there instead (WAL mode, one short
|
||||
connection per call — safe across threads, processes and restarts sharing
|
||||
the same file). Semantics (windows, budgets, success reset) are identical
|
||||
to the in-memory store, which remains the default when the variable is
|
||||
unset.
|
||||
|
||||
Configuration via environment variables:
|
||||
OBSIGATE_LOGIN_MAX_ATTEMPTS Max failures per IP (default: 10)
|
||||
OBSIGATE_ACCOUNT_MAX_ATTEMPTS Max failures per account (default: 10)
|
||||
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900)
|
||||
OBSIGATE_RATELIMIT_DB SQLite file for shared/persistent counters (default: unset = memory)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -37,6 +47,127 @@ _last_cleanup = time.time()
|
||||
CLEANUP_INTERVAL = 60 # seconds
|
||||
|
||||
|
||||
def _db_path() -> str | None:
|
||||
"""SQLite file for shared counters, or ``None`` for the in-memory store."""
|
||||
path = os.environ.get("OBSIGATE_RATELIMIT_DB", "").strip()
|
||||
return path or None
|
||||
|
||||
|
||||
def _db_connect(path: str) -> sqlite3.Connection:
|
||||
"""Open a short-lived connection (WAL + busy timeout for concurrent workers)."""
|
||||
_db_ensure_schema(path)
|
||||
conn = sqlite3.connect(path, timeout=10.0)
|
||||
conn.execute("PRAGMA busy_timeout=10000")
|
||||
return conn
|
||||
|
||||
|
||||
_schema_ready: set[str] = set()
|
||||
_schema_lock = threading.Lock()
|
||||
|
||||
|
||||
def _db_ensure_schema(path: str) -> None:
|
||||
"""Create the store schema once per file (DDL under a process-wide lock)."""
|
||||
with _schema_lock:
|
||||
if path in _schema_ready:
|
||||
return
|
||||
conn = sqlite3.connect(path, timeout=10.0)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS attempts"
|
||||
" (kind TEXT NOT NULL, key TEXT NOT NULL, ts REAL NOT NULL, success INTEGER NOT NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_attempts_kind_key_ts"
|
||||
" ON attempts (kind, key, ts)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_schema_ready.add(path)
|
||||
|
||||
|
||||
def _db_write(fn, *args):
|
||||
"""Run a write op, retrying once on lock contention (concurrent workers)."""
|
||||
try:
|
||||
return fn(*args)
|
||||
except sqlite3.OperationalError as e:
|
||||
if "locked" not in str(e).lower():
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
return fn(*args)
|
||||
|
||||
|
||||
def _db_prune(conn: sqlite3.Connection, cutoff: float) -> None:
|
||||
"""Drop expired entries (best-effort cap on disk growth)."""
|
||||
conn.execute("DELETE FROM attempts WHERE ts <= ?", (cutoff,))
|
||||
|
||||
|
||||
def _db_record(kind: str, key: str, success: bool) -> int:
|
||||
"""Record one attempt in SQLite; return the live failure count."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
now = time.time()
|
||||
cutoff = now - WINDOW_SECONDS
|
||||
|
||||
def _write() -> int:
|
||||
with _db_connect(path) as conn:
|
||||
_db_prune(conn, cutoff)
|
||||
if success:
|
||||
# Mirror the in-memory reset: replace history with one success.
|
||||
conn.execute("DELETE FROM attempts WHERE kind = ? AND key = ?", (kind, key))
|
||||
conn.execute(
|
||||
"INSERT INTO attempts (kind, key, ts, success) VALUES (?, ?, ?, ?)",
|
||||
(kind, key, now, int(success)),
|
||||
)
|
||||
conn.commit()
|
||||
(failures,) = conn.execute(
|
||||
"SELECT COUNT(*) FROM attempts WHERE kind = ? AND key = ? AND ts > ? AND success = 0",
|
||||
(kind, key, cutoff),
|
||||
).fetchone()
|
||||
return failures
|
||||
|
||||
return _db_write(_write)
|
||||
|
||||
|
||||
def _db_failures(kind: str, key: str) -> int:
|
||||
"""Live failure count in SQLite (expired entries never count)."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
cutoff = time.time() - WINDOW_SECONDS
|
||||
with _db_connect(path) as conn:
|
||||
(failures,) = conn.execute(
|
||||
"SELECT COUNT(*) FROM attempts WHERE kind = ? AND key = ? AND ts > ? AND success = 0",
|
||||
(kind, key, cutoff),
|
||||
).fetchone()
|
||||
return failures
|
||||
|
||||
|
||||
def _db_tracked(kind: str) -> int:
|
||||
"""Number of distinct keys ever seen for one budget (SQLite)."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
with _db_connect(path) as conn:
|
||||
(n,) = conn.execute(
|
||||
"SELECT COUNT(DISTINCT key) FROM attempts WHERE kind = ?", (kind,)
|
||||
).fetchone()
|
||||
return n
|
||||
|
||||
|
||||
def _db_limited_count(kind: str, max_attempts: int) -> int:
|
||||
"""Number of keys currently over budget (SQLite)."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
cutoff = time.time() - WINDOW_SECONDS
|
||||
with _db_connect(path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key, COUNT(*) FROM attempts"
|
||||
" WHERE kind = ? AND ts > ? AND success = 0 GROUP BY key",
|
||||
(kind, cutoff),
|
||||
).fetchall()
|
||||
return sum(1 for _, n in rows if n >= max_attempts)
|
||||
|
||||
|
||||
def _prune(store: dict[str, list], cutoff: float) -> None:
|
||||
"""Drop expired entries from one store in place."""
|
||||
expired = []
|
||||
@@ -66,6 +197,12 @@ def record_failure(ip: str) -> tuple[int, int]:
|
||||
Returns:
|
||||
(current_failure_count, remaining_attempts)
|
||||
"""
|
||||
if _db_path() is not None:
|
||||
failures = _db_record("ip", ip, False)
|
||||
remaining = max(0, MAX_ATTEMPTS - failures)
|
||||
if failures >= MAX_ATTEMPTS:
|
||||
logger.warning(f"IP {ip} rate-limited after {failures} failed logins")
|
||||
return failures, remaining
|
||||
_cleanup_expired()
|
||||
_ip_attempts[ip].append((time.time(), False))
|
||||
failures = sum(1 for _, success in _ip_attempts[ip] if not success)
|
||||
@@ -77,12 +214,17 @@ def record_failure(ip: str) -> tuple[int, int]:
|
||||
|
||||
def record_success(ip: str):
|
||||
"""Clear rate limit state for an IP after successful login."""
|
||||
if _db_path() is not None:
|
||||
_db_record("ip", ip, True)
|
||||
return
|
||||
_cleanup_expired()
|
||||
_ip_attempts[ip] = [(time.time(), True)]
|
||||
|
||||
|
||||
def is_rate_limited(ip: str) -> bool:
|
||||
"""Check if an IP has exceeded the rate limit."""
|
||||
if _db_path() is not None:
|
||||
return _db_failures("ip", ip) >= MAX_ATTEMPTS
|
||||
_cleanup_expired()
|
||||
failures = sum(1 for _, success in _ip_attempts.get(ip, []) if not success)
|
||||
return failures >= MAX_ATTEMPTS
|
||||
@@ -94,8 +236,14 @@ def record_account_failure(account: str) -> tuple[int, int]:
|
||||
Returns:
|
||||
(current_failure_count, remaining_attempts)
|
||||
"""
|
||||
_cleanup_expired()
|
||||
key = account.lower()
|
||||
if _db_path() is not None:
|
||||
failures = _db_record("account", key, False)
|
||||
remaining = max(0, ACCOUNT_MAX_ATTEMPTS - failures)
|
||||
if failures >= ACCOUNT_MAX_ATTEMPTS:
|
||||
logger.warning(f"Account {account} rate-limited after {failures} failed attempts")
|
||||
return failures, remaining
|
||||
_cleanup_expired()
|
||||
_account_attempts[key].append((time.time(), False))
|
||||
failures = sum(1 for _, success in _account_attempts[key] if not success)
|
||||
remaining = max(0, ACCOUNT_MAX_ATTEMPTS - failures)
|
||||
@@ -106,12 +254,17 @@ def record_account_failure(account: str) -> tuple[int, int]:
|
||||
|
||||
def record_account_success(account: str):
|
||||
"""Clear the per-account rate limit state after a successful login."""
|
||||
if _db_path() is not None:
|
||||
_db_record("account", account.lower(), True)
|
||||
return
|
||||
_cleanup_expired()
|
||||
_account_attempts[account.lower()] = [(time.time(), True)]
|
||||
|
||||
|
||||
def is_account_rate_limited(account: str) -> bool:
|
||||
"""Check if an account has exceeded the per-account rate limit."""
|
||||
if _db_path() is not None:
|
||||
return _db_failures("account", account.lower()) >= ACCOUNT_MAX_ATTEMPTS
|
||||
_cleanup_expired()
|
||||
failures = sum(
|
||||
1 for _, success in _account_attempts.get(account.lower(), []) if not success
|
||||
@@ -121,6 +274,24 @@ def is_account_rate_limited(account: str) -> bool:
|
||||
|
||||
def get_status(ip: str | None = None) -> dict:
|
||||
"""Get rate limit status for an IP (for diagnostics)."""
|
||||
if _db_path() is not None:
|
||||
if ip:
|
||||
failures = _db_failures("ip", ip)
|
||||
return {
|
||||
"ip": ip,
|
||||
"failures": failures,
|
||||
"max": MAX_ATTEMPTS,
|
||||
"limited": failures >= MAX_ATTEMPTS,
|
||||
"window_seconds": WINDOW_SECONDS,
|
||||
}
|
||||
return {
|
||||
"tracked_ips": _db_tracked("ip"),
|
||||
"tracked_accounts": _db_tracked("account"),
|
||||
"max_attempts": MAX_ATTEMPTS,
|
||||
"account_max_attempts": ACCOUNT_MAX_ATTEMPTS,
|
||||
"window_seconds": WINDOW_SECONDS,
|
||||
"limited_ips": _db_limited_count("ip", MAX_ATTEMPTS),
|
||||
}
|
||||
_cleanup_expired()
|
||||
if ip:
|
||||
attempts = _ip_attempts.get(ip, [])
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Markdown rendering pipeline (ROADMAP #85, tranche 9).
|
||||
|
||||
Helpers extraits de :mod:`backend.main` sans changement de comportement :
|
||||
slugification des headings, IDs d'ancrage, rendu mistune singleton,
|
||||
wikilinks, normalisation des sauts de ligne et pipeline complet
|
||||
:func:`_render_markdown` (rendu + sanitizer XSS BUG-021).
|
||||
|
||||
Les noms gardent leur préfixe ``_`` d'origine pour un déplacement
|
||||
strictement verbatim (tests et routers pointent ici désormais).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_mod
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import mistune
|
||||
|
||||
from backend.image_processor import preprocess_images
|
||||
from backend.indexer import find_file_in_index, get_vault_data
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.services.sanitizer import sanitize_html
|
||||
|
||||
|
||||
def _heading_slugify(text: str) -> str:
|
||||
"""Generate a URL-safe slug from heading text.
|
||||
|
||||
Matches the JavaScript slugify algorithm exactly using
|
||||
Unicode-aware character classification:
|
||||
1. Strip HTML tags (e.g. wikilink spans rendered inside headings)
|
||||
2. Decode HTML entities (e.g. ``&`` → ``&``)
|
||||
3. Lowercase
|
||||
4. NFD normalize + strip combining marks
|
||||
5. Keep only Unicode letters, numbers, spaces, hyphens
|
||||
6. Replace spaces with hyphens, collapse multiple hyphens
|
||||
|
||||
Args:
|
||||
text: The heading text content (may contain inline HTML).
|
||||
|
||||
Returns:
|
||||
A URL-safe slug string.
|
||||
"""
|
||||
# Strip any inline HTML so it does not pollute the slug
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
# Decode HTML entities so & becomes & before slugification
|
||||
text = html_mod.unescape(text)
|
||||
text = text.lower()
|
||||
text = unicodedata.normalize("NFD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
# Unicode-aware: keep letters (L*), numbers (N*), spaces, and hyphens
|
||||
cleaned = []
|
||||
for ch in text:
|
||||
cat = unicodedata.category(ch)
|
||||
if cat.startswith('L') or cat.startswith('N') or ch in (' ', '-'):
|
||||
cleaned.append(ch)
|
||||
text = "".join(cleaned)
|
||||
text = re.sub(r"\s+", "-", text)
|
||||
text = re.sub(r"-+", "-", text)
|
||||
result = text.strip("-")
|
||||
return result if result else "heading"
|
||||
|
||||
|
||||
def _add_heading_ids(html: str) -> str:
|
||||
"""Post-process rendered HTML to add IDs to heading tags.
|
||||
|
||||
Adds an ``id`` attribute to every ``<h1>`` through ``<h6>`` tag
|
||||
using a slug generated from the heading's text content.
|
||||
Duplicate slugs get a ``-2``, ``-3``, etc. suffix.
|
||||
|
||||
Args:
|
||||
html: Rendered HTML string.
|
||||
|
||||
Returns:
|
||||
HTML with heading IDs injected.
|
||||
"""
|
||||
used_ids: dict[str, int] = {}
|
||||
|
||||
def _replace_heading(match):
|
||||
tag = match.group(1)
|
||||
content = match.group(2)
|
||||
slug = _heading_slugify(content)
|
||||
count = used_ids.get(slug, 0)
|
||||
used_ids[slug] = count + 1
|
||||
if count > 0:
|
||||
slug = f"{slug}-{count + 1}"
|
||||
return f'<{tag} id="{slug}">{content}</{tag}>'
|
||||
|
||||
# Match h1-h6 tags with text content (no existing id attribute)
|
||||
return re.sub(
|
||||
r'<(h[1-6])>([^<]*(?:<(?!/?h[1-6])[^<]*)*)</h[1-6]>',
|
||||
_replace_heading,
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
# Cached mistune renderer — avoids re-creating on every request
|
||||
_markdown_renderer = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
|
||||
|
||||
def _convert_wikilinks(content: str, current_vault: str) -> str:
|
||||
"""Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML.
|
||||
|
||||
Supports:
|
||||
- Internal file links: ``[[My Note]]`` / ``[[My Note|display]]``
|
||||
- Same-document anchors: ``[[#Heading]]`` / ``[[#Heading|display]]``
|
||||
|
||||
Resolved file links get a ``data-vault`` / ``data-path`` attribute pair.
|
||||
Anchor links target the slugified heading ID in the current document.
|
||||
Unresolved links are rendered as ``<span class="wikilink-missing">``.
|
||||
|
||||
Args:
|
||||
content: Markdown string potentially containing wikilinks.
|
||||
current_vault: Active vault name for resolution priority.
|
||||
|
||||
Returns:
|
||||
Markdown string with wikilinks replaced by HTML anchors.
|
||||
"""
|
||||
def _replace(match):
|
||||
target = match.group(1).strip()
|
||||
display = match.group(2).strip() if match.group(2) else target
|
||||
|
||||
# Same-document anchor link: [[#Heading|display]]
|
||||
if target.startswith("#"):
|
||||
anchor_text = target[1:].strip()
|
||||
anchor_slug = _heading_slugify(anchor_text)
|
||||
link_display = display if display != target else anchor_text
|
||||
return f'<a class="wikilink-anchor" href="#{anchor_slug}">{link_display}</a>'
|
||||
|
||||
found = find_file_in_index(target, current_vault)
|
||||
if found:
|
||||
return (
|
||||
f'<a class="wikilink" href="#" '
|
||||
f'data-vault="{found["vault"]}" '
|
||||
f'data-path="{found["path"]}">{display}</a>'
|
||||
)
|
||||
return f'<span class="wikilink-missing">{display}</span>'
|
||||
|
||||
pattern = r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
|
||||
return re.sub(pattern, _replace, content)
|
||||
|
||||
|
||||
def _normalize_line_breaks(text: str) -> str:
|
||||
"""Convert single newlines to hard breaks (matching Obsidian default behavior).
|
||||
|
||||
In standard Markdown, a single ``\\n`` is a "soft break" — it renders as a space,
|
||||
not a visible line break. Obsidian defaults to treating single newlines as hard
|
||||
breaks (equivalent to ``<br>``). This function pre-processes the Markdown source
|
||||
so that mistune renders standalone lines on separate rows, while still honouring
|
||||
blank lines as paragraph separators.
|
||||
|
||||
Fenced code blocks (`` ``` ``) are left untouched so their internal newlines are
|
||||
preserved verbatim.
|
||||
"""
|
||||
parts = re.split(r"(```[\s\S]*?```)", text)
|
||||
for i, part in enumerate(parts):
|
||||
if part.startswith("```"):
|
||||
continue # Protect fenced code blocks
|
||||
# Single \n (not preceded or followed by another \n) → two spaces + \n
|
||||
parts[i] = re.sub(r"(?<!\n)\n(?!\n)", " \n", part)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
|
||||
"""Render a markdown string to HTML with wikilink and image support.
|
||||
|
||||
Uses the cached singleton mistune renderer for performance.
|
||||
|
||||
Args:
|
||||
raw_md: Raw markdown text (frontmatter already stripped).
|
||||
vault_name: Current vault for wikilink resolution context.
|
||||
current_file_path: Absolute path to the current markdown file.
|
||||
|
||||
Returns:
|
||||
HTML string.
|
||||
"""
|
||||
# Get vault data for image resolution
|
||||
vault_data = get_vault_data(vault_name)
|
||||
vault_root = Path(vault_data["path"]) if vault_data else None
|
||||
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
||||
|
||||
# Redact secrets before rendering (P0 security)
|
||||
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
|
||||
|
||||
# Preprocess images first
|
||||
if vault_root:
|
||||
raw_md = preprocess_images(raw_md, vault_name, vault_root, current_file_path, attachments_path)
|
||||
|
||||
# Convert wikilinks
|
||||
converted = _convert_wikilinks(raw_md, vault_name)
|
||||
|
||||
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
|
||||
converted = _normalize_line_breaks(converted)
|
||||
|
||||
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (les
|
||||
# renderers HTML renvoient toujours `str` à l'exécution).
|
||||
rendered = cast(str, _markdown_renderer(converted))
|
||||
|
||||
# Add heading IDs for TOC navigation
|
||||
rendered = _add_heading_ids(rendered)
|
||||
|
||||
# Sanitize: raw HTML in vault content must never reach the DOM (BUG-021).
|
||||
rendered = sanitize_html(rendered)
|
||||
|
||||
return rendered
|
||||
@@ -1,9 +1,9 @@
|
||||
fastapi==0.110.3
|
||||
uvicorn==0.30.0
|
||||
fastapi==0.141.1
|
||||
uvicorn==0.54.0
|
||||
websockets>=12.0
|
||||
python-frontmatter==1.1.0
|
||||
mistune==3.0.2
|
||||
python-multipart==0.0.9
|
||||
mistune==3.3.3
|
||||
python-multipart==0.0.31
|
||||
aiofiles==23.2.1
|
||||
aiohttp>=3.9.0
|
||||
watchdog>=4.0.0
|
||||
@@ -11,16 +11,31 @@ argon2-cffi>=23.1.0
|
||||
python-jose>=3.3.0
|
||||
sortedcontainers>=2.4.0
|
||||
snowballstemmer>=2.2.0
|
||||
weasyprint>=60.0
|
||||
weasyprint>=70.0
|
||||
httpx>=0.27.0
|
||||
pypdf>=4.0
|
||||
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
|
||||
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
|
||||
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
|
||||
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
|
||||
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
|
||||
# et n'est jamais mis à niveau.
|
||||
pypdf>=6.16.1
|
||||
pyotp>=2.10.0
|
||||
segno>=1.5.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
mcp==1.9.4
|
||||
mcp==1.28.1
|
||||
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
|
||||
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
|
||||
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
|
||||
pyjwt[crypto]>=2.14.0
|
||||
sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
xlrd==2.0.2
|
||||
odfpy==1.4.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||
urllib3>=2.8.0
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""ObsiGate — routers FastAPI par domaine (ROADMAP #85).
|
||||
|
||||
Découpage progressif du monolithe ``backend/main.py`` : chaque module de ce
|
||||
paquet expose un ``APIRouter`` monté par ``main.py``. Les handlers sont
|
||||
déplacés sans changement de comportement (mêmes chemins, mêmes modèles de
|
||||
réponse, mêmes dépendances d'authentification).
|
||||
"""
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Backup endpoints (ROADMAP #85, tranche 4).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/file/{vault}/backups|diff|restore``,
|
||||
``/api/backups*``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification. La logique métier vit déjà dans
|
||||
:mod:`backend.services.backups`.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` / ``_list_backup_files`` de
|
||||
``main`` n'étaient que des wrappers directs : appelés ici via
|
||||
:mod:`backend.services.paths` et :mod:`backend.services.backups`.
|
||||
- ``RestoreRequest`` / ``RestoreResponse`` / ``DiffResponse`` ont déménagé
|
||||
dans :mod:`backend.schemas`.
|
||||
- Le singleton SSE vit désormais dans :mod:`backend.sse` (partagé avec
|
||||
``main`` : les clients ``/api/events`` reçoivent les mêmes broadcasts).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.indexer import get_vault_data, index, update_single_file
|
||||
from backend.schemas import (
|
||||
BackupContentResponse,
|
||||
BackupsAutoResponse,
|
||||
BackupsCompressResponse,
|
||||
BackupsDeletedResponse,
|
||||
BackupsListResponse,
|
||||
BackupsResponse,
|
||||
DiffResponse,
|
||||
RestoreRequest,
|
||||
RestoreResponse,
|
||||
)
|
||||
from backend.services.backups import (
|
||||
create_backup,
|
||||
)
|
||||
from backend.services.backups import (
|
||||
diff_backup as service_diff_backup,
|
||||
)
|
||||
from backend.services.backups import (
|
||||
list_backup_files as service_list_backup_files,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
restore_backup as service_restore_backup,
|
||||
)
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
from backend.webhooks import dispatch_webhooks
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["backups"])
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/backups", response_model=BackupsResponse)
|
||||
async def api_file_backups(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List all available backups for a file.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
|
||||
Returns:
|
||||
BackupListResponse with backups sorted newest first.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
try:
|
||||
backups = service_list_backup_files(vault_name, path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {e!s}")
|
||||
|
||||
return {"vault": vault_name, "path": path, "backups": backups}
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
|
||||
async def api_file_diff(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
version: int = Query(..., description="Timestamp of the backup version (left/old side)"),
|
||||
compare_with: int | None = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Generate a unified diff between a backup version and another version or the current file.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
version: Timestamp of the backup to use as the old/left side.
|
||||
compare_with: Optional timestamp of another backup as the new/right side.
|
||||
If omitted, the current file on disk is used.
|
||||
|
||||
Returns:
|
||||
DiffResponse containing the unified diff string.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
return service_diff_backup(vault_name, path, version, compare_with)
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
|
||||
async def api_file_restore(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
body: RestoreRequest = ..., # type: ignore
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Restore a file from a backup version.
|
||||
|
||||
The current file is backed up before being overwritten (so the operation is reversible).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
body: RestoreRequest with the backup version timestamp.
|
||||
|
||||
Returns:
|
||||
RestoreResponse confirming the restore.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_restore_backup(vault_name, path, body.version)
|
||||
current_backed_up = result["current_backed_up"]
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, path)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_restored", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"restored_from": body.version,
|
||||
"current_backed_up": current_backed_up,
|
||||
})
|
||||
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"restored_from": body.version,
|
||||
"current_backed_up": current_backed_up,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/backups", response_model=BackupsListResponse)
|
||||
async def api_backups_list(
|
||||
vault: str | None = Query(None, description="Filter by vault name"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List all backups across vaults, grouped by file."""
|
||||
result: list[dict[str, Any]] = []
|
||||
try:
|
||||
for vault_name in index:
|
||||
if vault and vault_name != vault:
|
||||
continue
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
continue
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
vault_backup_dir = backup_root / vault_name
|
||||
if not vault_backup_dir.exists():
|
||||
continue
|
||||
for fpath in vault_backup_dir.rglob("*.bak"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
st = fpath.stat()
|
||||
fsize = st.st_size
|
||||
ts_part = fpath.name.rsplit(".", 2)
|
||||
if len(ts_part) < 3 or not ts_part[-2].isdigit():
|
||||
continue
|
||||
ts = int(ts_part[-2])
|
||||
rel_dir = str(fpath.parent.relative_to(vault_backup_dir)).replace("\\", "/")
|
||||
rel_file = rel_dir + "/" + ts_part[0] if rel_dir != "." else ts_part[0]
|
||||
result.append({
|
||||
"vault": vault_name,
|
||||
"file": rel_file,
|
||||
"backup_file": fpath.name,
|
||||
"timestamp": ts,
|
||||
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
|
||||
"size": fsize,
|
||||
"full_path": str(fpath),
|
||||
})
|
||||
|
||||
result.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
total_size = sum(r["size"] for r in result)
|
||||
return {"backups": result, "total": len(result), "total_size_bytes": total_size}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}")
|
||||
|
||||
|
||||
@router.post("/api/backups/delete", response_model=BackupsDeletedResponse)
|
||||
async def api_backups_delete(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Delete one or more backup files."""
|
||||
paths = body.get("paths", [])
|
||||
if not paths:
|
||||
raise HTTPException(status_code=400, detail="No backup paths provided")
|
||||
|
||||
deleted = 0
|
||||
for p in paths:
|
||||
try:
|
||||
fpath = Path(p)
|
||||
# Security: ensure path is within a backup directory
|
||||
if ".obsigate-backup" not in str(fpath):
|
||||
continue
|
||||
if fpath.exists() and fpath.is_file():
|
||||
fpath.unlink()
|
||||
deleted += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete backup {p}: {e}")
|
||||
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.post("/api/backups/purge", response_model=BackupsDeletedResponse)
|
||||
async def api_backups_purge(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Purge all backups for a specific file or entire vault."""
|
||||
vault_name = body.get("vault")
|
||||
file_path = body.get("file") # optional
|
||||
|
||||
if not vault_name:
|
||||
raise HTTPException(status_code=400, detail="Vault name required")
|
||||
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
raise HTTPException(status_code=404, detail="Vault not found")
|
||||
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
|
||||
if file_path:
|
||||
# Delete backups for specific file
|
||||
backup_dir = backup_root / vault_name / Path(file_path).parent
|
||||
if backup_dir.exists():
|
||||
fname = Path(file_path).name
|
||||
deleted = 0
|
||||
for f in backup_dir.iterdir():
|
||||
if f.is_file() and f.name.startswith(fname + ".") and f.name.endswith(".bak"):
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
return {"deleted": deleted}
|
||||
return {"deleted": 0}
|
||||
else:
|
||||
# Delete all backups for vault
|
||||
vault_backup_dir = backup_root / vault_name
|
||||
if vault_backup_dir.exists():
|
||||
deleted = 0
|
||||
for f in vault_backup_dir.rglob("*.bak"):
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
return {"deleted": deleted}
|
||||
return {"deleted": 0}
|
||||
|
||||
|
||||
|
||||
@router.get("/api/backups/content", response_model=BackupContentResponse)
|
||||
async def api_backups_content(
|
||||
path: str = Query(..., description="Full path to backup file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return the content of a specific backup file."""
|
||||
try:
|
||||
fpath = Path(path)
|
||||
if ".obsigate-backup" not in str(fpath):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if not fpath.exists() or not fpath.is_file():
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
content = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
# Truncate large files to 100KB
|
||||
if len(content) > 102400:
|
||||
content = content[:102400] + "\n\n... (tronque a 100 Ko)"
|
||||
return {"content": content, "name": fpath.name, "size": len(content)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/backups/compress", response_model=BackupsCompressResponse)
|
||||
async def api_backups_compress(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Compress backups older than N days. Body: {older_than_days: 30, dry_run: false}"""
|
||||
import gzip as gz_mod
|
||||
older_than = body.get("older_than_days", 30)
|
||||
dry_run = body.get("dry_run", False)
|
||||
cutoff = time.time() - (older_than * 86400)
|
||||
compressed = 0
|
||||
saved_bytes = 0
|
||||
|
||||
for vault_name in index:
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
continue
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
vault_dir = backup_root / vault_name
|
||||
if not vault_dir.exists():
|
||||
continue
|
||||
for fpath in vault_dir.rglob("*.bak"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
if fpath.name.endswith(".bak.gz"):
|
||||
continue
|
||||
mtime = fpath.stat().st_mtime
|
||||
if mtime > cutoff:
|
||||
continue
|
||||
if not dry_run:
|
||||
try:
|
||||
gz_path = fpath.with_suffix(fpath.suffix + ".gz")
|
||||
data = fpath.read_bytes()
|
||||
with gz_mod.open(str(gz_path), "wb", compresslevel=6) as gzf:
|
||||
gzf.write(data)
|
||||
orig_size = len(data)
|
||||
gz_size = gz_path.stat().st_size
|
||||
if gz_size < orig_size:
|
||||
fpath.unlink()
|
||||
saved_bytes += (orig_size - gz_size)
|
||||
else:
|
||||
gz_path.unlink() # compression didn't help
|
||||
compressed += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compress {fpath}: {e}")
|
||||
else:
|
||||
compressed += 1
|
||||
|
||||
return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run}
|
||||
|
||||
|
||||
@router.post("/api/backups/auto", response_model=BackupsAutoResponse)
|
||||
async def api_backups_auto(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create backups for files modified since a given time. Body: {since_hours: 24}"""
|
||||
since_hours = body.get("since_hours", 24)
|
||||
cutoff = time.time() - (since_hours * 3600)
|
||||
backed_up = 0
|
||||
|
||||
for vault_name in index:
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
continue
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
for fpath in vault_root.rglob("*"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
if fpath.name.startswith('.'):
|
||||
continue
|
||||
if any(p.startswith('.') or p in {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} for p in fpath.relative_to(vault_root).parts):
|
||||
continue
|
||||
mtime = fpath.stat().st_mtime
|
||||
if mtime < cutoff:
|
||||
continue
|
||||
try:
|
||||
rel = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
||||
create_backup(fpath, vault_name, rel)
|
||||
backed_up += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto-backup failed for {rel}: {e}")
|
||||
|
||||
return {"backed_up": backed_up, "since_hours": since_hours}
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Configuration, AI keys, diagnostics & dashboard endpoints (ROADMAP #85, tranche 7).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/config*``, ``/api/diagnostics``,
|
||||
``/api/dashboard``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_load_config`` / ``_save_config`` / ``_DEFAULT_CONFIG`` /
|
||||
``_CONFIG_PATH`` / ``_BASE_DIR`` ont déménagé ici : ``main`` les
|
||||
réimporte pour son lifespan (pas de cycle : ce module ne dépend pas de
|
||||
``main``).
|
||||
- ``AI_KEYS_FILE`` / ``_write_ai_keys`` / ``_FALLBACK_MODELS`` ont déménagé
|
||||
ici (``AI_KEYS_FILE`` garde son chemin relatif ``data/api_keys.json``,
|
||||
résolu depuis le même CWD au runtime).
|
||||
"""
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.indexer import index
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
from backend.schemas import (
|
||||
AIKeyDeleteResponse,
|
||||
AIKeysResponse,
|
||||
AIModelsResponse,
|
||||
AITestResponse,
|
||||
AppConfigResponse,
|
||||
DashboardResponse,
|
||||
DiagnosticsResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
from backend.search_executor import get_search_executor
|
||||
from backend.tools.secrets import (
|
||||
TOOL_KEY_NAMES as _TOOL_KEY_NAMES,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
delete_tool_key as _delete_tool_key,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
get_tool_key as _get_tool_key,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
mask_value as _mask_tool_value,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
set_tool_key as _set_tool_key,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["System"])
|
||||
|
||||
_BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
_CONFIG_PATH = _BASE_DIR / "data" / "config.json"
|
||||
|
||||
_DEFAULT_CONFIG = {
|
||||
"search_workers": 2,
|
||||
"debounce_ms": 300,
|
||||
"results_per_page": 50,
|
||||
"min_query_length": 2,
|
||||
"search_timeout_ms": 30000,
|
||||
"max_content_size": 100000,
|
||||
"snippet_context_chars": 120,
|
||||
"max_snippet_highlights": 5,
|
||||
"title_boost": 3.0,
|
||||
"path_boost": 1.5,
|
||||
"watcher_enabled": True,
|
||||
"watcher_use_polling": False,
|
||||
"watcher_polling_interval": 5.0,
|
||||
"watcher_debounce": 2.0,
|
||||
"tag_boost": 2.0,
|
||||
"prefix_max_expansions": 50,
|
||||
"recent_files_limit": 20,
|
||||
"max_backups_per_file": 10,
|
||||
"ai_default_provider": "deepseek",
|
||||
"ai_default_models": {},
|
||||
}
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load config from disk, merging with defaults."""
|
||||
config = dict(_DEFAULT_CONFIG)
|
||||
if _CONFIG_PATH.exists():
|
||||
try:
|
||||
stored = _json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
config.update(stored)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read config.json: {e}")
|
||||
return config
|
||||
|
||||
|
||||
def _save_config(config: dict) -> None:
|
||||
"""Persist config to disk."""
|
||||
try:
|
||||
_CONFIG_PATH.write_text(
|
||||
_json.dumps(config, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write config.json: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save config: {e}")
|
||||
|
||||
|
||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
||||
|
||||
def _write_ai_keys(data: dict):
|
||||
AI_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = AI_KEYS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(AI_KEYS_FILE)
|
||||
|
||||
@router.get("/api/config", response_model=AppConfigResponse)
|
||||
async def api_get_config(current_user=Depends(require_auth)):
|
||||
"""Return current configuration with defaults for missing keys."""
|
||||
return _load_config()
|
||||
|
||||
|
||||
@router.post("/api/config", response_model=AppConfigResponse)
|
||||
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update configuration. Only known keys are accepted.
|
||||
|
||||
Keys matching ``_DEFAULT_CONFIG`` are validated and persisted.
|
||||
Unknown keys are silently ignored.
|
||||
Returns the full merged config after update.
|
||||
"""
|
||||
current = _load_config()
|
||||
updated_keys = []
|
||||
for key, value in body.items():
|
||||
if key in _DEFAULT_CONFIG:
|
||||
expected_type = type(_DEFAULT_CONFIG[key])
|
||||
if isinstance(value, expected_type) or (expected_type is float and isinstance(value, (int, float))):
|
||||
current[key] = value
|
||||
updated_keys.append(key)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid type for '{key}': expected {expected_type.__name__}, got {type(value).__name__}",
|
||||
)
|
||||
_save_config(current)
|
||||
if any(k.startswith("ai_") for k in updated_keys):
|
||||
try:
|
||||
from backend.ai import reload_ai_config
|
||||
reload_ai_config()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reload AI config: {e}")
|
||||
logger.info(f"Config updated: {updated_keys}")
|
||||
return current
|
||||
|
||||
|
||||
@router.get("/api/config/ai-keys", response_model=AIKeysResponse)
|
||||
async def api_get_ai_keys(current_user=Depends(require_admin)):
|
||||
"""Return stored AI keys (values masked)."""
|
||||
keys = _read_ai_keys()
|
||||
masked = {}
|
||||
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
||||
val = keys.get(k, "") or os.environ.get(k, "")
|
||||
if val:
|
||||
masked[k] = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
|
||||
else:
|
||||
masked[k] = ""
|
||||
return masked
|
||||
|
||||
@router.post("/api/config/ai-keys", response_model=StatusResponse)
|
||||
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
|
||||
keys = _read_ai_keys()
|
||||
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
||||
if body.get(k):
|
||||
keys[k] = body[k]
|
||||
_write_ai_keys(keys)
|
||||
logger.info("AI keys updated")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse)
|
||||
async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)):
|
||||
"""Delete a specific AI provider key from storage."""
|
||||
allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY",
|
||||
"NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"}
|
||||
key_name = provider_env.upper()
|
||||
if key_name not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"Clé inconnue: {provider_env}")
|
||||
keys = _read_ai_keys()
|
||||
if key_name in keys:
|
||||
del keys[key_name]
|
||||
_write_ai_keys(keys)
|
||||
# Also clear from env at runtime so get_ai_key() no longer finds it
|
||||
os.environ.pop(key_name, None)
|
||||
logger.info(f"AI key deleted: {key_name}")
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
@router.get("/api/config/tool-keys", response_model=AIKeysResponse)
|
||||
async def api_get_tool_keys(current_user=Depends(require_admin)):
|
||||
"""Return tool/connected-source configuration (tokens masked, URLs clear)."""
|
||||
masked = {}
|
||||
for name in _TOOL_KEY_NAMES:
|
||||
masked[name] = _mask_tool_value(name, _get_tool_key(name))
|
||||
return masked
|
||||
|
||||
|
||||
@router.post("/api/config/tool-keys", response_model=StatusResponse)
|
||||
async def api_set_tool_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Save tool/connected-source keys.
|
||||
|
||||
Only whitelisted names (``backend.tools.secrets.TOOL_KEY_NAMES``) are
|
||||
accepted: Tavily/Brave/SerpAPI/Exa API keys, Gitea URL + token, GitHub
|
||||
token. Empty values delete the stored entry.
|
||||
"""
|
||||
updated = []
|
||||
for name, value in body.items():
|
||||
if name not in _TOOL_KEY_NAMES:
|
||||
raise HTTPException(status_code=400, detail=f"Clé inconnue: {name}")
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise HTTPException(status_code=400, detail=f"Type invalide pour {name}")
|
||||
_set_tool_key(name, value or "")
|
||||
updated.append(name)
|
||||
logger.info(f"Tool keys updated: {updated}")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/config/tool-keys/{name}", response_model=AIKeyDeleteResponse)
|
||||
async def api_delete_tool_key(name: str, current_user=Depends(require_admin)):
|
||||
"""Delete a stored tool key (the environment fallback still applies)."""
|
||||
key_name = name.upper()
|
||||
try:
|
||||
existed = _delete_tool_key(key_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
logger.info(f"Tool key deleted: {key_name} (existed={existed})")
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
@router.post("/api/config/ai-keys/test", response_model=AITestResponse)
|
||||
async def api_test_ai_keys(current_user=Depends(require_admin)):
|
||||
"""Test which AI providers are configured.
|
||||
|
||||
Each provider has a dedicated (URL, header-name) test pair.
|
||||
- Most OpenAI-compatible APIs use `Authorization: Bearer KEY`
|
||||
- Xiaomi MiMo uses `api-key: KEY`
|
||||
- Gemini uses a query-string key
|
||||
"""
|
||||
results = {}
|
||||
for key_name, label, test_url_tmpl, header_name in [
|
||||
# OpenAI-compatible — Authorization: Bearer
|
||||
("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"),
|
||||
("OPENROUTER_API_KEY","openrouter", "https://openrouter.ai/api/v1/models", "Authorization"),
|
||||
("NVIDIA_API_KEY", "nvidia", "https://integrate.api.nvidia.com/v1/models", "Authorization"),
|
||||
("QWENCLOUD_API_KEY", "qwencloud", "https://dashscope.aliyuncs.com/compatible-mode/v1/models", "Authorization"),
|
||||
("MISTRAL_API_KEY", "mistral", "https://api.mistral.ai/v1/models", "Authorization"),
|
||||
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer)
|
||||
("XIAOMI_API_KEY", "xiaomi", "https://api.xiaomimimo.com/v1/models", "api-key"),
|
||||
# Gemini — key in query string
|
||||
("GEMINI_API_KEY", "gemini", "https://generativelanguage.googleapis.com/v1beta/models?key={key}", None),
|
||||
]:
|
||||
key = get_ai_key(key_name)
|
||||
if not key:
|
||||
results[label] = "non configuré"
|
||||
continue
|
||||
try:
|
||||
url = test_url_tmpl.replace("{key}", key) if "{key}" in test_url_tmpl else test_url_tmpl
|
||||
if header_name:
|
||||
req = urllib.request.Request(url, headers={header_name: key})
|
||||
else:
|
||||
req = urllib.request.Request(url)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
results[label] = "ok"
|
||||
except Exception as e:
|
||||
# Truncate the error to keep the response small.
|
||||
results[label] = "erreur: " + str(e)[:80]
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/api/config/ai-models", response_model=AIModelsResponse)
|
||||
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
|
||||
"""List available models for a given AI provider.
|
||||
|
||||
Strategy:
|
||||
1. Try the provider's public models endpoint (OpenAI-compatible /v1/models or Gemini).
|
||||
2. If the network call fails (timeout, 4xx, 5xx, DNS, etc.), fall back to a
|
||||
curated static list of known-good models for that provider.
|
||||
3. Always return a non-empty list when the provider is known, so the UI
|
||||
dropdown is never empty.
|
||||
"""
|
||||
provider = provider.lower()
|
||||
|
||||
from backend.model_capabilities import get_capabilities_for_models
|
||||
from backend.provider_capabilities import remember_declared_capabilities
|
||||
|
||||
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
|
||||
if provider not in all_providers:
|
||||
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
|
||||
|
||||
key_name = f"{provider.upper()}_API_KEY"
|
||||
key = get_ai_key(key_name)
|
||||
if not key:
|
||||
# No key configured — return curated fallback list so the UI can
|
||||
# still show what WOULD be available once a key is set.
|
||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||
return {"models": fallback, "source": "fallback",
|
||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||
"note": "API key not configured — showing default model list"}
|
||||
|
||||
# Build URL
|
||||
if provider == "gemini":
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
|
||||
elif provider == "deepseek":
|
||||
url = "https://api.deepseek.com/v1/models"
|
||||
elif provider == "openrouter":
|
||||
url = "https://openrouter.ai/api/v1/models"
|
||||
elif provider == "nvidia":
|
||||
url = "https://integrate.api.nvidia.com/v1/models"
|
||||
elif provider == "qwencloud":
|
||||
url = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
|
||||
elif provider == "xiaomi":
|
||||
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer).
|
||||
# Endpoint: https://api.xiaomimimo.com/v1/models
|
||||
url = "https://api.xiaomimimo.com/v1/models"
|
||||
models = [] # parsed below with the custom header
|
||||
elif provider == "mistral":
|
||||
url = "https://api.mistral.ai/v1/models"
|
||||
|
||||
try:
|
||||
if provider == "gemini":
|
||||
req = urllib.request.Request(url)
|
||||
elif provider == "xiaomi":
|
||||
# Xiaomi MiMo uses a dedicated api-key header.
|
||||
req = urllib.request.Request(url, headers={"api-key": key})
|
||||
else:
|
||||
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + key})
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = _json.loads(resp.read().decode())
|
||||
|
||||
if provider == "gemini":
|
||||
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
|
||||
# Gemini returns names like "models/gemini-1.5-flash" — strip prefix
|
||||
models = [m.replace("models/", "") for m in models]
|
||||
else:
|
||||
models = [m.get("id", "") for m in data.get("data", []) if m.get("id")]
|
||||
|
||||
# Cache the capabilities the provider declares for these models
|
||||
# (BUG-044) — get_capabilities_for_models() below then returns the
|
||||
# provider's own truth for the flags it declares, the curated table
|
||||
# for the rest. Providers that declare nothing are left untouched.
|
||||
remember_declared_capabilities(provider, data)
|
||||
|
||||
if models:
|
||||
# Prepend the configured default if not already present
|
||||
default = PROVIDERS.get(provider, {}).get("model")
|
||||
if default and default not in models:
|
||||
models = [default] + models
|
||||
return {"models": models, "source": "live", "count": len(models),
|
||||
"capabilities": get_capabilities_for_models(provider, models)}
|
||||
# Empty list from API — fall through to fallback
|
||||
raise ValueError("empty model list from provider API")
|
||||
except Exception as e:
|
||||
# Network error, auth error, parsing error — use curated fallback
|
||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
|
||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||
"note": "Could not reach provider API — showing default model list"}
|
||||
|
||||
|
||||
# ── Curated fallback model lists ──────────────────────────────────────────
|
||||
# Used when the provider API is unreachable or returns empty.
|
||||
# Keep these short and focused on models known to work with the
|
||||
# OpenAI-compatible chat completions interface (or Gemini's generateContent).
|
||||
_FALLBACK_MODELS: dict[str, list[str]] = {
|
||||
"deepseek": [
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
],
|
||||
"openrouter": [
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-4o",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3-haiku",
|
||||
"google/gemini-2.0-flash-exp:free",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
"meta-llama/llama-3.1-8b-instruct:free",
|
||||
"mistralai/mistral-large-latest",
|
||||
],
|
||||
"gemini": [
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-8b",
|
||||
],
|
||||
"nvidia": [
|
||||
"meta/llama-3.1-405b-instruct",
|
||||
"meta/llama-3.1-70b-instruct",
|
||||
"meta/llama-3.1-8b-instruct",
|
||||
"mistralai/mistral-large",
|
||||
"google/gemma-2-27b-it",
|
||||
"nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
],
|
||||
"qwencloud": [
|
||||
"qwen-max",
|
||||
"qwen-plus",
|
||||
"qwen-turbo",
|
||||
"qwen-long",
|
||||
"qwen-vl-max",
|
||||
"qwen-vl-plus",
|
||||
],
|
||||
"xiaomi": [
|
||||
# Xiaomi MiMo models — the public /v1/models endpoint requires the
|
||||
# `api-key` custom header (NOT Authorization: Bearer), so the live
|
||||
# call often fails with 401 even with the right key. We ship a
|
||||
# known-good list as fallback. See https://mimo.mi.com/docs/
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-asr",
|
||||
"mimo-v2.5-tts",
|
||||
"mimo-v2.5-tts-voiceclone",
|
||||
"mimo-v2.5-tts-voicedesign",
|
||||
],
|
||||
"mistral": [
|
||||
"mistral-large-latest",
|
||||
"mistral-medium-latest",
|
||||
"mistral-small-latest",
|
||||
"open-mistral-7b",
|
||||
"open-mixtral-8x7b",
|
||||
"codestral-latest",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/diagnostics", response_model=DiagnosticsResponse)
|
||||
async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
"""Return index statistics and system diagnostics.
|
||||
|
||||
Includes document counts, token counts, memory estimates,
|
||||
and inverted index status.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from backend.search import get_inverted_index
|
||||
|
||||
inv = get_inverted_index()
|
||||
|
||||
# Per-vault stats
|
||||
vault_stats = {}
|
||||
total_files = 0
|
||||
total_tags = 0
|
||||
# Snapshot both dicts first: the indexer mutates them from background
|
||||
# threads, and iterating a live dict raises "dictionary changed size".
|
||||
for vname, vdata in list(index.items()):
|
||||
file_count = len(vdata.get("files", []))
|
||||
tag_count = len(vdata.get("tags", {}))
|
||||
vault_stats[vname] = {"file_count": file_count, "tag_count": tag_count}
|
||||
total_files += file_count
|
||||
total_tags += tag_count
|
||||
|
||||
# Memory estimate for inverted index
|
||||
word_index = inv.word_index.copy()
|
||||
word_index_entries = sum(len(docs) for docs in word_index.values())
|
||||
mem_estimate_mb = round(
|
||||
(sys.getsizeof(inv.word_index) + word_index_entries * 80
|
||||
+ len(inv.doc_info) * 200
|
||||
+ len(inv._sorted_tokens) * 60) / (1024 * 1024), 2
|
||||
)
|
||||
|
||||
return {
|
||||
"index": {
|
||||
"total_files": total_files,
|
||||
"total_tags": total_tags,
|
||||
"vaults": vault_stats,
|
||||
},
|
||||
"inverted_index": {
|
||||
"unique_tokens": len(word_index),
|
||||
"total_postings": word_index_entries,
|
||||
"documents": inv.doc_count,
|
||||
"sorted_tokens": len(inv._sorted_tokens),
|
||||
"is_ready": inv.is_ready(),
|
||||
"memory_estimate_mb": mem_estimate_mb,
|
||||
},
|
||||
"config": _load_config(),
|
||||
"search_executor": {
|
||||
"active": get_search_executor() is not None,
|
||||
"max_workers": get_search_executor()._max_workers if get_search_executor() else 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/dashboard", response_model=DashboardResponse)
|
||||
async def api_dashboard(current_user=Depends(require_auth)):
|
||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
vault_stats = []
|
||||
total_files = 0
|
||||
total_tags = set()
|
||||
total_size = 0
|
||||
total_images = 0
|
||||
for vname, vdata in index.items():
|
||||
if "*" not in user_vaults and vname not in user_vaults:
|
||||
continue
|
||||
files = vdata.get("files", [])
|
||||
fc = len(files)
|
||||
total_files += fc
|
||||
vtags = set()
|
||||
vsize = 0
|
||||
vimages = 0
|
||||
for f in files:
|
||||
vtags.update(f.get("tags", []))
|
||||
vsize += f.get("size", 0)
|
||||
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
|
||||
vimages += 1
|
||||
total_tags.update(vtags)
|
||||
total_size += vsize
|
||||
total_images += vimages
|
||||
vault_stats.append({
|
||||
"name": vname, "file_count": fc, "tag_count": len(vtags),
|
||||
"total_size_bytes": vsize, "image_count": vimages,
|
||||
})
|
||||
return {
|
||||
"vaults": vault_stats,
|
||||
"total_files": total_files,
|
||||
"total_tags": len(total_tags),
|
||||
"total_size_bytes": total_size,
|
||||
"total_images": total_images,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Syncthing conflict endpoints (ROADMAP #85, tranche 8).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/conflicts*``), mêmes modèles de
|
||||
réponse, mêmes dépendances d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
|
||||
et :mod:`backend.services.backups` (pass-through).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from backend.audit import log_file_delete
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.indexer import get_conflicts, get_vault_data, remove_single_file
|
||||
from backend.schemas import ConflictResolveResponse, ConflictsResponse
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["conflicts"])
|
||||
|
||||
|
||||
@router.get("/api/conflicts", response_model=ConflictsResponse)
|
||||
async def api_conflicts(current_user=Depends(require_auth)):
|
||||
"""List sync-conflict files across accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
all_conflicts = get_conflicts()
|
||||
if "*" not in user_vaults:
|
||||
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
|
||||
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
||||
|
||||
|
||||
@router.post("/api/conflicts/resolve", response_model=ConflictResolveResponse)
|
||||
async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
"""Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original)."""
|
||||
vault_name = body.get("vault")
|
||||
conflict_path = body.get("conflict_path")
|
||||
original_path = body.get("original_path")
|
||||
action = body.get("action") # "keep_local" or "keep_conflict"
|
||||
# mypy: narrow down from dict values
|
||||
assert isinstance(vault_name, str), "'vault' is required and must be a string"
|
||||
assert isinstance(conflict_path, str), "'conflict_path' is required and must be a string"
|
||||
assert isinstance(original_path, str), "'original_path' is required and must be a string"
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
conf_file = resolve_safe_path(vault_root, conflict_path)
|
||||
orig_file = resolve_safe_path(vault_root, original_path)
|
||||
if not conf_file.exists():
|
||||
raise HTTPException(404, "Conflict file not found")
|
||||
try:
|
||||
if action == "keep_conflict":
|
||||
create_backup(orig_file, vault_name, original_path)
|
||||
shutil.copy2(conf_file, orig_file)
|
||||
logger.info(f"Conflict resolved (keep_conflict): {conflict_path} → {original_path}")
|
||||
conf_file.unlink()
|
||||
await remove_single_file(vault_name, conflict_path)
|
||||
log_file_delete(current_user["username"], vault_name, conflict_path)
|
||||
await sse_manager.broadcast("file_deleted", {"vault": vault_name, "path": conflict_path})
|
||||
return {"status": "resolved", "action": action}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Error resolving conflict: {e!s}")
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Media, PDF, export & vault-settings endpoints (ROADMAP #85, tranche 6c).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/file/*/pdf*``, ``/api/export/*``,
|
||||
``/api/guide/download``, ``/api/image/*``, ``/api/media*``,
|
||||
``/api/attachments/*``, ``/api/vaults/*/settings``, ``/api/vault/*/files``,
|
||||
``/api/vaults/settings/all``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
- ``_resolve_export_target`` / ``_safe_export_name`` (export uniquement)
|
||||
sont définis ici ; ``stream_file_with_range`` vit dans
|
||||
:mod:`backend.routers.helpers` (partagé).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.export import ExportError, export_epub, export_html, export_md_bundle
|
||||
from backend.history import record_open
|
||||
from backend.indexer import get_vault_data, index, parse_markdown_file
|
||||
from backend.media_thumbs import generate_thumbnail, is_decodable
|
||||
from backend.media_types import is_audio, is_image, is_video, media_mime_type
|
||||
from backend.render import _render_markdown
|
||||
from backend.routers.helpers import media_max_inline_bytes, stream_file_with_range
|
||||
from backend.schemas import (
|
||||
AllVaultSettingsResponse,
|
||||
AttachmentRescanResponse,
|
||||
AttachmentStatsResponse,
|
||||
PdfInfoResponse,
|
||||
VaultFilesResponse,
|
||||
VaultSettingsResponse,
|
||||
)
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import list_all_files
|
||||
from backend.vault_settings import get_vault_setting, update_vault_setting
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
|
||||
try:
|
||||
from backend.pdf_export import build_pdf_html, generate_pdf
|
||||
except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
||||
generate_pdf = None # type: ignore[assignment]
|
||||
build_pdf_html = None # type: ignore[assignment]
|
||||
|
||||
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
|
||||
|
||||
router = APIRouter() # pas de tags : assignation par chemin via openapi_docs.tag_for_path (comme avant)
|
||||
|
||||
|
||||
def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tuple[Path, Path]:
|
||||
"""Resolve a vault + relative path into (vault_root, absolute file path).
|
||||
|
||||
Enforces auth (vault access) and path traversal protection.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
target = resolve_safe_path(vault_root, path)
|
||||
return vault_root, target
|
||||
|
||||
|
||||
def _safe_export_name(name: str) -> str:
|
||||
"""ASCII-safe, filename-safe download name (falls back to 'document')."""
|
||||
cleaned = "".join(c for c in name if c.isascii() and (c.isalnum() or c in " _-.")).strip()
|
||||
return cleaned or "document"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/pdf",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}},
|
||||
)
|
||||
async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Download a markdown file as PDF."""
|
||||
if generate_pdf is None:
|
||||
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, f"File not found: {path}")
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot read file")
|
||||
record_open(current_user.get("username"), vault_name, path)
|
||||
raw = redact_file_content(raw, str(file_path))
|
||||
post = parse_markdown_file(raw)
|
||||
html = _render_markdown(post.content, vault_name, file_path)
|
||||
title = post.metadata.get("title", file_path.stem)
|
||||
pdf_html = build_pdf_html(html, str(title))
|
||||
pdf_bytes = generate_pdf(pdf_html, str(title))
|
||||
safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document"
|
||||
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/export/html",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}},
|
||||
)
|
||||
async def api_export_html(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Export a markdown note as a standalone HTML file."""
|
||||
try:
|
||||
vault_root, target = _resolve_export_target(vault, path, current_user)
|
||||
html_bytes = export_html(vault_root, target)
|
||||
except ExportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
record_open(current_user.get("username"), vault, path)
|
||||
safe_name = _safe_export_name(target.stem)
|
||||
return Response(
|
||||
content=html_bytes,
|
||||
media_type="text/html; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.html"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/export/md-bundle",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}},
|
||||
)
|
||||
async def api_export_md_bundle(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to directory or file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Export a directory (or single file) of markdown as a ZIP bundle."""
|
||||
try:
|
||||
vault_root, target = _resolve_export_target(vault, path, current_user)
|
||||
zip_bytes = export_md_bundle(vault_root, target)
|
||||
except ExportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
safe_name = _safe_export_name(target.name)
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/export/epub",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}},
|
||||
)
|
||||
async def api_export_epub(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Export a markdown note as an ePub document."""
|
||||
try:
|
||||
vault_root, target = _resolve_export_target(vault, path, current_user)
|
||||
epub_bytes = export_epub(vault_root, target)
|
||||
except ExportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
record_open(current_user.get("username"), vault, path)
|
||||
safe_name = _safe_export_name(target.stem)
|
||||
return Response(
|
||||
content=epub_bytes,
|
||||
media_type="application/epub+zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.epub"'},
|
||||
)
|
||||
|
||||
|
||||
@router.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}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
|
||||
async def api_pdf_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
path: str = Query(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
|
||||
|
||||
Supports HTTP Range requests (206 Partial Content) so browsers can
|
||||
progressively render large PDFs in the native viewer.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".pdf":
|
||||
raise HTTPException(status_code=400, detail="Not a PDF file")
|
||||
|
||||
return stream_file_with_range(file_path, request, "application/pdf")
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
|
||||
async def api_pdf_info(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to PDF file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return PDF metadata (pages, title, author, size) without the document content.
|
||||
|
||||
Lets the UI display file info before loading a heavy PDF into the viewer.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".pdf":
|
||||
raise HTTPException(status_code=400, detail="Not a PDF file")
|
||||
|
||||
from backend.pdf_reader import extract_pdf_metadata
|
||||
meta = extract_pdf_metadata(file_path)
|
||||
stat = file_path.stat()
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"pages": meta.get("pages", 0),
|
||||
"title": meta.get("title") or file_path.name,
|
||||
"author": meta.get("author", ""),
|
||||
"size_bytes": stat.st_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/image/{vault_name}",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}},
|
||||
)
|
||||
async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)):
|
||||
"""Serve an image file with proper MIME type.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
Image file with appropriate content-type header.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
|
||||
|
||||
mime_type = media_mime_type(str(file_path))
|
||||
|
||||
# #108-B3 — a standalone SVG opened in a tab executes its embedded JS
|
||||
# (same-origin XSS). ``sandbox`` forces a unique opaque origin with no
|
||||
# script execution; inside an <img> tag the header is irrelevant.
|
||||
headers = {"X-Content-Type-Options": "nosniff"}
|
||||
if file_path.suffix.lower() == ".svg":
|
||||
headers["Content-Security-Policy"] = "sandbox"
|
||||
|
||||
try:
|
||||
# Read and return the image file
|
||||
content = file_path.read_bytes()
|
||||
return Response(content=content, media_type=mime_type, headers=headers)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving image {vault_name}/{path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
|
||||
|
||||
|
||||
@router.get("/api/media/{vault_name}", response_class=FileResponse)
|
||||
async def api_media_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to audio/video file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Stream an audio/video file with HTTP Range support (roadmap #109-A2).
|
||||
|
||||
Serves the bytes with the correct MIME type and honours ``Range`` requests
|
||||
(``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is
|
||||
what enables scrubbing in ``<audio>``/``<video>`` and is required by Safari
|
||||
for MP4. Files above ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB) are
|
||||
refused with ``413`` — the viewer falls back to the download button.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Media not found: {path}")
|
||||
|
||||
ext = file_path.suffix.lower()
|
||||
if not (is_audio(ext) or is_video(ext)):
|
||||
raise HTTPException(status_code=400, detail="Not an audio/video file")
|
||||
|
||||
if file_path.stat().st_size > media_max_inline_bytes():
|
||||
raise HTTPException(status_code=413, detail="Media too large for inline streaming")
|
||||
|
||||
return stream_file_with_range(file_path, request, media_mime_type(str(file_path)))
|
||||
|
||||
|
||||
@router.get("/api/media/{vault_name}/thumb", response_class=FileResponse)
|
||||
async def api_media_thumb(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to image"),
|
||||
size: int = Query(256, ge=32, le=1024, description="Max thumbnail edge in pixels"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Serve a cached WebP thumbnail of an image (roadmap #108-C).
|
||||
|
||||
SVG (and any format Pillow cannot decode) falls back to the original
|
||||
bytes. Generation runs in a thread and is capped at 2 s; on timeout or
|
||||
failure the original is served so the UI never breaks.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
|
||||
if not is_image(file_path.suffix.lower()):
|
||||
raise HTTPException(status_code=400, detail="Not an image file")
|
||||
|
||||
mime_type = media_mime_type(str(file_path))
|
||||
if not is_decodable(file_path):
|
||||
# SVG: never let a standalone navigation execute embedded JS (#108-B3).
|
||||
svg_headers = {"X-Content-Type-Options": "nosniff"}
|
||||
if file_path.suffix.lower() == ".svg":
|
||||
svg_headers["Content-Security-Policy"] = "sandbox"
|
||||
return FileResponse(str(file_path), media_type=mime_type, headers=svg_headers)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
thumb: Path | None = None
|
||||
try:
|
||||
thumb = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, generate_thumbnail, file_path, size),
|
||||
timeout=2.0,
|
||||
)
|
||||
except Exception:
|
||||
thumb = None
|
||||
|
||||
if thumb is not None and thumb.exists():
|
||||
return FileResponse(str(thumb), media_type="image/webp")
|
||||
return FileResponse(str(file_path), media_type=mime_type)
|
||||
|
||||
|
||||
@router.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse)
|
||||
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Rescan attachments for a specific vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to rescan.
|
||||
|
||||
Returns:
|
||||
Dict with status and attachment count.
|
||||
"""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_path = vault_data["path"]
|
||||
count = await rescan_vault_attachments(vault_name, vault_path)
|
||||
|
||||
logger.info(f"Rescanned attachments for vault '{vault_name}': {count} attachments")
|
||||
return {"status": "ok", "vault": vault_name, "attachment_count": count}
|
||||
|
||||
|
||||
@router.get("/api/attachments/stats", response_model=AttachmentStatsResponse)
|
||||
async def api_attachment_stats(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
|
||||
"""Get attachment statistics for vaults.
|
||||
|
||||
Args:
|
||||
vault: Optional vault name to filter stats.
|
||||
|
||||
Returns:
|
||||
Dict with vault names as keys and attachment counts as values.
|
||||
"""
|
||||
stats = get_attachment_stats(vault)
|
||||
return {"vaults": stats}
|
||||
|
||||
|
||||
@router.get("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
|
||||
async def api_get_vault_settings(vault_name: str, current_user=Depends(require_auth)):
|
||||
"""Get UI display settings for a specific vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
|
||||
Returns:
|
||||
Dict with vault settings including hideHiddenFiles.
|
||||
"""
|
||||
if vault_name not in index:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
# Get persisted settings
|
||||
persisted = get_vault_setting(vault_name) or {}
|
||||
|
||||
# Default settings
|
||||
settings = {
|
||||
"hideHiddenFiles": False,
|
||||
}
|
||||
settings.update(persisted)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@router.post("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
|
||||
async def api_update_vault_settings(vault_name: str, body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update UI display settings for a specific vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Dict with settings to update (hideHiddenFiles).
|
||||
|
||||
Returns:
|
||||
Updated settings dict.
|
||||
"""
|
||||
if vault_name not in index:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
# Validate settings
|
||||
settings_to_update = {}
|
||||
|
||||
if "hideHiddenFiles" in body:
|
||||
if not isinstance(body["hideHiddenFiles"], bool):
|
||||
raise HTTPException(status_code=400, detail="hideHiddenFiles must be a boolean")
|
||||
settings_to_update["hideHiddenFiles"] = body["hideHiddenFiles"]
|
||||
|
||||
# Update persisted settings
|
||||
try:
|
||||
updated = update_vault_setting(vault_name, settings_to_update)
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission error saving settings for vault '{vault_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Permission denied: Cannot write to settings file. Check /app/data permissions."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings for vault '{vault_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to save settings: {e!s}"
|
||||
)
|
||||
|
||||
logger.info(f"Updated settings for vault '{vault_name}': {settings_to_update}")
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
@router.get("/api/vault/{vault_name}/files", response_model=VaultFilesResponse)
|
||||
async def api_vault_recent_files(
|
||||
vault_name: str,
|
||||
dir: str = Query("", description="Directory path within the vault (empty = root)"),
|
||||
limit: int = Query(200, description="Maximum number of files to return"),
|
||||
recursive: bool = Query(True, description="If true, list files recursively from directory and all subdirectories"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List files in a vault directory sorted by modification time (newest first).
|
||||
|
||||
Returns file metadata suitable for a vault home page display.
|
||||
Unlike /api/browse, this endpoint sorts by mtime and returns
|
||||
additional metadata (size, modified time, extension).
|
||||
|
||||
When recursive=True (default), lists files from the directory
|
||||
AND all its subdirectories, with a ``rel_dir`` field indicating
|
||||
the subdirectory path relative to the requested directory.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
dir: Relative directory path within the vault (empty for root).
|
||||
limit: Maximum files to return (default 200).
|
||||
recursive: If true, recursively list files in subdirectories (default true).
|
||||
|
||||
Returns:
|
||||
JSON with vault, directory, count, recursive flag, and list of file entries.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
return list_all_files(vault_name, dir=dir, limit=limit, recursive=recursive)
|
||||
|
||||
|
||||
@router.get("/api/vaults/settings/all", response_model=AllVaultSettingsResponse)
|
||||
async def api_get_all_vault_settings(current_user=Depends(require_auth)):
|
||||
"""Get UI display settings for all vaults.
|
||||
|
||||
Returns:
|
||||
Dict mapping vault names to their settings.
|
||||
"""
|
||||
all_settings = {}
|
||||
|
||||
for vault_name in index:
|
||||
persisted = get_vault_setting(vault_name) or {}
|
||||
|
||||
settings = {
|
||||
"hideHiddenFiles": False,
|
||||
}
|
||||
settings.update(persisted)
|
||||
all_settings[vault_name] = settings
|
||||
|
||||
return all_settings
|
||||
@@ -0,0 +1,690 @@
|
||||
"""File browsing & reading endpoints (ROADMAP #85, tranche 6a).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/browse/*``, ``/api/file/*`` en
|
||||
lecture), mêmes modèles de réponse (déménagés dans
|
||||
:mod:`backend.schemas`), mêmes dépendances d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
- ``_content_disposition`` / ``_media_max_inline_bytes`` / ``EXT_TO_LANG``
|
||||
ont déménagé : helpers partagés dans :mod:`backend.routers.helpers`
|
||||
(``EXT_TO_LANG`` n'était utilisé que par la vue fichier).
|
||||
"""
|
||||
|
||||
import html as html_mod
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import record_open
|
||||
from backend.indexer import (
|
||||
_extract_tags,
|
||||
get_backlinks,
|
||||
get_vault_data,
|
||||
parse_markdown_file,
|
||||
)
|
||||
from backend.media_types import is_audio, is_image, is_video, media_mime_type
|
||||
from backend.render import _render_markdown
|
||||
from backend.routers.helpers import media_max_inline_bytes
|
||||
from backend.schemas import (
|
||||
BacklinksResponse,
|
||||
BrowseResponse,
|
||||
FileContentResponse,
|
||||
FileRawResponse,
|
||||
XlsxDashboardResponse,
|
||||
XlsxSheetWindowResponse,
|
||||
)
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
# Map file extensions to highlight.js language hints
|
||||
EXT_TO_LANG = {
|
||||
".py": "python", ".js": "javascript", ".ts": "typescript",
|
||||
".jsx": "jsx", ".tsx": "tsx", ".sh": "bash", ".bash": "bash",
|
||||
".zsh": "bash", ".fish": "fish", ".bat": "batch", ".cmd": "batch",
|
||||
".ps1": "powershell", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
|
||||
".toml": "toml", ".xml": "xml", ".csv": "plaintext",
|
||||
".cfg": "ini", ".ini": "ini", ".conf": "ini", ".env": "bash",
|
||||
".html": "html", ".css": "css", ".scss": "scss", ".less": "less",
|
||||
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
|
||||
".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby",
|
||||
".php": "php", ".sql": "sql", ".r": "r", ".swift": "swift",
|
||||
".kt": "kotlin", ".txt": "plaintext", ".log": "plaintext",
|
||||
".lua": "lua", ".pl": "perl", ".pm": "perl", ".ex": "elixir", ".exs": "elixir",
|
||||
".dart": "dart", ".tf": "haskell", ".gradle": "groovy", ".groovy": "groovy",
|
||||
".graphql": "graphql", ".gql": "graphql", ".prisma": "sql", ".proto": "c",
|
||||
".vb": "basic", ".asm": "x86asm", ".s": "armasm",
|
||||
".vue": "xml", ".svelte": "xml", ".astro": "xml",
|
||||
".properties": "ini", ".service": "ini", ".hosts": "ini",
|
||||
".ksh": "bash", ".dockerfile": "dockerfile",
|
||||
".makefile": "makefile", ".cmake": "cmake",
|
||||
}
|
||||
|
||||
router = APIRouter(tags=["files"])
|
||||
|
||||
|
||||
@router.get("/api/browse/{vault_name}", response_model=BrowseResponse)
|
||||
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
|
||||
"""Browse directories and files in a vault at a given path level.
|
||||
|
||||
Returns sorted entries (directories first, then files) with metadata.
|
||||
Hidden files/directories (starting with ``"."`` ) are excluded.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to browse.
|
||||
path: Relative directory path within the vault (empty = root).
|
||||
|
||||
Returns:
|
||||
``BrowseResponse`` with vault name, path, and item list.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return browse_directory(vault_name, path)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
|
||||
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Return raw file content as plain text.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileRawResponse`` with vault, path, and raw text content.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return read_raw_file(vault_name, path)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/download", response_class=FileResponse)
|
||||
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Download a file as an attachment.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileResponse`` with ``application/octet-stream`` content-type.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
# Record history
|
||||
record_open(current_user.get("username"), vault_name, path)
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=file_path.name,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
|
||||
async def api_file_backlinks(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Get backlinks (files linking to this file via wikilinks).
|
||||
|
||||
Returns a list of files that contain `[[wikilinks]]` pointing
|
||||
to the requested file, across all accessible vaults.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault containing the target file.
|
||||
path: Relative path of the target file within the vault.
|
||||
|
||||
Returns:
|
||||
``{"vault": str, "path": str, "backlinks": [...]}``
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
backlinks = get_backlinks(vault_name, path)
|
||||
|
||||
# Filter by user-accessible vaults
|
||||
if "*" not in user_vaults:
|
||||
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"backlinks": backlinks,
|
||||
"total": len(backlinks),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/xlsx/dashboard", response_model=XlsxDashboardResponse
|
||||
)
|
||||
def api_file_xlsx_dashboard(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx workbook"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return the dashboard metadata of an .xlsx workbook (#153 A17).
|
||||
|
||||
Named ranges (workbook- or sheet-scoped), chart/pivot object counts and
|
||||
per-sheet KPI stats (non-empty cells, rows/cols coverage, formulas,
|
||||
numeric cells, first numeric values as KPI cards). Read-only, bounded by
|
||||
the 500x40 render caps; never raises for an unreadable workbook — an
|
||||
empty payload comes back and the viewer hides the panel.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
_vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(_vault_root, path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
try:
|
||||
dashboard = read_workbook_dashboard(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX dashboard read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
**dashboard,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
|
||||
)
|
||||
def api_file_xlsx_sheet(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
|
||||
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
|
||||
limit: int = Query(
|
||||
200, ge=1, le=1000, description="Rows to return (server-capped)"
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a window of rows of one sheet of an .xlsx/.xlsm workbook (#153 A9).
|
||||
|
||||
Backs the viewer's lazy loading: instead of every sheet in a single JSON
|
||||
payload, the client asks for the block it is about to display. The row
|
||||
numbers and the ``data-cell`` references are the real A1 coordinates of the
|
||||
sheet, so a window behaves like the full render (editing a cell in it
|
||||
targets the right cell).
|
||||
|
||||
The response also carries ``total_rows``/``total_cols`` and the ``truncated``
|
||||
flag, so the client can say what is hidden behind the 500x40 render caps
|
||||
instead of silently hiding it.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the .xlsx file within the vault.
|
||||
sheet: Sheet name; **404** if the workbook has no such sheet.
|
||||
offset: 0-based index of the first row to return.
|
||||
limit: Rows to return, capped server-side at 1000.
|
||||
|
||||
Returns:
|
||||
``XlsxSheetWindowResponse`` with the rendered ``html`` of the window.
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
|
||||
415 (not an .xlsx/.xlsm file), 500 (unreadable workbook).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
# BUG-097 — a .xlsm rides the same editable viewer (and its lazy loading),
|
||||
# so its row windows must be servable too; other formats stay refused.
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
|
||||
from backend.xlsx_reader import read_sheet_window
|
||||
|
||||
try:
|
||||
window = read_sheet_window(file_path, sheet, offset=offset, limit=limit)
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX sheet read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
if window is None:
|
||||
raise HTTPException(status_code=404, detail=f"Feuille introuvable: {sheet}")
|
||||
|
||||
return {"vault": vault_name, "path": path, **window}
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
|
||||
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Return rendered HTML and metadata for a file.
|
||||
|
||||
Markdown files are parsed for frontmatter, rendered with wikilink
|
||||
support, and returned with extracted tags. Other supported file
|
||||
types are syntax-highlighted as code blocks.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileContentResponse`` with HTML, metadata, and tags.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
# Record history
|
||||
record_open(current_user.get("username"), vault_name, path, title=file_path.name)
|
||||
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
# === PDF: special handling before read_text (binary file) ===
|
||||
if ext == ".pdf":
|
||||
try:
|
||||
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text, extract_pdf_toc
|
||||
pdf_text = extract_pdf_text(file_path, max_chars=100000)
|
||||
pdf_meta = extract_pdf_metadata(file_path)
|
||||
pdf_toc = extract_pdf_toc(file_path)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": pdf_meta.get("title") or file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": f"<div class='pdf-viewer'><p>PDF — {pdf_meta.get('pages', '?')} pages</p><pre>{pdf_text[:5000]}</pre></div>",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_pdf": True,
|
||||
"unsupported": False,
|
||||
"pdf_metadata": pdf_meta,
|
||||
"pdf_toc": pdf_toc,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"PDF read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
|
||||
|
||||
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
|
||||
if ext == ".xlsx":
|
||||
try:
|
||||
from backend.xlsx_reader import inspect_workbook, render_sheets
|
||||
|
||||
# #153 A15 — every sheet dict already carries its styles, aligns,
|
||||
# merges and freeze anchor (read_workbook_meta, one normal-mode
|
||||
# load inside render_sheets).
|
||||
sheets = render_sheets(file_path)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": sheets[0]["html"] if sheets else "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
# #156-A12 — optimistic-concurrency token: the viewer sends it
|
||||
# back as `if_match` so another writer cannot be overwritten in
|
||||
# silence (409 `conflict` instead).
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# #153 A1 — parts a save would drop; the viewer warns and asks
|
||||
# for an explicit confirmation before forcing the write.
|
||||
"xlsx_lossy_features": inspect_workbook(file_path),
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
|
||||
# === Images: return as viewable image ===
|
||||
if is_image(ext):
|
||||
size = file_path.stat().st_size
|
||||
mime = media_mime_type(str(file_path))
|
||||
# #108-B1 — the raw endpoint returns JSON (FileRawResponse), so the
|
||||
# standalone <img> must point to /api/image, which serves the bytes
|
||||
# with the right MIME type. Paths are URL-encoded (accents, spaces).
|
||||
img_url = f"/api/image/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
|
||||
html = (
|
||||
f'<div class="image-viewer">'
|
||||
f'<img src="{img_url}" '
|
||||
f'alt="{html_mod.escape(file_path.name, quote=True)}" '
|
||||
f'style="max-width:100%;max-height:80vh;object-fit:contain" />'
|
||||
f'</div>'
|
||||
)
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html,
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_image": True,
|
||||
"image_mime": mime,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
# === Audio / Video: HTML5 players streamed from /api/media (roadmap #109) ===
|
||||
if is_audio(ext) or is_video(ext):
|
||||
size = file_path.stat().st_size
|
||||
mime = media_mime_type(str(file_path))
|
||||
media_kind = "audio" if is_audio(ext) else "video"
|
||||
|
||||
# #109-A3 — beyond the inline limit the viewer falls back to download
|
||||
# (a single uvicorn worker must not be pinned by multi-GB media).
|
||||
if size > media_max_inline_bytes():
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"unsupported": True,
|
||||
"media_too_large": True,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
# #109-A2 — byte-range endpoint: enables scrub and is required by Safari.
|
||||
stream_url = f"/api/media/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
|
||||
if media_kind == "audio":
|
||||
html = (
|
||||
f'<div class="audio-viewer">'
|
||||
f'<audio controls preload="metadata" src="{stream_url}"></audio>'
|
||||
f'</div>'
|
||||
)
|
||||
else:
|
||||
html = (
|
||||
f'<div class="video-viewer">'
|
||||
f'<video controls playsinline preload="metadata" src="{stream_url}"></video>'
|
||||
f'</div>'
|
||||
)
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html,
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_audio": media_kind == "audio",
|
||||
"is_video": media_kind == "video",
|
||||
"media_mime": mime,
|
||||
"stream_url": stream_url,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied reading file {path}: {e}")
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
|
||||
except UnicodeDecodeError:
|
||||
# Binary / unsupported file — return structured info with download option
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"unsupported": True,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading file {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
|
||||
|
||||
# === Excel .xlsm: same editable viewer as .xlsx, macros preserved on save ===
|
||||
if ext == ".xlsm":
|
||||
try:
|
||||
from backend.xlsx_reader import inspect_workbook, render_sheets
|
||||
|
||||
sheets = render_sheets(file_path)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": sheets[0]["html"] if sheets else "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# Macros are NOT lossy for .xlsm: keep_vba re-serializes them
|
||||
# (an empty LOSSY probe is what makes the save gate pass).
|
||||
"xlsx_lossy_features": [],
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
|
||||
# === Legacy/ODF spreadsheets (.xls, .ods): read-only table view ===
|
||||
if ext in (".xls", ".ods"):
|
||||
try:
|
||||
from backend.xlsx_reader import render_legacy_workbook
|
||||
|
||||
sheets = render_legacy_workbook(file_path, ext)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": sheets[0]["html"] if sheets else "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_readonly": True,
|
||||
"xlsx_sheets": sheets,
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Spreadsheet read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading spreadsheet: {e!s}")
|
||||
|
||||
# === CSV: spreadsheet-style table (same shape as the xlsx viewer) ===
|
||||
if ext == ".csv":
|
||||
from backend.xlsx_reader import render_csv_table
|
||||
|
||||
html = render_csv_table(raw)
|
||||
return {
|
||||
"vault": vault_name, "path": path,
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
"html": html, "raw_length": len(raw), "extension": ext,
|
||||
"is_markdown": False, "is_csv": True,
|
||||
# #156-A12 — same stale-write guard as the workbooks.
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
# === JSON: syntax-highlighted display ===
|
||||
if ext == ".json":
|
||||
import json as json_mod
|
||||
try:
|
||||
parsed = json_mod.loads(raw)
|
||||
formatted = json_mod.dumps(parsed, indent=2, ensure_ascii=False)
|
||||
except json_mod.JSONDecodeError:
|
||||
formatted = raw
|
||||
html = f"<pre class='json-viewer'><code>{html_mod.escape(formatted)}</code></pre>"
|
||||
return {
|
||||
"vault": vault_name, "path": path,
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
"html": html, "raw_length": len(raw), "extension": ext,
|
||||
"is_markdown": False, "is_json": True,
|
||||
}
|
||||
|
||||
# === Excalidraw .excalidraw.md (Obsidian plugin format) ===
|
||||
if path.lower().endswith(".excalidraw.md"):
|
||||
import re as re_mod
|
||||
raw_lower = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
# Check for excalidraw-plugin in frontmatter or body
|
||||
if "excalidraw-plugin:" in raw_lower:
|
||||
# Extract compressed JSON block
|
||||
match = re_mod.search(r'```compressed-json\n(.*?)\n```', raw_lower, re_mod.DOTALL)
|
||||
if match:
|
||||
compressed = match.group(1).strip()
|
||||
return {
|
||||
"vault": vault_name, "path": path,
|
||||
"title": file_path.name.replace(".excalidraw.md", ""),
|
||||
"tags": [], "frontmatter": {},
|
||||
"html": "", "raw_length": len(raw_lower),
|
||||
"extension": ".excalidraw.md",
|
||||
"is_markdown": False,
|
||||
"is_excalidraw": True,
|
||||
"excalidraw_data_compressed": compressed,
|
||||
}
|
||||
# Fallback: treat as regular markdown
|
||||
raw = raw_lower
|
||||
if ext == ".excalidraw":
|
||||
import json as json_mod
|
||||
try:
|
||||
parsed = json_mod.loads(raw)
|
||||
except json_mod.JSONDecodeError:
|
||||
parsed = None
|
||||
if parsed and parsed.get("type") == "excalidraw":
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": parsed.get("appState", {}).get("name") or file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": len(raw),
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_excalidraw": True,
|
||||
"excalidraw_data": {
|
||||
"elements": parsed.get("elements", []),
|
||||
"appState": parsed.get("appState", {}),
|
||||
"files": parsed.get("files", {}),
|
||||
},
|
||||
}
|
||||
else:
|
||||
# Not a valid Excalidraw file — fall through to text viewer
|
||||
pass
|
||||
|
||||
# === Plain text / other readable files ===
|
||||
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
|
||||
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
|
||||
".xml", ".rst", ".tex", ".sql", ".conf", ".env"}
|
||||
if ext in TEXT_EXTENSIONS or ext == ".md":
|
||||
pass # handled below or by markdown section
|
||||
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
|
||||
# Extract metadata using shared indexer logic
|
||||
tags = _extract_tags(post)
|
||||
|
||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
||||
html_content = _render_markdown(post.content, vault_name, file_path)
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": str(title),
|
||||
"tags": tags,
|
||||
"frontmatter": dict(post.metadata) if post.metadata else {},
|
||||
"html": html_content,
|
||||
"raw_length": len(raw),
|
||||
"extension": ext,
|
||||
"is_markdown": True,
|
||||
}
|
||||
else:
|
||||
# Non-markdown: wrap in syntax-highlighted code block
|
||||
lang = EXT_TO_LANG.get(ext, "")
|
||||
if not lang:
|
||||
# Fichiers sans extension usuels (Dockerfile, Makefile, etc.)
|
||||
NAME_TO_LANG = {
|
||||
"dockerfile": "dockerfile", "makefile": "makefile",
|
||||
"cmakelists.txt": "cmake", "jenkinsfile": "groovy",
|
||||
"vagrantfile": "ruby", "rakefile": "ruby", "gemfile": "ruby",
|
||||
"procfile": "plaintext", "bashrc": "bash", "bash_profile": "bash",
|
||||
"zshrc": "bash", "profile": "bash", "gitignore": "plaintext",
|
||||
}
|
||||
lang = NAME_TO_LANG.get(file_path.name.lower(), "plaintext")
|
||||
escaped = html_mod.escape(raw)
|
||||
html_content = f'<pre><code class="language-{lang}">{escaped}</code></pre>'
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html_content,
|
||||
"raw_length": len(raw),
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
"""File & directory mutation endpoints (ROADMAP #85, tranche 6b).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``PUT/DELETE/PATCH/POST /api/file/*``,
|
||||
``/api/directory/*``, ``/api/move/*``, ``/api/vault/*/batch-upload``),
|
||||
mêmes modèles de requête/réponse (déménagés dans :mod:`backend.schemas`),
|
||||
mêmes dépendances d'authentification et mêmes effets de bord (audit, index
|
||||
incrémental, SSE, webhooks, plugins, historique).
|
||||
|
||||
La logique métier vit déjà dans :mod:`backend.services.mutations`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.audit import log_file_delete, log_file_save
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import (
|
||||
remove_recent,
|
||||
update_bookmarks_after_rename,
|
||||
update_history_after_rename,
|
||||
)
|
||||
from backend.indexer import handle_file_move, remove_single_file, update_single_file
|
||||
from backend.schemas import (
|
||||
BatchUploadRequest,
|
||||
BatchUploadResponse,
|
||||
DirectoryCreateRequest,
|
||||
DirectoryCreateResponse,
|
||||
DirectoryDeleteResponse,
|
||||
DirectoryRenameRequest,
|
||||
DirectoryRenameResponse,
|
||||
FileCreateRequest,
|
||||
FileCreateResponse,
|
||||
FileDeleteResponse,
|
||||
FileMoveRequest,
|
||||
FileMoveResponse,
|
||||
FileRenameRequest,
|
||||
FileRenameResponse,
|
||||
FileSaveResponse,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
batch_upload_files as service_batch_upload_files,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
create_directory as service_create_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
create_file as service_create_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
delete_directory as service_delete_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
delete_file as service_delete_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
edit_file as service_edit_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
edit_xlsx_cells as service_edit_xlsx_cells,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
move_path as service_move_path,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_structure as service_mutate_xlsx_structure,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_style as service_mutate_xlsx_style,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_file as service_rename_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
save_csv_cells as service_save_csv_cells,
|
||||
)
|
||||
from backend.share import update_shares_after_rename
|
||||
from backend.sse import sse_manager
|
||||
from backend.webhooks import dispatch_webhooks
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["files"])
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
|
||||
async def api_file_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
body: dict = Body(...),
|
||||
backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Save (overwrite) a file's content.
|
||||
|
||||
Expects a JSON body with a ``content`` key containing the new text.
|
||||
The path is validated against traversal attacks before writing.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
body: JSON body with ``content`` string.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
content = body.get("content", "")
|
||||
result = service_edit_file(vault_name, path, content, backup=backup)
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"sheet": str, "cells": {"A1": value}, '
|
||||
'"allow_formula": false, "force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply cell edits to an .xlsx workbook.
|
||||
|
||||
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
|
||||
scalar values, max 500 per request) plus two optional boolean flags:
|
||||
|
||||
* ``allow_formula`` — keep values starting with ``=``/``@`` as real
|
||||
formulas. Off by default (#153 A4): such a value is stored as text so a
|
||||
later Excel session cannot execute it (DDE).
|
||||
* ``force`` — write a workbook carrying features openpyxl cannot re-serialize
|
||||
(slicers, form controls, connections, custom XML, signature, cached formula
|
||||
results). Without it the call fails **409** ``xlsx_lossy_content`` and the
|
||||
client asks the user to confirm (#153 A1).
|
||||
* ``if_match`` — revision token returned by the read (#156-A12). When it no
|
||||
longer matches the file on disk the write is refused with **409**
|
||||
``conflict`` (``reason=stale_revision``) instead of overwriting a change
|
||||
made by another writer. Omitted: last writer wins (curl, AI tools).
|
||||
|
||||
A backup is created before the workbook is rewritten, and the new archive
|
||||
swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl
|
||||
round-trip and the per-file lock wait (#153 A3) then run in the threadpool
|
||||
instead of blocking the event loop.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
sheet = body.get("sheet")
|
||||
cells = body.get("cells")
|
||||
if not isinstance(sheet, str) or not sheet:
|
||||
raise HTTPException(status_code=400, detail="Feuille manquante")
|
||||
if not isinstance(cells, dict) or not cells or len(cells) > 500:
|
||||
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
|
||||
for ref, value in cells.items():
|
||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
||||
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
||||
flags: dict[str, bool] = {}
|
||||
for name in ("allow_formula", "force"):
|
||||
raw = body.get(name, False)
|
||||
if not isinstance(raw, bool):
|
||||
raise HTTPException(status_code=400, detail=f"Flag invalide: {name}")
|
||||
flags[name] = raw
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_edit_xlsx_cells(
|
||||
vault_name, path, sheet, cells, expected_revision=if_match, **flags
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
|
||||
def api_file_csv_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .csv file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"cells": {"A1": value}, "if_match": str} — A1-addressed text '
|
||||
'edits (#153 A16). `if_match` is the revision token of the read '
|
||||
'(#156-A12): a stale token fails with 409 `conflict`.'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
|
||||
|
||||
The grid is re-parsed with :mod:`csv`, patched and re-serialized
|
||||
(RFC 4180 quoting). References beyond the extent grow the grid. Values
|
||||
are stored verbatim as text — a CSV has no formula engine.
|
||||
|
||||
``if_match`` (optional, #156-A12) is the revision the client read: when the
|
||||
file changed in the meantime the write is refused with **409** ``conflict``.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
cells = body.get("cells")
|
||||
if not isinstance(cells, dict) or not cells or len(cells) > 500:
|
||||
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
|
||||
for ref, value in cells.items():
|
||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
||||
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
||||
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_save_csv_cells(vault_name, path, cells, expected_revision=if_match)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_structure(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"actions": [{"op": "sheet_add", "name": "X"}, '
|
||||
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply structural changes to an .xlsx workbook (#153 A14).
|
||||
|
||||
``actions`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``sheet_add`` (``name``, optional ``at`` 0-based), ``sheet_rename``
|
||||
(``from``/``to``), ``sheet_delete`` (refused on the last sheet),
|
||||
``sheet_duplicate`` (``name``/``as``) and ``row_insert``/``row_delete``/
|
||||
``col_insert``/``col_delete`` (``sheet``, 1-based ``at``, ``count``).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced. The optional
|
||||
``if_match`` revision (#156-A12) refuses a structural rewrite on a file that
|
||||
changed since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx`` file.
|
||||
body: JSON body with ``actions`` (1 to 50) and optional ``force``.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
actions = body.get("actions")
|
||||
if not isinstance(actions, list) or not actions or len(actions) > 50:
|
||||
raise HTTPException(status_code=400, detail="Actions invalides (1 à 50 par requête)")
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_structure(
|
||||
vault_name, path, actions, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(actions),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/style", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"ops": [{"op": "cell", "sheet": "X", "range": "A1:B2", '
|
||||
'"style": {"bold": true, "fill_color": "#ffe08a"}}], '
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Write formatting on an .xlsx/.xlsm workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
|
||||
``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``,
|
||||
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
|
||||
and ``freeze`` (``cell``, empty to release).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced; the optional
|
||||
``if_match`` revision (#156-A12) refuses a rewrite on a file that changed
|
||||
since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx``/``.xlsm`` file.
|
||||
body: JSON body with ``ops`` (1 to 50) and optional ``force``.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
ops = body.get("ops")
|
||||
if not isinstance(ops, list) or not ops or len(ops) > 50:
|
||||
raise HTTPException(status_code=400, detail="Ops invalides (1 à 50 par requête)")
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_style(
|
||||
vault_name, path, ops, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(ops),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Delete a file from the vault.
|
||||
|
||||
The path is validated against traversal attacks before deletion.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileDeleteResponse`` confirming the deletion.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_delete_file(vault_name, path)
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||
|
||||
# Update index
|
||||
await remove_single_file(vault_name, path)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_deleted", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
})
|
||||
|
||||
from backend.plugins import emit_file_deleted
|
||||
emit_file_deleted(vault_name, path)
|
||||
|
||||
# Remove from recent files
|
||||
remove_recent(current_user["username"], vault_name, path)
|
||||
|
||||
# Dispatch webhooks
|
||||
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
|
||||
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"]}
|
||||
|
||||
|
||||
@router.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse)
|
||||
async def api_directory_create(
|
||||
vault_name: str,
|
||||
body: DirectoryCreateRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a new directory in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with directory path.
|
||||
|
||||
Returns:
|
||||
DirectoryCreateResponse confirming creation.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_create_directory(vault_name, body.path)
|
||||
|
||||
# Update path_index with the new directory
|
||||
from backend.indexer import _index_lock
|
||||
from backend.indexer import path_index as _path_idx
|
||||
with _index_lock:
|
||||
if vault_name not in _path_idx:
|
||||
_path_idx[vault_name] = []
|
||||
existing = {p["path"] for p in _path_idx[vault_name]}
|
||||
# Build all parent segments
|
||||
parts = body.path.split("/")
|
||||
for i in range(1, len(parts) + 1):
|
||||
seg_path = "/".join(parts[:i])
|
||||
if seg_path and seg_path not in existing:
|
||||
existing.add(seg_path)
|
||||
_path_idx[vault_name].append({
|
||||
"path": seg_path,
|
||||
"name": parts[i - 1],
|
||||
"type": "directory",
|
||||
})
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_created", {
|
||||
"vault": vault_name,
|
||||
"path": result["path"],
|
||||
})
|
||||
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
return {"success": True, "path": result["path"]}
|
||||
|
||||
|
||||
@router.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
|
||||
async def api_directory_rename(
|
||||
vault_name: str,
|
||||
body: DirectoryRenameRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Rename a directory in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with current path and new name.
|
||||
|
||||
Returns:
|
||||
DirectoryRenameResponse with old and new paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_rename_directory(vault_name, body.path, body.new_name)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# Update index for all files in the directory
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_renamed", {
|
||||
"vault": vault_name,
|
||||
"old_path": old_path_str,
|
||||
"new_path": new_path_str,
|
||||
})
|
||||
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||
|
||||
|
||||
@router.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
|
||||
async def api_directory_delete(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to directory"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Delete a directory and all its contents from a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative directory path within the vault.
|
||||
|
||||
Returns:
|
||||
DirectoryDeleteResponse with count of deleted files.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_delete_directory(vault_name, path, recursive=True)
|
||||
file_count = result["deleted_count"]
|
||||
|
||||
# Update index
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_deleted", {
|
||||
"vault": vault_name,
|
||||
"path": result["path"],
|
||||
"deleted_count": file_count,
|
||||
})
|
||||
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
return {"success": True, "deleted_count": file_count}
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}", response_model=FileCreateResponse)
|
||||
async def api_file_create(
|
||||
vault_name: str,
|
||||
body: FileCreateRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a new file in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with file path and initial content.
|
||||
|
||||
Returns:
|
||||
FileCreateResponse confirming creation.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_create_file(vault_name, body.path, body.content)
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, result["path"])
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_created", {
|
||||
"vault": vault_name,
|
||||
"path": result["path"],
|
||||
})
|
||||
await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]})
|
||||
from backend.plugins import emit_file_created
|
||||
emit_file_created(vault_name, result["path"])
|
||||
|
||||
return {"success": True, "path": result["path"]}
|
||||
|
||||
|
||||
@router.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse)
|
||||
async def api_batch_upload(
|
||||
vault_name: str,
|
||||
body: BatchUploadRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Upload multiple files and directories (recursively) into a vault.
|
||||
|
||||
Accepts base64 encoded or plain text files with relative directory paths.
|
||||
Creates missing parent folders safely.
|
||||
|
||||
Args:
|
||||
vault_name: Target vault name.
|
||||
body: BatchUploadRequest with target_dir and files list.
|
||||
|
||||
Returns:
|
||||
BatchUploadResponse with summary of uploaded files and errors.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
import base64
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for f in body.files:
|
||||
if f.is_dir:
|
||||
items.append({"path": f.path, "is_dir": True})
|
||||
continue
|
||||
|
||||
raw_bytes = b""
|
||||
if f.content is not None:
|
||||
# Check if content is base64 encoded data URI or raw base64
|
||||
content_str = f.content
|
||||
if content_str.startswith("data:") and ";base64," in content_str:
|
||||
content_str = content_str.split(";base64,", 1)[1]
|
||||
try:
|
||||
raw_bytes = base64.b64decode(content_str)
|
||||
except Exception:
|
||||
# Fallback to utf-8 text encoding
|
||||
raw_bytes = f.content.encode("utf-8")
|
||||
|
||||
items.append({"path": f.path, "content": raw_bytes, "is_dir": False})
|
||||
|
||||
result = service_batch_upload_files(
|
||||
vault_name,
|
||||
body.target_dir,
|
||||
items,
|
||||
overwrite=body.overwrite,
|
||||
)
|
||||
|
||||
# Update index and SSE notifications for uploaded files
|
||||
for path in result["uploaded"]:
|
||||
try:
|
||||
await update_single_file(vault_name, path)
|
||||
await sse_manager.broadcast("file_created", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
})
|
||||
await dispatch_webhooks("file_created", {"vault": vault_name, "path": path})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to post-process upload of {path}: {e}")
|
||||
|
||||
# SSE notification for tree refresh
|
||||
if result["uploaded"] or result["created_dirs"]:
|
||||
await sse_manager.broadcast("tree_updated", {
|
||||
"vault": vault_name,
|
||||
"target_dir": result["target_dir"],
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
|
||||
async def api_file_rename(
|
||||
vault_name: str,
|
||||
body: FileRenameRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Rename a file in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with current path and new name.
|
||||
|
||||
Returns:
|
||||
FileRenameResponse with old and new paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_rename_file(vault_name, body.path, body.new_name)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# Update index
|
||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||
|
||||
# Update bookmarks, history, and shares
|
||||
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
|
||||
update_history_after_rename(vault_name, old_path_str, new_path_str)
|
||||
update_shares_after_rename(vault_name, old_path_str, new_path_str)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_renamed", {
|
||||
"vault": vault_name,
|
||||
"old_path": old_path_str,
|
||||
"new_path": new_path_str,
|
||||
})
|
||||
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||
|
||||
|
||||
@router.post("/api/move/{vault_name}", response_model=FileMoveResponse)
|
||||
async def api_file_move(
|
||||
vault_name: str,
|
||||
body: FileMoveRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Move a file or directory to a different parent directory within the same vault.
|
||||
|
||||
Supports both files and directories. The item keeps its original name;
|
||||
only the parent directory changes.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with source_path and destination_dir.
|
||||
|
||||
Returns:
|
||||
FileMoveResponse with old and new paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_move_path(vault_name, body.source_path, body.destination_dir)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
item_type = result["item_type"]
|
||||
|
||||
# Update index
|
||||
if item_type == "directory":
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
else:
|
||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("item_moved", {
|
||||
"vault": vault_name,
|
||||
"old_path": old_path_str,
|
||||
"new_path": new_path_str,
|
||||
"item_type": item_type,
|
||||
})
|
||||
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
||||
@@ -0,0 +1,143 @@
|
||||
"""System health endpoints (ROADMAP #85, tranche 1).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/health``, ``/api/health/detailed``),
|
||||
même ``response_model`` (:class:`backend.schemas.HealthResponse`), même
|
||||
dépendance admin. Seule différence : la version est lue via
|
||||
:func:`backend.version.get_version` au lieu de ``app.version`` (valeur
|
||||
identique, figée au démarrage depuis le fichier ``VERSION``).
|
||||
|
||||
Note : ``uptime_seconds`` reprend l'expression d'origine
|
||||
(``'_SERVER_START_TIME' in globals()``), qui vaut toujours 0 — le global
|
||||
n'est défini nulle part dans ``backend.main`` (voir ``backend.admin`` qui
|
||||
possède son propre compteur). Ce comportement est préservé tel quel ; le
|
||||
corriger fera l'objet d'une tranche ultérieure avec test dédié.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from backend.auth.middleware import require_admin
|
||||
from backend.indexer import index
|
||||
from backend.schemas import HealthResponse
|
||||
from backend.version import get_git_commit, get_git_describe, get_version
|
||||
|
||||
router = APIRouter(tags=["System"])
|
||||
|
||||
|
||||
@router.get("/api/health", response_model=HealthResponse)
|
||||
async def api_health():
|
||||
"""Health check endpoint for Docker and monitoring.
|
||||
|
||||
Returns:
|
||||
Application status, version, vault count and total file count.
|
||||
"""
|
||||
total_files = sum(len(v["files"]) for v in index.values())
|
||||
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values()) # rough approx
|
||||
import time
|
||||
|
||||
from backend.indexer import _last_full_index_ts
|
||||
# `_SERVER_START_TIME` n'existe dans aucun module (comportement d'origine
|
||||
# préservé : uptime toujours 0 — voir docstring du module).
|
||||
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0 # noqa: F821
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": get_version(),
|
||||
"vaults": len(index),
|
||||
"total_files": total_files,
|
||||
"total_tokens": total_tokens,
|
||||
"last_full_index_ts": _last_full_index_ts,
|
||||
"uptime_seconds": uptime,
|
||||
"git_describe": get_git_describe(),
|
||||
"git_commit": get_git_commit(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/health/detailed", response_model=HealthResponse)
|
||||
async def api_health_detailed(current_user=Depends(require_admin)):
|
||||
"""Detailed health check — admin only.
|
||||
|
||||
Returns enriched metrics including memory, disk, SSE connections, and backup stats.
|
||||
"""
|
||||
|
||||
import psutil
|
||||
|
||||
from backend.admin import _count_active_sessions, _get_disk_stats
|
||||
from backend.indexer import _last_full_index_ts, index
|
||||
|
||||
total_files = sum(len(v["files"]) for v in index.values())
|
||||
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values())
|
||||
import time
|
||||
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0 # noqa: F821 — voir ci-dessus
|
||||
|
||||
# Memory
|
||||
vm = psutil.virtual_memory()
|
||||
mem_used_mb = round(vm.used / (1024 ** 2), 1)
|
||||
mem_total_mb = round(vm.total / (1024 ** 2), 1)
|
||||
mem_pct = round(vm.percent, 1)
|
||||
|
||||
# CPU
|
||||
cpu_pct = psutil.cpu_percent(interval=None)
|
||||
|
||||
# Disk
|
||||
disk_used_gb, disk_total_gb = _get_disk_stats()
|
||||
disk_free_gb = round(disk_total_gb - disk_used_gb, 2)
|
||||
disk_pct = round((disk_used_gb / disk_total_gb * 100) if disk_total_gb > 0 else 0, 1)
|
||||
|
||||
# SSE connections (approximation)
|
||||
active_sessions = _count_active_sessions()
|
||||
|
||||
# Backups
|
||||
from backend.admin import _scan_backups
|
||||
backup_rows = _scan_backups()
|
||||
total_backups = len(backup_rows)
|
||||
total_backup_size_mb = round(sum(r["size"] for r in backup_rows) / (1024 ** 2), 2)
|
||||
oldest_backup_age_days = 0.0
|
||||
if backup_rows:
|
||||
now_ts = int(time.time())
|
||||
oldest_ts = min(r["timestamp"] for r in backup_rows)
|
||||
oldest_backup_age_days = round((now_ts - oldest_ts) / 86400, 2)
|
||||
|
||||
# Index details
|
||||
index_detail = {}
|
||||
for name, data in index.items():
|
||||
index_detail[name] = {
|
||||
"file_count": len(data["files"]),
|
||||
"tag_count": len(data.get("tags", [])),
|
||||
"token_count_approx": len(data.get("files", [])) * 1000,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": get_version(),
|
||||
"vaults": len(index),
|
||||
"total_files": total_files,
|
||||
"total_tokens": total_tokens,
|
||||
"last_full_index_ts": _last_full_index_ts,
|
||||
"uptime_seconds": uptime,
|
||||
"git_describe": get_git_describe(),
|
||||
"git_commit": get_git_commit(),
|
||||
# Enriched fields
|
||||
"memory": {
|
||||
"used_mb": mem_used_mb,
|
||||
"total_mb": mem_total_mb,
|
||||
"percent": mem_pct,
|
||||
},
|
||||
"cpu": {
|
||||
"percent": cpu_pct,
|
||||
},
|
||||
"disk": {
|
||||
"used_gb": disk_used_gb,
|
||||
"total_gb": disk_total_gb,
|
||||
"free_gb": disk_free_gb,
|
||||
"percent": disk_pct,
|
||||
},
|
||||
"connections": {
|
||||
"active_sse": active_sessions,
|
||||
},
|
||||
"backups": {
|
||||
"total_count": total_backups,
|
||||
"total_size_mb": total_backup_size_mb,
|
||||
"oldest_age_days": oldest_backup_age_days,
|
||||
},
|
||||
"index": index_detail,
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Shared helpers for the file routers (ROADMAP #85, tranche 6a).
|
||||
|
||||
Petites fonctions pures extraites de :mod:`backend.main` sans changement
|
||||
de comportement. Regroupées ici car utilisées par plusieurs routers
|
||||
(``files_read`` aujourd'hui, ``files_media`` / mutations ensuite) :
|
||||
- :func:`content_disposition` — aussi utilisée par ``_stream_file_with_range``
|
||||
(resté dans ``main`` jusqu'à la tranche media).
|
||||
- :func:`media_max_inline_bytes` — aussi utilisée par ``/api/media``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
|
||||
def content_disposition(disposition: str, filename: str) -> str:
|
||||
"""Build a header-safe Content-Disposition value.
|
||||
|
||||
HTTP header values must be ASCII. Unicode filenames are sent per
|
||||
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
|
||||
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
|
||||
the filename contains accented characters (e.g. 'Bière blonde…pdf').
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
|
||||
ext = Path(filename).suffix
|
||||
if ext and not Path(ascii_name).suffix:
|
||||
ascii_name = ascii_name + ext
|
||||
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
|
||||
|
||||
|
||||
def media_max_inline_bytes() -> int:
|
||||
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
|
||||
|
||||
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
|
||||
above the limit are not streamed in the viewer (the UI falls back to the
|
||||
download button), which keeps a single uvicorn worker from being pinned by
|
||||
multi-gigabyte media. Invalid or non-positive values fall back to default.
|
||||
"""
|
||||
default_mb = 500
|
||||
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
|
||||
if not raw:
|
||||
return default_mb * 1024 * 1024
|
||||
try:
|
||||
mb = float(raw)
|
||||
except ValueError:
|
||||
return default_mb * 1024 * 1024
|
||||
if mb <= 0:
|
||||
return default_mb * 1024 * 1024
|
||||
return int(mb * 1024 * 1024)
|
||||
|
||||
|
||||
def stream_file_with_range(file_path: Path, request: Request, media_type: str):
|
||||
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
|
||||
|
||||
Extrait de :mod:`backend.main` (``_stream_file_with_range``) sans
|
||||
changement de comportement. Shared by ``pdf/stream`` and ``/api/media``:
|
||||
a plain :class:`FileResponse` with ``Accept-Ranges: bytes`` when no range
|
||||
is requested, or a :class:`StreamingResponse` (206 Partial Content,
|
||||
64 KiB chunks) for a valid single range. An unsatisfiable range yields
|
||||
``416`` with a ``Content-Range: bytes */size`` header.
|
||||
|
||||
Reads are offloaded to threads so the event loop is never blocked
|
||||
(ASYNC230), matching the previous inline implementation.
|
||||
"""
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
disposition = content_disposition("inline", file_path.name)
|
||||
|
||||
if range_header:
|
||||
# Parse "bytes=start-end" (single range only; multi-range is not used by viewers)
|
||||
m = re.match(r"bytes=(\d*)-(\d*)", range_header)
|
||||
if not m:
|
||||
raise HTTPException(status_code=416,
|
||||
headers={"Content-Range": f"bytes */{file_size}"})
|
||||
start_s, end_s = m.group(1), m.group(2)
|
||||
if start_s == "" and end_s == "":
|
||||
raise HTTPException(status_code=416,
|
||||
headers={"Content-Range": f"bytes */{file_size}"})
|
||||
if start_s == "":
|
||||
# suffix range: last N bytes
|
||||
length = min(int(end_s), file_size)
|
||||
start = file_size - length
|
||||
end = file_size - 1
|
||||
else:
|
||||
start = int(start_s)
|
||||
end = int(end_s) if end_s else file_size - 1
|
||||
end = min(end, file_size - 1)
|
||||
if start > end or start >= file_size:
|
||||
raise HTTPException(status_code=416,
|
||||
headers={"Content-Range": f"bytes */{file_size}"})
|
||||
|
||||
chunk_size = end - start + 1
|
||||
|
||||
async def _partial():
|
||||
f = await asyncio.to_thread(open, str(file_path), "rb")
|
||||
try:
|
||||
await asyncio.to_thread(f.seek, start)
|
||||
remaining = chunk_size
|
||||
while remaining > 0:
|
||||
data = await asyncio.to_thread(f.read, min(64 * 1024, remaining))
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
finally:
|
||||
await asyncio.to_thread(f.close)
|
||||
|
||||
return StreamingResponse(
|
||||
_partial(),
|
||||
status_code=206,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(chunk_size),
|
||||
"Content-Disposition": disposition,
|
||||
},
|
||||
)
|
||||
|
||||
return FileResponse(str(file_path), media_type=media_type, headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": disposition})
|
||||
@@ -0,0 +1,160 @@
|
||||
"""History endpoints — recent, bookmarks, saved searches (ROADMAP #85, tranche 8).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins, mêmes modèles (``BookmarkToggleRequest``
|
||||
déménagé dans :mod:`backend.schemas`), mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
|
||||
et :mod:`backend.services.backups` (pass-through).
|
||||
- ``_load_config`` vient de :mod:`backend.routers.config`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import get_bookmarks, toggle_bookmark
|
||||
from backend.indexer import find_file_in_index, get_vault_data, update_single_file
|
||||
from backend.routers.config import _load_config
|
||||
from backend.saved_searches import delete_saved, get_saved, save_search
|
||||
from backend.schemas import (
|
||||
BookmarksResponse,
|
||||
BookmarkToggleRequest,
|
||||
BookmarkToggleResponse,
|
||||
RecentResponse,
|
||||
SavedSearch,
|
||||
StatusResponse,
|
||||
)
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.recent import humanize_mtime, list_recent
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["Bookmarks"])
|
||||
|
||||
|
||||
@router.get("/api/recent", response_model=RecentResponse)
|
||||
async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
|
||||
config = _load_config()
|
||||
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
|
||||
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
|
||||
return list_recent(
|
||||
username,
|
||||
user_vaults,
|
||||
vault=vault,
|
||||
limit=actual_limit,
|
||||
mode=mode or "opened",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/bookmarks", response_model=BookmarksResponse)
|
||||
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
|
||||
if not username:
|
||||
return {"files": []}
|
||||
|
||||
history = get_bookmarks(username, vault_filter=vault)
|
||||
files_resp = []
|
||||
for item in history:
|
||||
v_name = item["vault"]
|
||||
if "*" not in user_vaults and v_name not in user_vaults:
|
||||
continue
|
||||
|
||||
# Find in index to get metadata
|
||||
f_idx = find_file_in_index(item["path"], v_name)
|
||||
if f_idx:
|
||||
files_resp.append({
|
||||
"path": f_idx["path"],
|
||||
"title": f_idx.get("title") or item["path"].split("/")[-1],
|
||||
"vault": v_name,
|
||||
"mtime": item["bookmarked_at"],
|
||||
"mtime_human": humanize_mtime(item["bookmarked_at"]),
|
||||
"size_bytes": f_idx.get("size", 0),
|
||||
"tags": [f"#{t}" for t in f_idx.get("tags", [])][:5],
|
||||
"bookmarked": True
|
||||
})
|
||||
else:
|
||||
files_resp.append({
|
||||
"path": item["path"],
|
||||
"title": item.get("title") or item["path"].split("/")[-1],
|
||||
"vault": v_name,
|
||||
"mtime": item["bookmarked_at"],
|
||||
"mtime_human": humanize_mtime(item["bookmarked_at"]),
|
||||
"tags": [],
|
||||
"bookmarked": True
|
||||
})
|
||||
return {
|
||||
"files": files_resp,
|
||||
"total": len(files_resp)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse)
|
||||
async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# Check vault access
|
||||
if not check_vault_access(req.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="Access denied to vault")
|
||||
|
||||
is_now_bookmarked = toggle_bookmark(username, req.vault, req.path, req.title or "")
|
||||
|
||||
# Update the file's YAML frontmatter: favoris: true/false
|
||||
vault_data = get_vault_data(req.vault)
|
||||
if vault_data:
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), req.path)
|
||||
if file_path.exists() and file_path.suffix == ".md":
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
post = frontmatter.loads(raw)
|
||||
if is_now_bookmarked:
|
||||
post.metadata["favoris"] = True
|
||||
elif "favoris" in post.metadata:
|
||||
del post.metadata["favoris"]
|
||||
new_raw = frontmatter.dumps(post)
|
||||
create_backup(file_path, req.vault, req.path)
|
||||
file_path.write_text(new_raw, encoding="utf-8")
|
||||
await update_single_file(req.vault, str(file_path))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update favoris metadata on {req.vault}/{req.path}: {e}")
|
||||
|
||||
return {"bookmarked": is_now_bookmarked}
|
||||
|
||||
|
||||
@router.get("/api/saved-searches", response_model=list[SavedSearch])
|
||||
async def api_saved_searches(current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(401)
|
||||
return get_saved(username)
|
||||
|
||||
|
||||
@router.post("/api/saved-searches", response_model=SavedSearch)
|
||||
async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(401)
|
||||
return save_search(username, body)
|
||||
|
||||
|
||||
@router.delete("/api/saved-searches/{search_id}", response_model=StatusResponse)
|
||||
async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(401)
|
||||
if not delete_saved(username, search_id):
|
||||
raise HTTPException(404, "Not found")
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Real-time endpoints — SSE stream & collaboration WebSocket (ROADMAP #85, tranche 9).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/events``,
|
||||
``/ws/collab/{vault}/{path}``), même authentification (Depend pour le SSE,
|
||||
manuelle pour le WebSocket — les ``Depends`` FastAPI ne s'exécutent pas sur
|
||||
les routes WebSocket).
|
||||
|
||||
Pas de tags déclarés : assignation par chemin via
|
||||
``openapi_docs.tag_for_path`` comme avant (``/api/events`` → System).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.collab import authenticate_websocket, collab_manager
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
from backend.sse import sse_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/events",
|
||||
response_class=StreamingResponse,
|
||||
responses={200: {"content": {"text/event-stream": {}}, "description": "Server-Sent Events stream"}},
|
||||
)
|
||||
async def api_events(current_user=Depends(require_auth)):
|
||||
"""SSE stream for real-time index update notifications.
|
||||
|
||||
Sends keepalive comments every 30s. Events:
|
||||
- ``index_updated``: partial index change (file create/modify/delete/move)
|
||||
- ``index_reloaded``: full re-index completed
|
||||
- ``vault_added``: new vault added dynamically
|
||||
- ``vault_removed``: vault removed dynamically
|
||||
"""
|
||||
queue = await sse_manager.connect()
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
# Send initial connection event
|
||||
yield f"event: connected\ndata: {_json.dumps({'sse_clients': sse_manager.client_count})}\n\n"
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
# Keepalive comment
|
||||
yield ": keepalive\n\n"
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
finally:
|
||||
sse_manager.disconnect(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws/collab/{vault_name}/{path:path}")
|
||||
async def collab_websocket(websocket: WebSocket, vault_name: str, path: str):
|
||||
"""Real-time collaborative editing over WebSocket (ROADMAP #62).
|
||||
|
||||
One *room* is created per ``vault::path``; all clients editing the same
|
||||
file share Yjs/CRDT updates, awareness (cursors/selection) and a debounced
|
||||
server-side persistence of the markdown content.
|
||||
|
||||
Authentication is performed manually (FastAPI ``Depends`` do not run for
|
||||
WebSocket routes) and vault access is enforced per connection.
|
||||
"""
|
||||
from backend.services.errors import ServiceError
|
||||
|
||||
user = authenticate_websocket(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
if not check_vault_access(vault_name, user):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
try:
|
||||
vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
except ServiceError:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
await collab_manager.connect(websocket, vault_name, path, file_path, user)
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Search, suggest, graph & index-reload endpoints (ROADMAP #85, tranche 5).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins, mêmes modèles de réponse (déménagés dans
|
||||
:mod:`backend.schemas`), mêmes dépendances d'authentification. La logique
|
||||
métier vit déjà dans :mod:`backend.services.search`,
|
||||
:mod:`backend.search`, :mod:`backend.services.graph` et
|
||||
:mod:`backend.services.mutations`.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- Le pool ``_search_executor`` de ``main`` vit désormais dans
|
||||
:mod:`backend.search_executor` (même dimensionnement, même cycle de vie
|
||||
géré par le lifespan de ``main``) : accès via
|
||||
:func:`get_search_executor`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.audit import log_file_save
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.indexer import get_vault_data, reload_index, update_single_file
|
||||
from backend.schemas import (
|
||||
AdvancedSearchResponse,
|
||||
GraphResponse,
|
||||
ReloadResponse,
|
||||
ReplaceResponse,
|
||||
SearchResponse,
|
||||
SuggestResponse,
|
||||
TagsResponse,
|
||||
TagSuggestResponse,
|
||||
TreeSearchResponse,
|
||||
VaultPathsResponse,
|
||||
VaultStatsResponse,
|
||||
)
|
||||
from backend.search import suggest_tags, suggest_titles
|
||||
from backend.search_executor import get_search_executor
|
||||
from backend.services.graph import get_graph as service_get_graph
|
||||
from backend.services.mutations import (
|
||||
replace_in_files as service_replace_in_files,
|
||||
)
|
||||
from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults
|
||||
from backend.services.search import list_tags as service_list_tags
|
||||
from backend.sse import sse_manager
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search", response_model=SearchResponse)
|
||||
async def api_search(
|
||||
q: str = Query("", description="Search query"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
tag: str | None = Query(None, description="Tag filter"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Results per page"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Full-text search across vaults with relevance scoring.
|
||||
|
||||
Supports combining free-text queries with tag filters.
|
||||
Results are ranked by a multi-factor scoring algorithm.
|
||||
Pagination via ``limit`` and ``offset`` (defaults preserve backward compat).
|
||||
|
||||
Args:
|
||||
q: Free-text search string.
|
||||
vault: Vault name or ``"all"`` to search everywhere.
|
||||
tag: Comma-separated tag names to require.
|
||||
limit: Max results per page (1–200).
|
||||
offset: Pagination offset.
|
||||
|
||||
Returns:
|
||||
``SearchResponse`` with ranked results and snippets.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
# Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and
|
||||
# paginate in the shared service so routes and tools share the same logic.
|
||||
return await loop.run_in_executor(
|
||||
get_search_executor(),
|
||||
partial(search_vaults, q, vault, tag, limit, offset),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/tags", response_model=TagsResponse)
|
||||
async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
|
||||
"""Return all unique tags with occurrence counts.
|
||||
|
||||
Args:
|
||||
vault: Optional vault name to restrict tag aggregation.
|
||||
|
||||
Returns:
|
||||
``TagsResponse`` with tags sorted by descending count.
|
||||
"""
|
||||
return {"vault_filter": vault, "tags": service_list_tags(vault)}
|
||||
|
||||
|
||||
@router.get("/api/tree-search", response_model=TreeSearchResponse)
|
||||
async def api_tree_search(
|
||||
q: str = Query("", description="Search query"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Search for files and directories in the tree structure using pre-built index.
|
||||
|
||||
Uses the in-memory path index for instant filtering without filesystem access.
|
||||
|
||||
Args:
|
||||
q: Search string to match against file/directory paths.
|
||||
vault: Vault name or "all" to search everywhere.
|
||||
|
||||
Returns:
|
||||
``TreeSearchResponse`` with matching paths.
|
||||
"""
|
||||
return search_paths(q, vault)
|
||||
|
||||
|
||||
@router.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse)
|
||||
async def api_vault_paths(
|
||||
vault_name: str,
|
||||
limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a flat list of every indexed file and directory in a vault.
|
||||
|
||||
Used by the AI assistant ``@`` mention menu to filter paths instantly on
|
||||
the client (one request instead of one per keystroke).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
limit: Maximum number of entries returned.
|
||||
|
||||
Returns:
|
||||
``VaultPathsResponse`` with the vault's indexed paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return list_paths(vault_name, limit=limit)
|
||||
|
||||
|
||||
@router.get("/api/search/advanced", response_model=AdvancedSearchResponse)
|
||||
async def api_advanced_search(
|
||||
q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
tag: str | None = Query(None, description="Comma-separated tag filter"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Results per page"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||
sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"),
|
||||
case_sensitive: bool = Query(False, description="Match case"),
|
||||
whole_word: bool = Query(False, description="Match whole words only"),
|
||||
regex: bool = Query(False, description="Treat query as regex"),
|
||||
include_paths: str | None = Query(None, description="Comma-separated glob patterns to include"),
|
||||
exclude_paths: str | None = Query(None, description="Comma-separated glob patterns to exclude"),
|
||||
created: str | None = Query(None, description="Created date filter (>date, <date, date..date)"),
|
||||
modified: str | None = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
|
||||
size: str | None = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
|
||||
semantic: bool = Query(False, description="Fuse TF-IDF with semantic embeddings (RRF)"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
|
||||
|
||||
Supports advanced query operators:
|
||||
- ``tag:<name>`` or ``#<name>`` — filter by tag
|
||||
- ``vault:<name>`` — filter by vault
|
||||
- ``title:<text>`` — filter by title substring
|
||||
- ``path:<text>`` — filter by path substring
|
||||
- ``ext:<type>`` — filter by file extension
|
||||
- ``created:>2024-01-01`` — filter by creation date
|
||||
- ``modified:<7d`` or ``modified:2024-01-01..2024-06-01`` — filter by modification date
|
||||
- ``size:>1MB`` or ``size:100KB..1MB`` — filter by file size
|
||||
- Remaining text is scored using TF-IDF with accent normalization.
|
||||
- Toggles: case_sensitive, whole_word, regex
|
||||
- Path filters: include_paths, exclude_paths (glob patterns)
|
||||
- ``semantic=true`` — fuse the TF-IDF ranking with the semantic (embedding)
|
||||
ranking via Reciprocal Rank Fusion and expose ``semantic_score`` per result.
|
||||
|
||||
Results include ``<mark>``-highlighted snippets and faceted tag/vault counts.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag,
|
||||
limit=limit, offset=offset, sort=sort,
|
||||
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex,
|
||||
include_paths=include_paths, exclude_paths=exclude_paths,
|
||||
created=created, modified=modified, size=size, semantic=semantic)
|
||||
try:
|
||||
return await loop.run_in_executor(get_search_executor(), search_fn)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/api/search/replace", response_model=ReplaceResponse)
|
||||
async def api_search_replace(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Find and replace across vault files."""
|
||||
query = body.get("query", "")
|
||||
replacement = body.get("replacement", "")
|
||||
vault_filter = body.get("vault", "all")
|
||||
case_sensitive = body.get("case_sensitive", False)
|
||||
whole_word = body.get("whole_word", False)
|
||||
regex_mode = body.get("regex", False)
|
||||
include_paths = body.get("include_paths")
|
||||
exclude_paths = body.get("exclude_paths")
|
||||
replace_all = body.get("replace_all", False)
|
||||
dry_run = body.get("dry_run", not replace_all)
|
||||
|
||||
if not query:
|
||||
raise HTTPException(400, "Query is required")
|
||||
|
||||
result = service_replace_in_files(
|
||||
query,
|
||||
replacement,
|
||||
vault=vault_filter,
|
||||
case_sensitive=case_sensitive,
|
||||
whole_word=whole_word,
|
||||
regex=regex_mode,
|
||||
include_paths=include_paths,
|
||||
exclude_paths=exclude_paths,
|
||||
replace_all=replace_all,
|
||||
dry_run=dry_run,
|
||||
is_vault_allowed=lambda v: check_vault_access(v, current_user),
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return result
|
||||
|
||||
# Side effects for applied replacements (audit + incremental index).
|
||||
for match in result.get("replaced", []):
|
||||
log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0))
|
||||
vault_data = get_vault_data(match["vault"])
|
||||
if vault_data:
|
||||
abs_path = str(Path(vault_data["path"]) / match["path"])
|
||||
await update_single_file(match["vault"], abs_path)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/suggest", response_model=SuggestResponse)
|
||||
async def api_suggest(
|
||||
q: str = Query("", description="Prefix to search for in file titles"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Used for autocomplete in the search input.
|
||||
|
||||
Args:
|
||||
q: User-typed prefix (minimum 2 characters).
|
||||
vault: Vault name or ``"all"``.
|
||||
limit: Max number of suggestions.
|
||||
|
||||
Returns:
|
||||
``SuggestResponse`` with matching file title suggestions.
|
||||
"""
|
||||
suggestions = suggest_titles(q, vault_filter=vault, limit=limit)
|
||||
return {"query": q, "suggestions": suggestions}
|
||||
|
||||
|
||||
@router.get("/api/tags/suggest", response_model=TagSuggestResponse)
|
||||
async def api_tags_suggest(
|
||||
q: str = Query("", description="Prefix to search for in tags"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Suggest tags matching a prefix (accent-insensitive).
|
||||
|
||||
Used for autocomplete when typing ``tag:`` or ``#`` in the search input.
|
||||
|
||||
Args:
|
||||
q: User-typed prefix (with or without ``#``, minimum 2 characters).
|
||||
vault: Vault name or ``"all"``.
|
||||
limit: Max number of suggestions.
|
||||
|
||||
Returns:
|
||||
``TagSuggestResponse`` with matching tag suggestions and counts.
|
||||
"""
|
||||
suggestions = suggest_tags(q, vault_filter=vault, limit=limit)
|
||||
return {"query": q, "suggestions": suggestions}
|
||||
|
||||
|
||||
@router.get("/api/index/reload", response_model=ReloadResponse)
|
||||
async def api_reload(current_user=Depends(require_admin)):
|
||||
"""Force a full re-index of all configured vaults.
|
||||
|
||||
Returns:
|
||||
``ReloadResponse`` with per-vault file and tag counts.
|
||||
"""
|
||||
stats = await reload_index()
|
||||
await sse_manager.broadcast("index_reloaded", {
|
||||
"vaults": list(stats.keys()),
|
||||
"stats": stats,
|
||||
})
|
||||
return {"status": "ok", "vaults": stats}
|
||||
|
||||
|
||||
@router.get("/api/graph/{vault_name}", response_model=GraphResponse)
|
||||
async def api_graph(
|
||||
vault_name: str,
|
||||
path: str = Query("", description="Relative path to focus on"),
|
||||
depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"),
|
||||
scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"),
|
||||
tag: str = Query("", description="Filter: only show files with this tag"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return graph data (nodes and edges) for a vault or directory.
|
||||
|
||||
Nodes represent files and directories. Edges represent parent-child
|
||||
relationships and wikilinks between markdown files.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative directory path to focus on (empty = root).
|
||||
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
|
||||
scope: 'directory' for subtree, 'full' for entire vault.
|
||||
tag: Optional tag filter (only files with this tag appear).
|
||||
|
||||
Returns:
|
||||
``GraphResponse`` with nodes and edges.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
return service_get_graph(vault_name, path=path, depth=depth, scope=scope, tag=tag)
|
||||
|
||||
|
||||
@router.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse)
|
||||
async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Force a re-index of a single vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to reindex.
|
||||
|
||||
Returns:
|
||||
Dict with vault statistics.
|
||||
"""
|
||||
try:
|
||||
from backend.indexer import reload_single_vault
|
||||
stats = await reload_single_vault(vault_name)
|
||||
await sse_manager.broadcast("vault_reloaded", {
|
||||
"vault": vault_name,
|
||||
"stats": stats,
|
||||
})
|
||||
return {"status": "ok", "vault": vault_name, "stats": stats}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Public share endpoints (ROADMAP #85, tranche 3).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/share/*``, ``/api/shares``,
|
||||
``/s/{token}*``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification (les pages ``/s/*`` restent publiques). La logique
|
||||
métier vit déjà dans :mod:`backend.share`.
|
||||
|
||||
Adaptations strictement équivalentes (pas de changement de comportement) :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` de ``main`` n'étaient que des
|
||||
wrappers directs : appelés ici via :mod:`backend.services.paths` et
|
||||
:mod:`backend.services.backups` (mêmes signatures, mêmes exceptions
|
||||
``ServiceError`` toujours mappées par le handler global de ``main``).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
"""
|
||||
|
||||
import html as html_mod
|
||||
import json as _json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
|
||||
from backend.render import _render_markdown
|
||||
from backend.schemas import ShareModel, StatusResponse
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.share import (
|
||||
create_share,
|
||||
get_share_by_token,
|
||||
list_shares,
|
||||
record_access,
|
||||
revoke_share,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
|
||||
try:
|
||||
from backend.pdf_export import build_pdf_html, generate_pdf
|
||||
except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
||||
generate_pdf = None # type: ignore[assignment]
|
||||
build_pdf_html = None # type: ignore[assignment]
|
||||
|
||||
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
|
||||
|
||||
router = APIRouter(tags=["sharing"])
|
||||
|
||||
|
||||
@router.post("/api/share/{vault_name}", response_model=ShareModel)
|
||||
async def api_share_create(
|
||||
vault_name: str,
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a public share link for a document.
|
||||
|
||||
Also sets ``publish: true`` in the file's YAML frontmatter so the
|
||||
frontend can visually indicate the file is publicly shared.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
path = body.get("path", "")
|
||||
expires = body.get("expires_in_hours")
|
||||
share = create_share(vault_name, path, current_user["username"], expires)
|
||||
share["url"] = f"/s/{share['token']}"
|
||||
|
||||
# Set publish: true in the file's frontmatter
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if vault_data:
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if file_path.exists() and file_path.suffix == ".md":
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
post = frontmatter.loads(raw)
|
||||
if not post.metadata.get("publish"):
|
||||
post.metadata["publish"] = True
|
||||
new_raw = frontmatter.dumps(post)
|
||||
create_backup(file_path, vault_name, path)
|
||||
file_path.write_text(new_raw, encoding="utf-8")
|
||||
await update_single_file(vault_name, str(file_path))
|
||||
logger.info(f"Set publish:true on {vault_name}/{path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set publish metadata on {vault_name}/{path}: {e}")
|
||||
|
||||
return share
|
||||
|
||||
|
||||
@router.get("/api/shares", response_model=list[ShareModel])
|
||||
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
"""List all shares (optionally filtered by vault)."""
|
||||
shares = list_shares(vault)
|
||||
for s in shares:
|
||||
s["url"] = f"/s/{s['token']}"
|
||||
return shares
|
||||
|
||||
|
||||
@router.delete("/api/share/{share_id}", response_model=StatusResponse)
|
||||
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
||||
if not revoke_share(share_id):
|
||||
raise HTTPException(404, "Share not found")
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/s/{token}/pdf",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
|
||||
)
|
||||
async def public_share_pdf_download(token: str):
|
||||
"""Download shared document as real PDF via WeasyPrint."""
|
||||
if generate_pdf is None:
|
||||
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, share["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot read file")
|
||||
record_access(token)
|
||||
raw = redact_file_content(raw, str(file_path))
|
||||
post = parse_markdown_file(raw)
|
||||
ext = file_path.suffix.lower()
|
||||
if ext == ".md":
|
||||
html = _render_markdown(post.content, share["vault"], file_path)
|
||||
else:
|
||||
html = f'<pre style="font-family:monospace;font-size:12px;line-height:1.6;white-space:pre-wrap">{html_mod.escape(raw)}</pre>'
|
||||
title = post.metadata.get("title", file_path.stem)
|
||||
pdf_html = build_pdf_html(html, str(title))
|
||||
pdf_bytes = generate_pdf(pdf_html, str(title))
|
||||
safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document"
|
||||
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
|
||||
|
||||
|
||||
@router.get("/s/{token}/raw", response_class=FileResponse)
|
||||
async def public_share_raw(token: str):
|
||||
"""Download the raw (original) shared document."""
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, share["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
record_access(token)
|
||||
return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/s/{token}", response_class=HTMLResponse)
|
||||
async def public_share_view(request: Request, token: str):
|
||||
"""Public share view — no authentication required."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, share["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot read file")
|
||||
record_access(token)
|
||||
raw = redact_file_content(raw, str(file_path))
|
||||
post = parse_markdown_file(raw)
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
if ext == ".md":
|
||||
html = _render_markdown(post.content, share["vault"], file_path)
|
||||
else:
|
||||
escaped = html_mod.escape(raw)
|
||||
html = f'<pre style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:0.85rem;line-height:1.6"><code>{escaped}</code></pre>'
|
||||
|
||||
title = post.metadata.get("title", file_path.stem)
|
||||
|
||||
# Escape everything user-controlled before embedding in HTML/JS (BUG-022).
|
||||
title_esc = html_mod.escape(str(title))
|
||||
# Neutralise ``</script>`` in the JS string literal too.
|
||||
title_download_js = (
|
||||
_json.dumps(f"{title}.md")
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
)
|
||||
|
||||
# JSON-escape raw content for embedding in HTML, and neutralise ``</script>``.
|
||||
raw_json = (
|
||||
_json.dumps(raw)
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
)
|
||||
fm_html = ""
|
||||
if post.metadata:
|
||||
fm_items = []
|
||||
skip_keys = {"title", "titre"}
|
||||
for k, v in post.metadata.items():
|
||||
if k in skip_keys:
|
||||
continue
|
||||
if isinstance(v, list):
|
||||
v = ", ".join(str(x) for x in v)
|
||||
elif isinstance(v, bool):
|
||||
v = "✓" if v else "✗"
|
||||
elif v is None:
|
||||
v = "—"
|
||||
fm_items.append(
|
||||
f'<div class="fm-row"><span class="fm-key">{html_mod.escape(str(k))}</span>'
|
||||
f'<span class="fm-val">{html_mod.escape(str(v))}</span></div>'
|
||||
)
|
||||
if fm_items:
|
||||
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
|
||||
|
||||
return HTMLResponse(
|
||||
inject_csp_nonce(
|
||||
f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title_esc} — ObsiGate Share</title>
|
||||
<style>
|
||||
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
|
||||
[data-theme="light"] {{ --bg:#f8f9fa; --bg-card:#fff; --text:#1a1a2e; --text-muted:#666; --accent:#4f46e5; --border:#ddd; --banner-bg:#eef2ff; --banner-text:#4338ca; }}
|
||||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.7;min-height:100vh}}
|
||||
.toolbar{{position:sticky;top:0;z-index:10;background:var(--bg-card);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}}
|
||||
.toolbar-title{{font-weight:600;font-size:0.9rem;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.toolbar-btn{{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.8rem;display:flex;align-items:center;gap:5px;transition:all .15s}}
|
||||
.toolbar-btn:hover{{background:var(--accent);color:#fff;border-color:var(--accent)}}
|
||||
.toolbar-btn svg{{width:15px;height:15px;flex-shrink:0}}
|
||||
.toolbar-btn:hover svg{{stroke:#fff}}
|
||||
.share-banner{{background:var(--banner-bg);color:var(--banner-text);padding:6px 16px;font-size:0.8rem;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}}
|
||||
.share-banner svg{{width:14px;height:14px;flex-shrink:0}}
|
||||
.content{{max-width:820px;margin:0 auto;padding:24px 20px 60px}}
|
||||
.content h1{{font-size:1.8rem;margin-bottom:16px;border-bottom:2px solid var(--border);padding-bottom:8px}}
|
||||
.content h2{{font-size:1.4rem;margin:24px 0 12px}}
|
||||
.content h3{{font-size:1.15rem;margin:20px 0 8px}}
|
||||
.content p{{margin:8px 0}}
|
||||
.content pre{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;overflow-x:auto;font-size:0.85rem}}
|
||||
.content code{{font-size:0.9em;background:var(--bg-card);padding:1px 4px;border-radius:3px}}
|
||||
.content pre code{{background:none;padding:0}}
|
||||
.content a{{color:var(--accent)}}.content img{{max-width:100%;border-radius:6px}}
|
||||
.fm-section{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:20px}}
|
||||
.fm-header{{font-weight:600;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}}
|
||||
.fm-body{{display:grid;grid-template-columns:1fr 2fr;gap:4px 12px;font-size:0.85rem}}
|
||||
.fm-row{{display:contents}}
|
||||
.fm-key{{color:var(--accent);font-weight:500}}
|
||||
.fm-val{{color:var(--text);word-break:break-word}}
|
||||
.content blockquote{{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-muted);margin:12px 0}}
|
||||
.content table{{border-collapse:collapse;width:100%;margin:12px 0}}
|
||||
.content th,.content td{{border:1px solid var(--border);padding:8px 12px;text-align:left}}
|
||||
.content th{{background:var(--bg-card)}}
|
||||
@media print{{.toolbar,.share-banner{{display:none}}body{{background:#fff;color:#000}}}}
|
||||
@media(max-width:600px){{.content{{padding:16px 12px 40px}}.toolbar{{gap:4px}}.toolbar-btn{{padding:4px 8px;font-size:0.7rem}}}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="share-banner">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Document partagé via ObsiGate
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-title">{title_esc}</span>
|
||||
<button class="toolbar-btn" data-share-theme title="Thème clair/sombre">
|
||||
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
</button>
|
||||
<button class="toolbar-btn" data-share-md title="Télécharger en Markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
.md
|
||||
</button>
|
||||
<button class="toolbar-btn" data-share-pdf title="Télécharger en PDF">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
<div class="content" id="content">{fm_html}{html}</div>
|
||||
<script id="raw-content" type="text/plain" style="display:none">{raw_json}</script>
|
||||
<script>
|
||||
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
|
||||
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
|
||||
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
|
||||
document.querySelector("[data-share-theme]").addEventListener("click",toggleTheme);
|
||||
document.querySelector("[data-share-md]").addEventListener("click",exportMD);
|
||||
document.querySelector("[data-share-pdf]").addEventListener("click",function(){{location.href=location.pathname+"/pdf"}});
|
||||
</script></body></html>""",
|
||||
request.state.csp_nonce,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Vault management endpoints (ROADMAP #85, tranche 8).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/vaults*``), mêmes modèles de réponse
|
||||
(``VaultInfo`` déménagé dans :mod:`backend.schemas`), mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Le handle du file-watcher vit désormais dans :mod:`backend.watcher_state`
|
||||
(partagé avec le lifespan de ``main``) au lieu du global de ``main``.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.indexer import add_vault_to_index, index, remove_vault_from_index
|
||||
from backend.schemas import VaultActionResponse, VaultInfo, VaultsStatusResponse, VaultStatsResponse
|
||||
from backend.services.vaults import list_accessible_vaults
|
||||
from backend.sse import sse_manager
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
router = APIRouter(tags=["vaults"])
|
||||
|
||||
|
||||
@router.get("/api/vaults", response_model=list[VaultInfo])
|
||||
async def api_vaults(current_user=Depends(require_auth)):
|
||||
"""List configured vaults the user has access to.
|
||||
|
||||
Returns:
|
||||
List of vault summary objects filtered by user permissions.
|
||||
"""
|
||||
return list_accessible_vaults(current_user)
|
||||
|
||||
|
||||
@router.post("/api/vaults/add", response_model=VaultStatsResponse)
|
||||
async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Add a new vault dynamically without restarting.
|
||||
|
||||
Body:
|
||||
name: Display name for the vault.
|
||||
path: Absolute filesystem path to the vault directory.
|
||||
"""
|
||||
name = body.get("name", "").strip()
|
||||
vault_path = body.get("path", "").strip()
|
||||
|
||||
if not name or not vault_path:
|
||||
raise HTTPException(status_code=400, detail="Both 'name' and 'path' are required")
|
||||
|
||||
if name in index:
|
||||
raise HTTPException(status_code=409, detail=f"Vault '{name}' already exists")
|
||||
|
||||
if not Path(vault_path).exists():
|
||||
raise HTTPException(status_code=400, detail=f"Path does not exist: {vault_path}")
|
||||
|
||||
stats = await add_vault_to_index(name, vault_path)
|
||||
|
||||
# Start watching the new vault
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.add_vault(name, vault_path)
|
||||
|
||||
await sse_manager.broadcast("vault_added", {"vault": name, "stats": stats})
|
||||
return {"status": "ok", "vault": name, "stats": stats}
|
||||
|
||||
|
||||
@router.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse)
|
||||
async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Remove a vault from the index and stop watching it.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to remove.
|
||||
"""
|
||||
if vault_name not in index:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
# Stop watching
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.remove_vault(vault_name)
|
||||
|
||||
await remove_vault_from_index(vault_name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
|
||||
return {"status": "ok", "vault": vault_name}
|
||||
|
||||
|
||||
@router.get("/api/vaults/status", response_model=VaultsStatusResponse)
|
||||
async def api_vaults_status(current_user=Depends(require_auth)):
|
||||
"""Detailed status of all vaults including watcher state.
|
||||
|
||||
Returns per-vault: file count, tag count, watching status, vault path.
|
||||
"""
|
||||
watcher = get_watcher()
|
||||
statuses = {}
|
||||
for vname, vdata in index.items():
|
||||
watching = watcher is not None and vname in watcher.observers
|
||||
statuses[vname] = {
|
||||
"file_count": len(vdata.get("files", [])),
|
||||
"tag_count": len(vdata.get("tags", {})),
|
||||
"path": vdata.get("path", ""),
|
||||
"watching": watching,
|
||||
}
|
||||
return {
|
||||
"vaults": statuses,
|
||||
"watcher_active": watcher is not None,
|
||||
"sse_clients": sse_manager.client_count,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Webhook CRUD endpoints (ROADMAP #85, tranche 2).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/webhooks``), même modèle de réponse
|
||||
(:class:`backend.schemas.WebhookModel`), même dépendance admin. La logique
|
||||
métier vit déjà dans :mod:`backend.webhooks` (validation d'URL anti-SSRF,
|
||||
store ``webhook_secrets.json`` — BUG-026).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from backend.auth.middleware import require_admin
|
||||
from backend.schemas import StatusResponse, WebhookModel
|
||||
from backend.webhooks import (
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
get_webhooks,
|
||||
update_webhook,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[WebhookModel])
|
||||
async def api_webhooks_list(current_user=Depends(require_admin)):
|
||||
return get_webhooks()
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookModel)
|
||||
async def api_webhooks_create(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
name = body.get("name", "Unnamed")
|
||||
url = body.get("url", "")
|
||||
events = body.get("events", [])
|
||||
secret = body.get("secret")
|
||||
if not url:
|
||||
raise HTTPException(400, "URL is required")
|
||||
return create_webhook(name, url, events, secret)
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=WebhookModel)
|
||||
async def api_webhooks_update(
|
||||
webhook_id: str, body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
result = update_webhook(webhook_id, body)
|
||||
if not result:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}", response_model=StatusResponse)
|
||||
async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admin)):
|
||||
if not delete_webhook(webhook_id):
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
return {"status": "deleted"}
|
||||
@@ -188,6 +188,546 @@ class BackupsAutoResponse(BaseModel):
|
||||
since_hours: int | float = Field(description="Look-back window in hours")
|
||||
|
||||
|
||||
class DiffResponse(BaseModel):
|
||||
"""Response containing a unified diff between two file versions (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
version: int = Field(description="Backup version timestamp (left/old side)")
|
||||
compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)")
|
||||
diff: str = Field(description="Unified diff (empty if no changes)")
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
"""Request to restore a file from a backup (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
version: int = Field(description="Timestamp of the backup version to restore")
|
||||
|
||||
|
||||
class RestoreResponse(BaseModel):
|
||||
"""Response after restoring a file from backup (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
success: bool = Field(description="Whether restore succeeded")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
restored_from: int = Field(description="Timestamp of the backup used")
|
||||
current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
|
||||
|
||||
|
||||
class BackupEntry(BaseModel):
|
||||
"""A single backup version of a file (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
timestamp: int = Field(description="Unix timestamp of when the backup was created")
|
||||
datetime: str = Field(description="ISO 8601 datetime string")
|
||||
size: int = Field(description="File size in bytes")
|
||||
filename: str = Field(description="Backup filename on disk")
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
"""Response listing all available backups for a file (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
backups: list[BackupEntry] = Field(description="Available backups, newest first")
|
||||
|
||||
|
||||
class DiffRequest(BaseModel):
|
||||
"""Request parameters for generating a diff (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
version: int = Field(description="Timestamp of the backup version to compare")
|
||||
compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Files — browse / read (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BrowseItem(BaseModel):
|
||||
"""A single entry (file or directory) returned by the browse endpoint."""
|
||||
|
||||
name: str = Field(description="File or directory name")
|
||||
path: str = Field(description="Relative path within vault")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
children_count: int | None = Field(default=None, description="Number of children (directories only)")
|
||||
size: int | None = Field(default=None, description="File size in bytes")
|
||||
extension: str | None = Field(default=None, description="File extension")
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
"""Paginated directory listing for a vault."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
items: list[BrowseItem]
|
||||
|
||||
|
||||
class FileContentResponse(BaseModel):
|
||||
"""Rendered file content with metadata."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
title: str = Field(description="File title (from frontmatter or filename)")
|
||||
tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
|
||||
frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
|
||||
html: str = Field(description="Rendered HTML content")
|
||||
raw_length: int = Field(description="Length of raw file content in characters")
|
||||
extension: str = Field(description="File extension (e.g. .md, .txt)")
|
||||
is_markdown: bool = Field(description="Whether the file is markdown")
|
||||
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
|
||||
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
|
||||
is_pdf: bool | None = Field(default=None, description="True for PDF files")
|
||||
is_image: bool | None = Field(default=None, description="True for image files")
|
||||
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
|
||||
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
|
||||
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
|
||||
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
|
||||
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
|
||||
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
||||
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
|
||||
xlsx_readonly: bool | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"True when the table is served read-only (.xls/.ods, #153 A16): "
|
||||
"the viewer hides the editable-cell wiring and the save/structure "
|
||||
"endpoints refuse the format"
|
||||
),
|
||||
)
|
||||
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Rendered xlsx sheets [{name, html, rows, cols, total_rows, "
|
||||
"total_cols, max_rows, max_cols, truncated}] — `truncated` is true "
|
||||
"when the sheet exceeds the 500x40 render caps (#153 A8)"
|
||||
),
|
||||
)
|
||||
xlsx_revision: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optimistic-concurrency token of the spreadsheet (#156-A12): the "
|
||||
"client sends it back as the `if_match` of a write so a change made "
|
||||
"elsewhere is refused (409 `conflict`) instead of overwritten"
|
||||
),
|
||||
)
|
||||
xlsx_lossy_features: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Workbook parts an openpyxl save would drop (#153 A1) — e.g. "
|
||||
"cached_values, slicers, form_controls, connections, custom_xml, "
|
||||
"signature, rich_comments, macros. Empty/absent = nothing at risk."
|
||||
),
|
||||
)
|
||||
is_json: bool | None = Field(default=None, description="True for JSON files")
|
||||
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
|
||||
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
|
||||
excalidraw_data_compressed: str | None = Field(default=None, description="Compressed Excalidraw data for .excalidraw.md files")
|
||||
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
|
||||
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
|
||||
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
||||
|
||||
|
||||
class XlsxDashboardNamedRange(BaseModel):
|
||||
"""One named range of a workbook (#153 A17)."""
|
||||
|
||||
name: str = Field(description="Range name as declared in the workbook")
|
||||
scope: str = Field(description="Sheet name when sheet-scoped, empty when workbook-wide")
|
||||
ref: str = Field(description="Formula-style reference, e.g. Data!$A$1:$B$5")
|
||||
|
||||
|
||||
class XlsxDashboardSheetKpi(BaseModel):
|
||||
"""One KPI card of a sheet dashboard (#153 A17)."""
|
||||
|
||||
label: str = Field(description="A1 reference of the numeric cell")
|
||||
value: float = Field(description="Numeric value of the cell")
|
||||
|
||||
|
||||
class XlsxDashboardSheet(BaseModel):
|
||||
"""Per-sheet KPI stats of a workbook dashboard (#153 A17)."""
|
||||
|
||||
name: str = Field(description="Sheet name")
|
||||
cells: int = Field(description="Non-empty cells inside the 500x40 caps")
|
||||
rows: int = Field(description="Rows carrying at least one non-empty cell")
|
||||
cols: int = Field(description="Columns carrying at least one non-empty cell")
|
||||
formulas: int = Field(description="Cells whose value is a formula")
|
||||
numeric: int = Field(description="Cells carrying a numeric value")
|
||||
kpi: list[XlsxDashboardSheetKpi] = Field(description="First numeric cells as KPI cards")
|
||||
|
||||
|
||||
class XlsxDashboardResponse(BaseModel):
|
||||
"""Dashboard metadata of an .xlsx workbook (#153 A17)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
named_ranges: list[XlsxDashboardNamedRange] = Field(description="Named ranges, sorted by name")
|
||||
objects: dict[str, int] = Field(description="Object counts: {charts, pivots}")
|
||||
sheets: list[XlsxDashboardSheet] = Field(description="Per-sheet KPI stats")
|
||||
|
||||
|
||||
class XlsxSheetWindowResponse(BaseModel):
|
||||
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
|
||||
|
||||
Served by ``GET /api/file/{vault_name}/xlsx/sheet``; the row numbers and
|
||||
the ``data-cell`` references in ``html`` are the real A1 coordinates of the
|
||||
sheet, whatever the window.
|
||||
"""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
sheet: str = Field(description="Sheet name (as shown in the tab)")
|
||||
offset: int = Field(description="0-based index of the first returned row")
|
||||
limit: int = Field(description="Maximum number of rows returned (capped server-side)")
|
||||
rows: int = Field(description="Rows actually returned in this window")
|
||||
cols: int = Field(description="Columns of the rendered window")
|
||||
total_rows: int = Field(description="Rows the sheet declares")
|
||||
total_cols: int = Field(description="Columns the sheet declares")
|
||||
max_rows: int = Field(description="Row cap of the renderer (500) — the coverage of this window")
|
||||
max_cols: int = Field(description="Column cap of the renderer (40)")
|
||||
truncated: bool = Field(
|
||||
description="True when the sheet exceeds the 500x40 render caps"
|
||||
)
|
||||
has_more: bool = Field(description="True when rows remain after this window")
|
||||
html: str = Field(description="Rendered HTML table for the window")
|
||||
|
||||
|
||||
class FileRawResponse(BaseModel):
|
||||
"""Raw text content of a file."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
raw: str = Field(description="Raw file content as text")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Files — mutations (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FileSaveResponse(BaseModel):
|
||||
"""Confirmation after saving a file."""
|
||||
|
||||
status: str = Field(description="Always 'ok'")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
size: int = Field(description="Size of saved content in characters")
|
||||
# #156-A12 — optimistic-concurrency token of the file AFTER the write, so a
|
||||
# client can chain writes without re-reading (absent on non-spreadsheets).
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
description="Opaque revision of the saved spreadsheet (send it back as `if_match`)",
|
||||
)
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
"""Confirmation after deleting a file."""
|
||||
|
||||
status: str = Field(description="Always 'ok'")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
|
||||
|
||||
class DirectoryCreateRequest(BaseModel):
|
||||
"""Request to create a new directory."""
|
||||
|
||||
path: str = Field(description="Relative path of the new directory")
|
||||
|
||||
|
||||
class DirectoryCreateResponse(BaseModel):
|
||||
"""Response after creating a directory."""
|
||||
|
||||
success: bool = Field(description="Whether creation succeeded")
|
||||
path: str = Field(description="Path of the created directory")
|
||||
|
||||
|
||||
class DirectoryRenameRequest(BaseModel):
|
||||
"""Request to rename a directory."""
|
||||
|
||||
path: str = Field(description="Current path of the directory")
|
||||
new_name: str = Field(description="New name for the directory")
|
||||
|
||||
|
||||
class DirectoryRenameResponse(BaseModel):
|
||||
"""Response after renaming a directory."""
|
||||
|
||||
success: bool = Field(description="Whether rename succeeded")
|
||||
old_path: str = Field(description="Original directory path")
|
||||
new_path: str = Field(description="New directory path")
|
||||
|
||||
|
||||
class DirectoryDeleteResponse(BaseModel):
|
||||
"""Response after deleting a directory."""
|
||||
|
||||
success: bool = Field(description="Whether deletion succeeded")
|
||||
deleted_count: int = Field(description="Number of files recursively deleted")
|
||||
|
||||
|
||||
class FileCreateRequest(BaseModel):
|
||||
"""Request to create a new file."""
|
||||
|
||||
path: str = Field(description="Relative path of the new file")
|
||||
content: str = Field(default="", description="Initial content")
|
||||
|
||||
|
||||
class FileCreateResponse(BaseModel):
|
||||
"""Response after creating a file."""
|
||||
|
||||
success: bool = Field(description="Whether creation succeeded")
|
||||
path: str = Field(description="Path of the created file")
|
||||
|
||||
|
||||
class BatchUploadFileItem(BaseModel):
|
||||
"""A single file/dir entry in a batch upload request."""
|
||||
|
||||
path: str = Field(description="Relative path of the item within the batch")
|
||||
content: str | None = Field(default=None, description="Base64 encoded or text content for files")
|
||||
is_dir: bool = Field(default=False, description="True if entry represents an empty directory")
|
||||
|
||||
|
||||
class BatchUploadRequest(BaseModel):
|
||||
"""Request payload for batch file/directory upload."""
|
||||
|
||||
target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)")
|
||||
files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload")
|
||||
overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)")
|
||||
|
||||
|
||||
class BatchUploadResponse(BaseModel):
|
||||
"""Response from batch file/directory upload."""
|
||||
|
||||
success: bool = Field(description="True if all files uploaded without error")
|
||||
vault: str = Field(description="Vault name")
|
||||
target_dir: str = Field(description="Target directory")
|
||||
uploaded: list[str] = Field(description="List of created/updated file paths")
|
||||
created_dirs: list[str] = Field(description="List of created directory paths")
|
||||
errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed")
|
||||
total_files: int = Field(description="Total uploaded files count")
|
||||
|
||||
|
||||
class FileRenameRequest(BaseModel):
|
||||
"""Request to rename a file."""
|
||||
|
||||
path: str = Field(description="Current path of the file")
|
||||
new_name: str = Field(description="New name for the file")
|
||||
|
||||
|
||||
class FileRenameResponse(BaseModel):
|
||||
"""Response after renaming a file."""
|
||||
|
||||
success: bool = Field(description="Whether rename succeeded")
|
||||
old_path: str
|
||||
new_path: str
|
||||
|
||||
|
||||
class FileMoveRequest(BaseModel):
|
||||
"""Request to move a file or directory to a different parent directory."""
|
||||
|
||||
source_path: str = Field(description="Current relative path of the file/directory")
|
||||
destination_dir: str = Field(description="Target directory relative path (empty string for vault root)")
|
||||
|
||||
|
||||
class FileMoveResponse(BaseModel):
|
||||
"""Response after moving a file or directory."""
|
||||
|
||||
success: bool = Field(description="Whether move succeeded")
|
||||
old_path: str = Field(description="Original path")
|
||||
new_path: str = Field(description="New path after move")
|
||||
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vaults & history (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VaultInfo(BaseModel):
|
||||
"""Summary information about a configured vault."""
|
||||
|
||||
name: str = Field(description="Display name of the vault")
|
||||
file_count: int = Field(description="Number of indexed files")
|
||||
tag_count: int = Field(description="Number of unique tags")
|
||||
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
|
||||
|
||||
|
||||
class BookmarkToggleRequest(BaseModel):
|
||||
"""Request to toggle a bookmark on a file."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
title: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(description="File tags")
|
||||
score: int = Field(description="Relevance score")
|
||||
snippet: str = Field(description="Content excerpt with highlights")
|
||||
modified: str = Field(description="ISO 8601 modification timestamp")
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Full-text search response with optional pagination."""
|
||||
|
||||
query: str = Field(description="Original search query")
|
||||
vault_filter: str = Field(description="Vault filter applied ('all' or vault name)")
|
||||
tag_filter: str | None = Field(default=None, description="Tag filter applied")
|
||||
count: int = Field(description="Number of results in this response")
|
||||
total: int = Field(default=0, description="Total results before pagination")
|
||||
offset: int = Field(default=0, description="Current pagination offset")
|
||||
limit: int = Field(default=200, description="Page size")
|
||||
results: list[SearchResultItem] = Field(description="Search result items")
|
||||
|
||||
|
||||
class TagsResponse(BaseModel):
|
||||
"""Tag aggregation response."""
|
||||
|
||||
vault_filter: str | None = Field(default=None, description="Vault filter applied")
|
||||
tags: dict[str, int] = Field(description="Tag name → count mapping")
|
||||
|
||||
|
||||
class TreeSearchResult(BaseModel):
|
||||
"""A single tree search result item."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Full relative path")
|
||||
name: str = Field(description="File or directory name")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
matched_path: str = Field(description="Path segment that matched the query")
|
||||
|
||||
|
||||
class TreeSearchResponse(BaseModel):
|
||||
"""Tree search response with matching paths."""
|
||||
|
||||
query: str = Field(description="Search query")
|
||||
vault_filter: str = Field(description="Vault filter applied")
|
||||
results: list[TreeSearchResult] = Field(description="Matching files and directories")
|
||||
|
||||
|
||||
class VaultPathEntry(BaseModel):
|
||||
"""A single indexed path (file or directory) in a vault."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Full relative path")
|
||||
name: str = Field(description="File or directory name")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
|
||||
|
||||
class VaultPathsResponse(BaseModel):
|
||||
"""Flat list of every indexed path in a vault (capped)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
count: int = Field(description="Number of returned entries")
|
||||
results: list[VaultPathEntry] = Field(description="Indexed files and directories")
|
||||
|
||||
|
||||
class AdvancedSearchResultItem(BaseModel):
|
||||
"""A single advanced search result with highlighted snippet."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(description="File tags")
|
||||
score: float = Field(description="TF-IDF relevance score (or fused RRF score in semantic mode)")
|
||||
semantic_score: float = Field(default=0.0, description="Cosine similarity from the semantic index (0 when unavailable)")
|
||||
snippet: str = Field(description="Content excerpt with <mark> highlights")
|
||||
modified: str = Field(description="ISO 8601 modification timestamp")
|
||||
extension: str = Field(default="", description="File extension")
|
||||
|
||||
|
||||
class SearchFacets(BaseModel):
|
||||
"""Faceted counts for search results."""
|
||||
|
||||
tags: dict[str, int] = Field(default_factory=dict)
|
||||
vaults: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AdvancedSearchResponse(BaseModel):
|
||||
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
|
||||
|
||||
results: list[AdvancedSearchResultItem] = Field(description="Search results")
|
||||
total: int = Field(description="Total number of matching results")
|
||||
offset: int = Field(description="Current pagination offset")
|
||||
limit: int = Field(description="Page size")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
|
||||
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
|
||||
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
|
||||
|
||||
|
||||
class TitleSuggestion(BaseModel):
|
||||
"""A file title suggestion for autocomplete."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(default_factory=list, description="File tags")
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
"""Autocomplete suggestions for file titles."""
|
||||
|
||||
query: str = Field(description="Original query string")
|
||||
suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions")
|
||||
|
||||
|
||||
class TagSuggestion(BaseModel):
|
||||
"""A tag suggestion for autocomplete."""
|
||||
|
||||
tag: str = Field(description="Tag name")
|
||||
count: int = Field(description="Number of files with this tag")
|
||||
|
||||
|
||||
class TagSuggestResponse(BaseModel):
|
||||
"""Autocomplete suggestions for tags."""
|
||||
|
||||
query: str = Field(description="Original query string")
|
||||
suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions")
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
"""A single node in the graph view."""
|
||||
|
||||
id: str = Field(description="Unique node identifier")
|
||||
name: str = Field(description="Display name")
|
||||
type: str = Field(description="'vault', 'directory', or 'file'")
|
||||
path: str = Field(description="Relative path within vault")
|
||||
size: int = Field(default=0, description="File size in bytes")
|
||||
tags: list[str] = Field(default_factory=list, description="Tags from frontmatter")
|
||||
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
|
||||
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
"""An edge between two nodes in the graph view."""
|
||||
|
||||
source: str = Field(description="Source node ID")
|
||||
target: str = Field(description="Target node ID")
|
||||
relation: str = Field(description="'parent', 'wikilink', or 'backlink'")
|
||||
|
||||
|
||||
class GraphResponse(BaseModel):
|
||||
"""Graph data for a vault or directory."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Root path for the graph")
|
||||
scope: str = Field(default="directory", description="'directory' or 'full'")
|
||||
nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)")
|
||||
edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
|
||||
|
||||
|
||||
class ReloadResponse(BaseModel):
|
||||
"""Index reload confirmation with per-vault stats."""
|
||||
|
||||
status: str = Field(description="Reload status ('ok' or 'error')")
|
||||
vaults: dict[str, Any] = Field(description="Per-vault file counts after reload")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -395,6 +935,7 @@ class DashboardVaultStat(BaseModel):
|
||||
file_count: int
|
||||
tag_count: int
|
||||
total_size_bytes: int
|
||||
image_count: int = 0
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
@@ -404,6 +945,32 @@ class DashboardResponse(BaseModel):
|
||||
total_files: int
|
||||
total_tags: int
|
||||
total_size_bytes: int
|
||||
total_images: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System / health (#85 — extrait de backend.main, comportement inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Application health status.
|
||||
|
||||
Déplacé depuis :mod:`backend.main` sans modification : pas de
|
||||
``extra="allow"`` ici, pour préserver la validation actuelle des
|
||||
réponses (les champs enrichis de ``/api/health/detailed`` restent
|
||||
filtrés comme avant).
|
||||
"""
|
||||
|
||||
status: str = Field(description="Health status ('ok' or 'error')")
|
||||
version: str = Field(description="Application version (x.y.z — latest release tag)")
|
||||
vaults: int = Field(description="Number of configured vaults")
|
||||
total_files: int = Field(description="Total indexed files across all vaults")
|
||||
total_tokens: int = Field(description="Total indexed tokens (approx.) across all vaults", default=0)
|
||||
last_full_index_ts: str = Field(description="ISO timestamp of last full index rebuild", default="")
|
||||
uptime_seconds: int = Field(description="Server uptime in seconds", default=0)
|
||||
git_describe: str = Field(default="", description="Full git describe string (commits beyond tag), empty if no git")
|
||||
git_commit: str = Field(default="", description="Short HEAD commit hash, empty if no git")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,7 +12,12 @@ from sortedcontainers import SortedList
|
||||
|
||||
from backend import indexer as _indexer
|
||||
from backend import semantic_search as _semantic
|
||||
from backend.indexer import index
|
||||
|
||||
# NOTE: the shared index is read through ``_indexer.index`` everywhere, never
|
||||
# via ``from backend.indexer import index``. That import binds the dict object
|
||||
# once, so a module reload of ``backend.indexer`` (tests, dev reload) rebinds
|
||||
# the module-level name to a FRESH dict while this module keeps writing to the
|
||||
# stale one — the inverted index then silently indexes nothing (BUG-089).
|
||||
from backend.services.regex_safety import (
|
||||
MAX_REGEX_MATCHES,
|
||||
truncate_for_regex,
|
||||
@@ -368,12 +373,19 @@ class InvertedIndex:
|
||||
self.doc_vault: dict[str, str] = {}
|
||||
self.vault_docs: dict[str, set] = defaultdict(set)
|
||||
self.tag_docs: dict[str, set] = defaultdict(set)
|
||||
self.doc_tags: dict[str, set] = defaultdict(set)
|
||||
self._sorted_tokens: SortedList = SortedList()
|
||||
self._ready: bool = False # True after initial build
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
"""Return True if the index has not been built yet."""
|
||||
return not self._ready
|
||||
def is_ready(self) -> bool:
|
||||
"""Return True once the initial build has completed.
|
||||
|
||||
The index is then kept current incrementally by ``add_document()`` /
|
||||
``remove_document()``, so it never goes stale: there is no generation
|
||||
counter, no cooldown and no lazy rebuild. Searches simply fall back to
|
||||
a full scan while this is False (see ``search()``).
|
||||
"""
|
||||
return self._ready
|
||||
|
||||
def rebuild(self) -> None:
|
||||
"""Rebuild inverted index from the global ``index`` dict.
|
||||
@@ -392,8 +404,9 @@ class InvertedIndex:
|
||||
self.doc_vault = {}
|
||||
self.vault_docs = defaultdict(set)
|
||||
self.tag_docs = defaultdict(set)
|
||||
self.doc_tags = defaultdict(set)
|
||||
|
||||
for vault_name, vault_data in index.items():
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
for file_info in vault_data.get("files", []):
|
||||
doc_key = f"{vault_name}::{file_info['path']}"
|
||||
self.doc_count += 1
|
||||
@@ -406,6 +419,7 @@ class InvertedIndex:
|
||||
# --- Per-document tag index ---
|
||||
for tag in file_info.get("tags", []):
|
||||
self.tag_docs[tag.lower()].add(doc_key)
|
||||
self.doc_tags[file_info['path']].add(tag.lower())
|
||||
|
||||
# --- Title tokens ---
|
||||
title_tokens = tokenize(file_info.get("title", ""))
|
||||
@@ -537,6 +551,10 @@ class InvertedIndex:
|
||||
self.doc_vault.pop(doc_key, None)
|
||||
if vault_name in self.vault_docs:
|
||||
self.vault_docs[vault_name].discard(doc_key)
|
||||
# Drop the empty entry so a fully removed vault leaves no trace
|
||||
# (it is a defaultdict: a bare lookup would recreate the key).
|
||||
if not self.vault_docs[vault_name]:
|
||||
del self.vault_docs[vault_name]
|
||||
# Tags (per-document, NOT the global tag_norm_map)
|
||||
for tag in file_info.get("tags", []):
|
||||
td = self.tag_docs.get(tag.lower())
|
||||
@@ -678,7 +696,7 @@ _indexer.set_index_change_hook(_on_index_change_hook)
|
||||
|
||||
def init_inverted_index():
|
||||
"""Force initial inverted index build. Called after build_index completes on startup."""
|
||||
if any(vdata.get("files") for vdata in index.values()):
|
||||
if any(vdata.get("files") for vdata in _indexer.index.values()):
|
||||
_inverted_index.rebuild()
|
||||
logger.info("Inverted index initialized.")
|
||||
|
||||
@@ -739,7 +757,7 @@ def search(
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
inv = get_inverted_index()
|
||||
use_index = (not inv.is_stale()) and inv.doc_count > 0
|
||||
use_index = inv.is_ready() and inv.doc_count > 0
|
||||
|
||||
if use_index:
|
||||
# BUG-033: retrieve candidates from the inverted index instead of
|
||||
@@ -774,7 +792,7 @@ def search(
|
||||
else:
|
||||
candidates = [
|
||||
(vault_name, file_info)
|
||||
for vault_name, vault_data in index.items()
|
||||
for vault_name, vault_data in _indexer.index.items()
|
||||
if vault_filter == "all" or vault_name == vault_filter
|
||||
for file_info in vault_data["files"]
|
||||
]
|
||||
@@ -1523,7 +1541,7 @@ def suggest_titles(
|
||||
prefix: str,
|
||||
vault_filter: str = "all",
|
||||
limit: int = SUGGEST_LIMIT,
|
||||
) -> list[dict[str, str]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Args:
|
||||
@@ -1532,7 +1550,7 @@ def suggest_titles(
|
||||
limit: Maximum suggestions.
|
||||
|
||||
Returns:
|
||||
List of ``{"vault", "path", "title"}`` dicts.
|
||||
List of ``{"vault", "path", "title", "tags"}`` dicts.
|
||||
"""
|
||||
if not prefix or len(prefix) < MIN_PREFIX_LENGTH:
|
||||
return []
|
||||
@@ -1550,7 +1568,10 @@ def suggest_titles(
|
||||
key = f"{entry['vault']}::{entry['path']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(entry)
|
||||
# Add tags from the index
|
||||
entry_with_tags: dict[str, Any] = dict(entry)
|
||||
entry_with_tags["tags"] = list(inv.doc_tags.get(entry["path"], set()))
|
||||
results.append(entry_with_tags)
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
|
||||
@@ -1603,7 +1624,7 @@ def get_all_tags(vault_filter: str | None = None) -> dict[str, int]:
|
||||
Dict mapping tag names to their total occurrence count.
|
||||
"""
|
||||
merged: dict[str, int] = {}
|
||||
for vault_name, vault_data in index.items():
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
if vault_filter and vault_filter != "all" and vault_name != vault_filter:
|
||||
continue
|
||||
for tag, count in vault_data.get("tags", {}).items():
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared thread pool for CPU-bound search (ROADMAP #85, tranche 5).
|
||||
|
||||
Holder extrait de :mod:`backend.main` sans changement de comportement :
|
||||
un seul pool (2 workers, préfixe ``"search"``) créé au démarrage et arrêté
|
||||
à l'extinction par le lifespan de ``main``. Les routers et les endpoints
|
||||
restants y accèdent via :func:`get_search_executor` au lieu du global de
|
||||
``main`` (plus d'import circulaire potentiel).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
|
||||
|
||||
def init_search_executor(max_workers: int = 2) -> ThreadPoolExecutor:
|
||||
"""Create (or reuse) the shared search thread pool."""
|
||||
global _executor
|
||||
if _executor is None:
|
||||
_executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="search")
|
||||
return _executor
|
||||
|
||||
|
||||
def shutdown_search_executor() -> None:
|
||||
"""Stop the shared search thread pool (best-effort, non-blocking)."""
|
||||
global _executor
|
||||
if _executor is not None:
|
||||
_executor.shutdown(wait=False)
|
||||
_executor = None
|
||||
|
||||
|
||||
def get_search_executor() -> ThreadPoolExecutor | None:
|
||||
"""Return the shared search thread pool (``None`` before startup)."""
|
||||
return _executor
|
||||
@@ -457,10 +457,6 @@ class SemanticIndex:
|
||||
"""Return True once a full rebuild has completed."""
|
||||
return self._ready
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
"""Alias used by callers that check index freshness."""
|
||||
return not self._ready
|
||||
|
||||
def _ensure_provider(self) -> EmbeddingProvider:
|
||||
if self.provider is None:
|
||||
self.provider = get_embedding_provider()
|
||||
|
||||
@@ -31,7 +31,7 @@ DEFAULT_MAX_BACKUPS = 10
|
||||
def _default_max_backups() -> int:
|
||||
"""Read ``max_backups_per_file`` from app config (lazy, best-effort)."""
|
||||
try:
|
||||
from backend.main import _load_config
|
||||
from backend.routers.config import _load_config # ROADMAP #85 T7 — déménagé depuis backend.main
|
||||
|
||||
return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS))
|
||||
except Exception: # pragma: no cover - config unavailable
|
||||
|
||||
@@ -14,8 +14,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -223,6 +227,902 @@ def edit_file(
|
||||
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
|
||||
|
||||
|
||||
# Cell reference like "A1" / "AB42" (Excel A1 notation, up to 3 letters / 8 digits).
|
||||
_XLSX_CELL_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,7}$")
|
||||
# ponytail: bare int/float coercion mirrors what Excel does when you type a
|
||||
# number; dates/booleans stay text (upgrade path: parse locale dates too).
|
||||
_XLSX_INT_RE = re.compile(r"^[+-]?\d+$")
|
||||
_XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$")
|
||||
# #153 A4 — openpyxl turns any string starting with "=" into a formula, which
|
||||
# Excel then evaluates on open (DDE / =cmd|… / =HYPERLINK exfiltration). "@" is
|
||||
# the legacy Lotus-style trigger. "+"/"-" are left alone: they are numbers here.
|
||||
_XLSX_FORMULA_RE = re.compile(r"^[=@]")
|
||||
|
||||
# #153 A10 — types recognised when a user types into a cell. Excel infers them
|
||||
# too; storing everything as text would make a spreadsheet unusable (a boolean
|
||||
# column stays a string, a date column sorts lexicographically).
|
||||
_XLSX_TRUE_LITERALS = {"true", "vrai", "oui", "yes"}
|
||||
_XLSX_FALSE_LITERALS = {"false", "faux", "non", "no"}
|
||||
# Shape check before strptime: keeps the hot path free of format attempts.
|
||||
_XLSX_DATE_RE = re.compile(r"^\d{1,2}[-/]\d{1,2}[-/]\d{4}(?:[ T]\d{1,2}:\d{2})?$")
|
||||
|
||||
# #153 A3 — per-file write lock. Two concurrent saves (two tabs, the AI agent
|
||||
# and the viewer, a watcher restore) would otherwise read-modify-write on the
|
||||
# same archive and the last writer silently wins. Kept deliberately small: the
|
||||
# lock only covers the load → edit → atomic-replace window.
|
||||
_XLSX_LOCK_TIMEOUT = 15.0
|
||||
_xlsx_locks: dict[str, threading.Lock] = {}
|
||||
_xlsx_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _xlsx_write_lock(key: str) -> Iterator[None]:
|
||||
"""Serialize the read-modify-write of one workbook path.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``conflict`` (409) when the lock is still held after
|
||||
:data:`_XLSX_LOCK_TIMEOUT` seconds.
|
||||
"""
|
||||
with _xlsx_locks_guard:
|
||||
lock = _xlsx_locks.setdefault(key, threading.Lock())
|
||||
if not lock.acquire(timeout=_XLSX_LOCK_TIMEOUT):
|
||||
raise ServiceError(
|
||||
"Workbook is being modified by another operation, retry shortly",
|
||||
code="conflict",
|
||||
status=409,
|
||||
details={"path": key, "timeout_seconds": _XLSX_LOCK_TIMEOUT},
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def _invalidate_meta(file_path: Path) -> None:
|
||||
"""Drop the cached workbook metadata after a write (#156-A13).
|
||||
|
||||
Imported lazily so the module stays importable when the spreadsheet reader
|
||||
is not needed (it pulls openpyxl in).
|
||||
"""
|
||||
try:
|
||||
from backend.xlsx_reader import invalidate_workbook_meta
|
||||
|
||||
invalidate_workbook_meta(file_path)
|
||||
except Exception: # pragma: no cover - cache invalidation is best-effort
|
||||
logger.debug("workbook meta invalidation skipped", exc_info=True)
|
||||
|
||||
|
||||
def file_revision(file_path: Path) -> str:
|
||||
"""Opaque revision token of a file — ``mtime_ns:size`` in hex (#156-A12).
|
||||
|
||||
Cheap by design (one ``stat``) and enough for optimistic concurrency: any
|
||||
writer that replaces the file changes at least one of the two fields. The
|
||||
viewer reads it with the file and sends it back as the ``if_match`` of a
|
||||
write, so an external editor (Excel, the watcher, another worker) can no
|
||||
longer be silently overwritten.
|
||||
|
||||
Returns:
|
||||
The token, or ``""`` when the file cannot be stat'ed.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
return f"{st.st_mtime_ns:x}-{st.st_size:x}"
|
||||
|
||||
|
||||
def _check_revision(file_path: Path, expected: str | None) -> None:
|
||||
"""Refuse a write on a file that changed since it was read (#156-A12).
|
||||
|
||||
``expected`` comes from the read payload (``if_match``): when it is absent
|
||||
the write keeps its pre-A12 behaviour (last writer wins), so curl, the AI
|
||||
tools and the batch uploader are unaffected.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``conflict`` (409, ``reason=stale_revision``) when the
|
||||
file on disk is not the revision the caller read.
|
||||
"""
|
||||
if not expected:
|
||||
return
|
||||
current = file_revision(file_path)
|
||||
if current and current != expected:
|
||||
raise ServiceError(
|
||||
"The file changed on disk since it was read; reload before saving",
|
||||
code="conflict",
|
||||
status=409,
|
||||
details={
|
||||
"reason": "stale_revision",
|
||||
"path": str(file_path),
|
||||
"expected_revision": expected,
|
||||
"current_revision": current,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _coerce_xlsx_value(value: Any) -> Any:
|
||||
"""Turn the string sent by the cell editor back into a scalar (#153 A10).
|
||||
|
||||
The coercion is symmetric with :func:`backend.xlsx_reader._fmt`: a value
|
||||
typed by the user comes back as a string, and Excel would have inferred a
|
||||
type when typing the same thing. Recognised here:
|
||||
|
||||
* an empty cell -> ``None`` (clears it)
|
||||
* ``1234`` / ``-1`` -> ``int``
|
||||
* ``1.5`` / ``.5`` -> ``float``
|
||||
* ``TRUE``/``FAUX`` (case-insensitive) -> ``bool``
|
||||
* ``31/12/2026`` / ``31/12/2026 14:30`` -> ``date``/``datetime`` (FR)
|
||||
|
||||
Anything else stays text. A date-looking string typed with a leading
|
||||
``=`` is a formula and never reaches here as a date.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
text = value.strip()
|
||||
if text == "":
|
||||
return None
|
||||
if _XLSX_INT_RE.match(text):
|
||||
return int(text)
|
||||
if _XLSX_FLOAT_RE.match(text):
|
||||
return float(text)
|
||||
lowered = text.lower()
|
||||
if lowered in _XLSX_TRUE_LITERALS:
|
||||
return True
|
||||
if lowered in _XLSX_FALSE_LITERALS:
|
||||
return False
|
||||
if not _XLSX_FORMULA_RE.match(text):
|
||||
parsed = _parse_fr_datetime(text)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return value
|
||||
|
||||
|
||||
def _parse_fr_datetime(text: str) -> date | datetime | None:
|
||||
"""Parse a FR-localised date/datetime, or return ``None``.
|
||||
|
||||
Accepts ``JJ/MM/AAAA`` and ``JJ/MM/AAAA HH:MM`` (also ``JJ-MM-AAAA``).
|
||||
``dayfirst`` is what makes ``01/02/2026`` the 1st of February rather than
|
||||
the 2nd of January — the French convention.
|
||||
"""
|
||||
if not _XLSX_DATE_RE.match(text):
|
||||
return None
|
||||
for fmt in ("%d/%m/%Y %H:%M", "%d/%m/%Y", "%d-%m-%Y %H:%M", "%d-%m-%Y"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _write_cell(ws: Any, ref: str, value: Any, *, allow_formula: bool) -> None:
|
||||
"""Assign one cell, forcing text when it looks like a formula.
|
||||
|
||||
``cell.data_type = "s"`` is what stops openpyxl from emitting ``<f>``: the
|
||||
text is then stored as an inline/shared string and Excel shows it verbatim.
|
||||
"""
|
||||
cell = ws[ref]
|
||||
coerced = _coerce_xlsx_value(value)
|
||||
cell.value = coerced
|
||||
if not allow_formula and isinstance(coerced, str) and _XLSX_FORMULA_RE.match(coerced):
|
||||
cell.data_type = "s"
|
||||
|
||||
|
||||
def edit_xlsx_cells(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
sheet: str,
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
allow_formula: bool = False,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault the workbook belongs to.
|
||||
path: Vault-relative path of the ``.xlsx`` file.
|
||||
sheet: Worksheet title to edit.
|
||||
cells: Mapping of A1 references to new scalar values.
|
||||
backup: Create a timestamped ``.bak`` before rewriting the archive.
|
||||
allow_formula: Keep values starting with ``=``/``@`` as real formulas.
|
||||
Off by default (#153 A4): a typed ``=cmd|…`` is a DDE payload when
|
||||
the file is later opened in Excel.
|
||||
force: Write even when the workbook carries features openpyxl drops
|
||||
(slicers, form controls, connections, custom XML, signature, cached
|
||||
formula results — see :data:`backend.xlsx_reader.LOSSY_PARTS`).
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
|
||||
(409, concurrent write), ``xlsx_lossy_content`` (409, a lossy write was
|
||||
attempted without ``force``) or ``invalid`` (400) for a bad sheet, cell
|
||||
reference or value.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise ServiceError(
|
||||
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
|
||||
)
|
||||
if not cells:
|
||||
raise ServiceError("No cells to update", code="invalid", status=400)
|
||||
for ref in cells:
|
||||
if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref):
|
||||
raise ServiceError(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
# #153 A16 — the lossy gate is skipped for .xlsm: the save re-serializes
|
||||
# with keep_vba=True, so the macro project (the only extra part a .xlsm
|
||||
# carries) survives and nothing is dropped.
|
||||
if not force and file_path.suffix.lower() != ".xlsm":
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Saving this workbook would drop features ObsiGate cannot "
|
||||
"preserve; retry with force=true after confirmation",
|
||||
code="xlsx_lossy_content",
|
||||
status=409,
|
||||
details={"path": path, "features": lossy},
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — inside the lock: no writer can slip in between the check
|
||||
# and the load.
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
|
||||
# project survives the save (the endpoint's lossy probe is empty
|
||||
# for .xlsm on purpose).
|
||||
try:
|
||||
wb = load_workbook(file_path, keep_vba=file_path.suffix.lower() == ".xlsm")
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Unknown sheet: {sheet}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
details={"sheets": wb.sheetnames},
|
||||
)
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
ws = wb[sheet]
|
||||
for ref, value in cells.items():
|
||||
_write_cell(ws, ref, value, allow_formula=allow_formula)
|
||||
# #153 A2 — write beside the target then swap: a crash mid-save leaves
|
||||
# the original workbook intact instead of a truncated archive.
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
wb.save(tmp_path)
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
# #156-A13 — the metadata cache is keyed on (mtime, size); drop it too
|
||||
# so a rewrite that lands on the same tick can never serve stale maps.
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
def mutate_xlsx_structure(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
actions: list[dict[str, Any]],
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
|
||||
|
||||
``actions`` is an ordered list — the workbook is loaded once and every
|
||||
action is applied in sequence inside the same per-file lock and the same
|
||||
atomic replace, so a half-applied batch can never reach the disk:
|
||||
|
||||
* ``{"op": "sheet_add", "name": "X", "at": 1}`` — new sheet (at =
|
||||
optional 0-based position);
|
||||
* ``{"op": "sheet_rename", "from": "X", "to": "Y"}``;
|
||||
* ``{"op": "sheet_delete", "name": "X"}`` — refused when it is the
|
||||
last sheet (an openpyxl workbook must keep one);
|
||||
* ``{"op": "sheet_duplicate", "name": "X", "as": "Y"}`` — values,
|
||||
styles and merged ranges are copied (not the data-dependent objects);
|
||||
* ``{"op": "row_insert"|"row_delete"|"col_insert"|"col_delete",
|
||||
"sheet": "X", "at": N, "count": k}`` — 1-based position, default 1.
|
||||
|
||||
All of it rides the same guards as the cell edits (P0): per-file lock,
|
||||
``.tmp`` + ``os.replace`` atomic write and the ``force`` gate on lossy
|
||||
round-trips. The UI proposes these actions with an explicit confirmation
|
||||
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
if not actions or len(actions) > 50:
|
||||
raise ServiceError(
|
||||
"Invalid actions (1 to 50 per request)", code="invalid", status=400
|
||||
)
|
||||
|
||||
if not force:
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Restructuring this workbook would drop features ObsiGate "
|
||||
"cannot preserve; retry with force=true after confirmation",
|
||||
code="xlsx_lossy_content",
|
||||
status=409,
|
||||
details={"path": path, "features": lossy},
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — stale-write guard (see edit_xlsx_cells).
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.worksheet.copier import WorksheetCopy
|
||||
|
||||
try:
|
||||
wb = load_workbook(file_path)
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
applied: list[str] = []
|
||||
try:
|
||||
for i, action in enumerate(actions):
|
||||
op = action.get("op")
|
||||
try:
|
||||
if op == "sheet_add":
|
||||
name = str(action.get("name", "")).strip()
|
||||
if not name or name in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Nom de feuille invalide ou déjà pris: {name!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
ws = wb.create_sheet(name[:31])
|
||||
at = action.get("at")
|
||||
# create_sheet appends at the end: shift left by the
|
||||
# distance between the last index and the target.
|
||||
if isinstance(at, int) and 0 <= at < len(wb.sheetnames):
|
||||
wb.move_sheet(ws, offset=at - (len(wb.sheetnames) - 1))
|
||||
applied.append(f"sheet_add:{ws.title}")
|
||||
elif op == "sheet_rename":
|
||||
src, dst = str(action.get("from", "")), str(action.get("to", "")).strip()
|
||||
if src not in wb.sheetnames or not dst or dst in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Renommage invalide: {src!r} -> {dst!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
wb[src].title = dst[:31]
|
||||
applied.append(f"sheet_rename:{src}->{dst}")
|
||||
elif op == "sheet_delete":
|
||||
name = str(action.get("name", ""))
|
||||
if name not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {name}", code="invalid", status=400
|
||||
)
|
||||
if len(wb.sheetnames) <= 1:
|
||||
raise ServiceError(
|
||||
"Impossible de supprimer la dernière feuille",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
del wb[name]
|
||||
applied.append(f"sheet_delete:{name}")
|
||||
elif op == "sheet_duplicate":
|
||||
name = str(action.get("name", ""))
|
||||
new_name = str(action.get("as", "")).strip()
|
||||
if name not in wb.sheetnames or not new_name or new_name in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Duplication invalide: {name!r} -> {new_name!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
# WorksheetCopy is the documented dup path (openpyxl
|
||||
# 3.1); it copies values, styles and merges — not
|
||||
# charts/images, which openpyxl itself cannot clone.
|
||||
copy = wb.create_sheet(new_name[:31])
|
||||
WorksheetCopy(wb[name], copy).copy_worksheet()
|
||||
applied.append(f"sheet_duplicate:{name}->{copy.title}")
|
||||
elif op in ("row_insert", "row_delete", "col_insert", "col_delete"):
|
||||
sheet = str(action.get("sheet", ""))
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {sheet}", code="invalid", status=400
|
||||
)
|
||||
ws = wb[sheet]
|
||||
at = action.get("at", 1)
|
||||
count = action.get("count", 1)
|
||||
if not isinstance(at, int) or at < 1 or not isinstance(count, int) or count < 1:
|
||||
raise ServiceError(
|
||||
"Position 'at' / 'count' invalides", code="invalid", status=400
|
||||
)
|
||||
if op == "row_insert":
|
||||
ws.insert_rows(at, count)
|
||||
elif op == "row_delete":
|
||||
ws.delete_rows(at, count)
|
||||
elif op == "col_insert":
|
||||
ws.insert_cols(at, count)
|
||||
else:
|
||||
ws.delete_cols(at, count)
|
||||
applied.append(f"{op}:{sheet}@{at}x{count}")
|
||||
else:
|
||||
raise ServiceError(
|
||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
||||
)
|
||||
except ServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} ({op}) a échoué: {exc}",
|
||||
code="invalid", status=400,
|
||||
) from exc
|
||||
except ServiceError:
|
||||
wb.close()
|
||||
raise
|
||||
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
wb.save(tmp_path)
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(
|
||||
f"XLSX structure: {vault_name}/{rel_path} {applied}"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
# #156-A8 — write-side formatting. Every value is data-driven (the colours
|
||||
# come from the caller, never from a hardcoded palette) and the whole batch
|
||||
# rides the same lock, backup and atomic swap as the cell edits.
|
||||
_STYLE_KEYS = {
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"font_color",
|
||||
"fill_color",
|
||||
"align",
|
||||
"number_format",
|
||||
}
|
||||
_STYLE_ALIGNS = {"left", "center", "right"}
|
||||
MAX_STYLE_CELLS = 10_000
|
||||
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def _style_color(value: Any) -> str | None:
|
||||
"""Normalise ``#rrggbb`` to openpyxl's ARGB, or ``None`` to clear."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str) or not _HEX_COLOR_RE.match(value.strip()):
|
||||
raise ServiceError(
|
||||
f"Couleur invalide: {value!r}", code="invalid", status=400
|
||||
)
|
||||
return "FF" + value.strip().lstrip("#").upper()
|
||||
|
||||
|
||||
def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
"""Apply the data-driven style fragment of one ``cell`` operation.
|
||||
|
||||
Only the keys listed in :data:`_STYLE_KEYS` are accepted — an unknown one
|
||||
is a client bug, not something to ignore silently. Font attributes are
|
||||
copied before mutation so the shared style of the other cells is left
|
||||
untouched.
|
||||
"""
|
||||
import copy as copy_mod
|
||||
|
||||
from openpyxl.styles import Alignment, Color, PatternFill
|
||||
|
||||
unknown = set(style) - _STYLE_KEYS
|
||||
if unknown:
|
||||
raise ServiceError(
|
||||
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
|
||||
)
|
||||
if {"bold", "italic", "underline", "font_color"} & set(style):
|
||||
font = copy_mod.copy(cell.font)
|
||||
if "bold" in style:
|
||||
font.bold = bool(style["bold"])
|
||||
if "italic" in style:
|
||||
font.italic = bool(style["italic"])
|
||||
if "underline" in style:
|
||||
font.underline = "single" if style["underline"] else None
|
||||
if "font_color" in style:
|
||||
rgb = _style_color(style["font_color"])
|
||||
font.color = Color(rgb=rgb) if rgb else None
|
||||
cell.font = font
|
||||
if "fill_color" in style:
|
||||
rgb = _style_color(style["fill_color"])
|
||||
cell.fill = (
|
||||
PatternFill(start_color=rgb, end_color=rgb, fill_type="solid")
|
||||
if rgb
|
||||
else PatternFill(fill_type=None)
|
||||
)
|
||||
if "align" in style:
|
||||
align = style["align"]
|
||||
if align not in _STYLE_ALIGNS:
|
||||
raise ServiceError(
|
||||
f"Alignement invalide: {align!r}", code="invalid", status=400
|
||||
)
|
||||
current = cell.alignment
|
||||
cell.alignment = Alignment(
|
||||
horizontal=align,
|
||||
vertical=getattr(current, "vertical", None),
|
||||
wrap_text=getattr(current, "wrap_text", None),
|
||||
)
|
||||
if "number_format" in style:
|
||||
fmt = style["number_format"] or "General"
|
||||
if not isinstance(fmt, str) or len(fmt) > 120:
|
||||
raise ServiceError(
|
||||
"Format de nombre invalide", code="invalid", status=400
|
||||
)
|
||||
cell.number_format = fmt
|
||||
|
||||
|
||||
def mutate_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
ops: list[dict[str, Any]],
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write formatting on an ``.xlsx``/``.xlsm`` workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
|
||||
|
||||
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
|
||||
``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color``
|
||||
(``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and
|
||||
``number_format`` ;
|
||||
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
|
||||
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
|
||||
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
|
||||
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
|
||||
|
||||
Same guards as the cell edits: per-file lock, ``.bak`` backup,
|
||||
``.tmp`` + ``os.replace`` atomic swap, lossy-write 409 and the optional
|
||||
``expected_revision`` concurrency check (#156-A12).
|
||||
"""
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise ServiceError(
|
||||
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
|
||||
)
|
||||
if not ops or len(ops) > 50:
|
||||
raise ServiceError(
|
||||
"Invalid ops (1 to 50 per request)", code="invalid", status=400
|
||||
)
|
||||
|
||||
if not force and file_path.suffix.lower() != ".xlsm":
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Restyling this workbook would drop features ObsiGate cannot "
|
||||
"preserve; retry with force=true after confirmation",
|
||||
code="xlsx_lossy_content",
|
||||
status=409,
|
||||
details={"path": path, "features": lossy},
|
||||
)
|
||||
|
||||
def _range(ref: str) -> tuple[int, int, int, int]:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(str(ref).upper())
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Plage invalide: {ref!r}", code="invalid", status=400
|
||||
) from exc
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_STYLE_CELLS:
|
||||
raise ServiceError(
|
||||
f"Plage trop grande (max {MAX_STYLE_CELLS} cellules)",
|
||||
code="invalid",
|
||||
status=400,
|
||||
)
|
||||
return min_col, min_row, max_col, max_row
|
||||
|
||||
def _sheet(wb: Any, action: dict[str, Any]) -> Any:
|
||||
name = str(action.get("sheet", ""))
|
||||
if name not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {name}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
details={"sheets": wb.sheetnames},
|
||||
)
|
||||
return wb[name]
|
||||
|
||||
applied: list[str] = []
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
try:
|
||||
wb = load_workbook(
|
||||
file_path, keep_vba=file_path.suffix.lower() == ".xlsm"
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
rel_path = _rel(root, file_path)
|
||||
try:
|
||||
for i, action in enumerate(ops):
|
||||
if not isinstance(action, dict):
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} invalide", code="invalid", status=400
|
||||
)
|
||||
op = action.get("op")
|
||||
try:
|
||||
if op == "cell":
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range") or action.get("cell") or "")
|
||||
min_col, min_row, max_col, max_row = _range(ref)
|
||||
style = action.get("style") or {}
|
||||
if not isinstance(style, dict) or not style:
|
||||
raise ServiceError(
|
||||
"Style vide", code="invalid", status=400
|
||||
)
|
||||
for row in ws.iter_rows(
|
||||
min_row=min_row,
|
||||
max_row=max_row,
|
||||
min_col=min_col,
|
||||
max_col=max_col,
|
||||
):
|
||||
for cell in row:
|
||||
_apply_cell_style(cell, style)
|
||||
applied.append(
|
||||
f"cell:{ws.title}!{ref}:{','.join(sorted(style))}"
|
||||
)
|
||||
elif op in ("merge", "unmerge"):
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range", ""))
|
||||
_range(ref)
|
||||
if op == "merge":
|
||||
ws.merge_cells(ref)
|
||||
else:
|
||||
ws.unmerge_cells(ref)
|
||||
applied.append(f"{op}:{ws.title}!{ref}")
|
||||
elif op == "col_width":
|
||||
ws = _sheet(wb, action)
|
||||
col = action.get("col")
|
||||
width = action.get("width")
|
||||
if isinstance(col, int):
|
||||
col = get_column_letter(col)
|
||||
if not isinstance(col, str) or not col.strip():
|
||||
raise ServiceError(
|
||||
"Colonne invalide", code="invalid", status=400
|
||||
)
|
||||
if not isinstance(width, (int, float)) or not 0 <= float(width) <= 255:
|
||||
raise ServiceError(
|
||||
"Largeur invalide (0 à 255)", code="invalid", status=400
|
||||
)
|
||||
ws.column_dimensions[col.strip().upper()].width = float(width)
|
||||
applied.append(f"col_width:{ws.title}!{col}={width}")
|
||||
elif op == "row_height":
|
||||
ws = _sheet(wb, action)
|
||||
row = action.get("row")
|
||||
height = action.get("height")
|
||||
if not isinstance(row, int) or row < 1:
|
||||
raise ServiceError("Ligne invalide", code="invalid", status=400)
|
||||
if not isinstance(height, (int, float)) or not 0 <= float(height) <= 409:
|
||||
raise ServiceError(
|
||||
"Hauteur invalide (0 à 409)", code="invalid", status=400
|
||||
)
|
||||
ws.row_dimensions[row].height = float(height)
|
||||
applied.append(f"row_height:{ws.title}!{row}={height}")
|
||||
elif op == "freeze":
|
||||
ws = _sheet(wb, action)
|
||||
cell_ref = str(action.get("cell", ""))
|
||||
if cell_ref:
|
||||
_range(cell_ref)
|
||||
ws.freeze_panes = cell_ref.upper() or None
|
||||
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
|
||||
else:
|
||||
raise ServiceError(
|
||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
||||
)
|
||||
except ServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} ({op}) a échoué: {exc}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
) from exc
|
||||
except ServiceError:
|
||||
wb.close()
|
||||
raise
|
||||
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
wb.save(tmp_path)
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX style: {vault_name}/{rel_path} {applied}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
def save_csv_cells(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
|
||||
|
||||
The file is re-parsed, patched and re-serialized with :mod:`csv` so
|
||||
quoting follows RFC 4180. BUG-098 — the delimiter is **sniffed** and the
|
||||
same one is reused on write-back: a `;`-separated French CSV used to be
|
||||
parsed as a single column and rewritten with `,`. References beyond the
|
||||
current extent grow the grid (missing rows/cells are filled with empty
|
||||
strings). Values are stored as text: a CSV has no formula engine, so any
|
||||
string — including ones starting with ``=`` — is written verbatim (the
|
||||
render escapes it).
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
|
||||
(409, concurrent write) or ``invalid`` (400) for a bad reference.
|
||||
"""
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
if file_path.suffix.lower() != ".csv":
|
||||
raise ServiceError(f"Not a .csv file: {path}", code="invalid", status=400)
|
||||
if not cells:
|
||||
raise ServiceError("No cells to update", code="invalid", status=400)
|
||||
for ref in cells:
|
||||
if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref):
|
||||
raise ServiceError(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
# #156-A12 — same stale-write guard as the workbook path.
|
||||
_check_revision(file_path, expected_revision)
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
# BUG-098 — parse AND rewrite with the file's own delimiter.
|
||||
delimiter = sniff_csv_delimiter(raw)
|
||||
try:
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter))
|
||||
except csv_mod.Error:
|
||||
rows = [[line] for line in raw.splitlines()]
|
||||
|
||||
def _col_num(ref: str) -> int:
|
||||
letters = ref.rstrip("0123456789").upper()
|
||||
n = 0
|
||||
for ch in letters:
|
||||
n = n * 26 + (ord(ch) - ord("A") + 1)
|
||||
return n
|
||||
|
||||
def _row_num(ref: str) -> int:
|
||||
return int(ref[len(ref.rstrip("0123456789")):])
|
||||
|
||||
for ref, value in cells.items():
|
||||
r, c = _row_num(ref), _col_num(ref)
|
||||
while len(rows) < r:
|
||||
rows.append([])
|
||||
row = rows[r - 1]
|
||||
while len(row) < c:
|
||||
row.append("")
|
||||
row[c - 1] = "" if value is None else str(value)
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
buf = io_mod.StringIO()
|
||||
csv_mod.writer(buf, delimiter=delimiter, lineterminator="\n").writerows(rows)
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
tmp_path.write_text(buf.getvalue(), encoding="utf-8")
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
logger.info(f"CSV cells saved: {vault_name}/{rel_path} +{len(cells)}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
def append_to_file(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
|
||||
@@ -10,6 +10,7 @@ No authentication required for public share views.
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +18,10 @@ logger = logging.getLogger("obsigate.share")
|
||||
|
||||
SHARES_FILE = Path("data/shares.json")
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour des read-modify-write (perte de mises à
|
||||
# jour en cas de créations/accès/révocations concurrents).
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _read() -> dict:
|
||||
if not SHARES_FILE.exists():
|
||||
@@ -41,26 +46,27 @@ def create_share(
|
||||
expires_in_hours: int | None = None,
|
||||
) -> dict:
|
||||
"""Create a new share token for a document."""
|
||||
data = _read()
|
||||
token = secrets.token_hex(32) # 64-char hex token
|
||||
with _lock:
|
||||
data = _read()
|
||||
token = secrets.token_hex(32) # 64-char hex token
|
||||
|
||||
expires_at = None
|
||||
if expires_in_hours:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
expires_at = None
|
||||
if expires_in_hours:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
|
||||
share = {
|
||||
"id": token,
|
||||
"token": token,
|
||||
"vault": vault,
|
||||
"path": path,
|
||||
"created_by": created_by,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
}
|
||||
data["shares"][token] = share
|
||||
_write(data)
|
||||
share = {
|
||||
"id": token,
|
||||
"token": token,
|
||||
"vault": vault,
|
||||
"path": path,
|
||||
"created_by": created_by,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
}
|
||||
data["shares"][token] = share
|
||||
_write(data)
|
||||
logger.info(f"Created share for {vault}/{path} by {created_by}")
|
||||
return share
|
||||
|
||||
@@ -80,22 +86,24 @@ def get_share_by_token(token: str) -> dict | None:
|
||||
|
||||
def record_access(token: str):
|
||||
"""Increment access counter for a share."""
|
||||
data = _read()
|
||||
share = data["shares"].get(token)
|
||||
if share:
|
||||
share["access_count"] = share.get("access_count", 0) + 1
|
||||
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
||||
_write(data)
|
||||
with _lock:
|
||||
data = _read()
|
||||
share = data["shares"].get(token)
|
||||
if share:
|
||||
share["access_count"] = share.get("access_count", 0) + 1
|
||||
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
||||
_write(data)
|
||||
|
||||
|
||||
def revoke_share(share_id: str) -> bool:
|
||||
"""Revoke (delete) a share by its token."""
|
||||
data = _read()
|
||||
if share_id in data["shares"]:
|
||||
del data["shares"][share_id]
|
||||
_write(data)
|
||||
logger.info(f"Revoked share {share_id}")
|
||||
return True
|
||||
with _lock:
|
||||
data = _read()
|
||||
if share_id in data["shares"]:
|
||||
del data["shares"][share_id]
|
||||
_write(data)
|
||||
logger.info(f"Revoked share {share_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -112,12 +120,13 @@ def list_shares(vault_filter: str | None = None) -> list:
|
||||
|
||||
def update_shares_after_rename(vault: str, old_path: str, new_path: str):
|
||||
"""Update all shares when a file is renamed."""
|
||||
data = _read()
|
||||
updated = False
|
||||
for sid, s in data["shares"].items():
|
||||
if s.get("vault") == vault and s.get("path") == old_path:
|
||||
s["path"] = new_path
|
||||
updated = True
|
||||
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
|
||||
if updated:
|
||||
_write(data)
|
||||
with _lock:
|
||||
data = _read()
|
||||
updated = False
|
||||
for sid, s in data["shares"].items():
|
||||
if s.get("vault") == vault and s.get("path") == old_path:
|
||||
s["path"] = new_path
|
||||
updated = True
|
||||
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
|
||||
if updated:
|
||||
_write(data)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Server-Sent Events manager (ROADMAP #85, tranche 4).
|
||||
|
||||
Singleton extrait de :mod:`backend.main` sans changement de comportement :
|
||||
les routers montés par ``main`` partagent la même instance (les clients SSE
|
||||
connectés sur ``/api/events`` reçoivent les broadcasts émis depuis
|
||||
n'importe quel router).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
|
||||
class SSEManager:
|
||||
"""Manages SSE client connections and broadcasts events."""
|
||||
|
||||
def __init__(self):
|
||||
self._clients: list[asyncio.Queue] = []
|
||||
|
||||
async def connect(self) -> asyncio.Queue:
|
||||
"""Register a new SSE client and return its message queue."""
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
self._clients.append(queue)
|
||||
logger.debug(f"SSE client connected (total: {len(self._clients)})")
|
||||
return queue
|
||||
|
||||
def disconnect(self, queue: asyncio.Queue):
|
||||
"""Remove a disconnected SSE client."""
|
||||
if queue in self._clients:
|
||||
self._clients.remove(queue)
|
||||
logger.debug(f"SSE client disconnected (total: {len(self._clients)})")
|
||||
|
||||
async def broadcast(self, event_type: str, data: dict):
|
||||
"""Send an event to all connected SSE clients."""
|
||||
message = _json.dumps(data, ensure_ascii=False)
|
||||
dead: list[asyncio.Queue] = []
|
||||
for q in self._clients:
|
||||
try:
|
||||
q.put_nowait({"event": event_type, "data": message})
|
||||
except asyncio.QueueFull:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
self.disconnect(q)
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
return len(self._clients)
|
||||
|
||||
|
||||
sse_manager = SSEManager()
|
||||
@@ -13,6 +13,7 @@ from backend.tools import connected as _connected # noqa: F401 (registers conn
|
||||
from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler)
|
||||
from backend.tools import documents as _documents # noqa: F401 (registers document tools)
|
||||
from backend.tools import service as _service # noqa: F401 (registers tools)
|
||||
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
|
||||
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
||||
from backend.tools.context import (
|
||||
ToolConfirmationRequired,
|
||||
|
||||
@@ -18,8 +18,10 @@ import csv as csv_lib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from xml.sax import saxutils
|
||||
from typing import Any, cast
|
||||
|
||||
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
|
||||
from xml.sax import saxutils # nosec B406
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.mutations import save_raw_file
|
||||
@@ -171,7 +173,9 @@ def _render_markdown_pdf(content: str, title: str) -> bytes | None:
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
html = renderer(content)
|
||||
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le
|
||||
# renderer HTML renvoie toujours `str` à l'exécution).
|
||||
html = cast(str, renderer(content))
|
||||
return generate_pdf(build_pdf_html(html, title), title)
|
||||
except Exception as e:
|
||||
# WeasyPrint loads GTK lazily: a missing native library can surface at
|
||||
|
||||
@@ -52,6 +52,13 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
||||
"git_search_issues": ("git_issues", "query"),
|
||||
"git_get_file": ("git_file", "path"),
|
||||
"create_xlsx": ("xlsx_create", "path"),
|
||||
"list_xlsx_sheets": ("xlsx_sheets", "path"),
|
||||
"xlsx_to_markdown": ("xlsx_read", "path"),
|
||||
"update_xlsx_cells": ("xlsx_update", "path"),
|
||||
"append_xlsx_rows": ("xlsx_append", "path"),
|
||||
"search_workbook": ("xlsx_search", "query"),
|
||||
"analyze_range": ("xlsx_analyze", "path"),
|
||||
"edit_xlsx_structure": ("xlsx_structure", "path"),
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
|
||||
@@ -315,6 +315,110 @@ class DocxInput(BaseModel):
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class ListXlsxSheetsInput(BaseModel):
|
||||
"""List the sheets of an existing .xlsx workbook (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
|
||||
|
||||
class XlsxToMarkdownInput(BaseModel):
|
||||
"""Read one sheet of an existing .xlsx workbook as markdown (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(
|
||||
"", description="Sheet name (empty = the first/active sheet)"
|
||||
)
|
||||
|
||||
|
||||
class SearchWorkbookInput(BaseModel):
|
||||
"""Find a text across the sheets of a spreadsheet (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
query: str = Field(..., description="Text to look for")
|
||||
sheet: str = Field("", description="Restrict to one sheet (empty = all sheets)")
|
||||
case_sensitive: bool = Field(False, description="Match case")
|
||||
|
||||
|
||||
class AnalyzeRangeInput(BaseModel):
|
||||
"""Aggregate the values of an A1 range (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
sheet: str = Field(
|
||||
"", description="Sheet name (empty = the first/active sheet)"
|
||||
)
|
||||
range: str = Field(
|
||||
"",
|
||||
description="A1 range to analyse (e.g. 'B2:B50'); empty = the whole sheet",
|
||||
)
|
||||
|
||||
|
||||
class UpdateXlsxCellsInput(BaseModel):
|
||||
"""Batch-edit cells of an existing .xlsx workbook (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(
|
||||
"", description="Worksheet title to edit (ignored for a .csv)"
|
||||
)
|
||||
cells: dict[str, str | int | float | bool | None] = Field(
|
||||
..., description="A1 reference -> new value (max 500 per call)"
|
||||
)
|
||||
allow_formula: bool = Field(
|
||||
False,
|
||||
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class AppendXlsxRowsInput(BaseModel):
|
||||
"""Append rows at the end of a sheet of an existing .xlsx (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(..., description="Worksheet title to extend")
|
||||
rows: list[list[str | int | float | bool | None]] = Field(
|
||||
..., description="Rows of cell values, appended below the last used row (max 500)"
|
||||
)
|
||||
allow_formula: bool = Field(
|
||||
False,
|
||||
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class EditXlsxStructureInput(BaseModel):
|
||||
"""Structural CRUD on an existing workbook (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx/.xlsm file")
|
||||
actions: list[dict[str, Any]] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Ordered structural actions (1-50): sheet_add/sheet_rename/"
|
||||
"sheet_duplicate/sheet_delete, row_insert/row_delete/"
|
||||
"col_insert/col_delete"
|
||||
),
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class CsvInput(BaseModel):
|
||||
"""Create a .csv file in a vault from rows of cells."""
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.secrets")
|
||||
@@ -34,6 +35,9 @@ TOOL_KEY_NAMES: tuple[str, ...] = (
|
||||
|
||||
_SECRET_MARKERS = ("API_KEY", "TOKEN")
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour des read-modify-write du store de clés.
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _keys_file() -> Path:
|
||||
base = os.environ.get("OBSIGATE_DATA_DIR", "data")
|
||||
@@ -89,21 +93,23 @@ def set_tool_key(name: str, value: str) -> None:
|
||||
if name not in TOOL_KEY_NAMES:
|
||||
raise ValueError(f"Clé non prise en charge: {name}")
|
||||
value = (value or "").strip()
|
||||
keys = _read_keys()
|
||||
if value:
|
||||
keys[name] = value
|
||||
else:
|
||||
keys.pop(name, None)
|
||||
_write_keys(keys)
|
||||
with _lock:
|
||||
keys = _read_keys()
|
||||
if value:
|
||||
keys[name] = value
|
||||
else:
|
||||
keys.pop(name, None)
|
||||
_write_keys(keys)
|
||||
|
||||
|
||||
def delete_tool_key(name: str) -> bool:
|
||||
"""Remove one key from the store; return True when it existed."""
|
||||
if name not in TOOL_KEY_NAMES:
|
||||
raise ValueError(f"Clé non prise en charge: {name}")
|
||||
keys = _read_keys()
|
||||
if name in keys:
|
||||
del keys[name]
|
||||
_write_keys(keys)
|
||||
return True
|
||||
with _lock:
|
||||
keys = _read_keys()
|
||||
if name in keys:
|
||||
del keys[name]
|
||||
_write_keys(keys)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
"""Spreadsheet tools (#153 A6, #156 A14) — read and mutate existing workbooks.
|
||||
|
||||
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
|
||||
file; here the assistant can read and edit one that already exists). The same
|
||||
three formats the viewer edits are supported — ``.xlsx``, ``.xlsm`` and
|
||||
``.csv`` (#156-A14 used to be ``.xlsx`` only, which made a workbook the UI
|
||||
edits invisible to the assistant):
|
||||
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``search_workbook`` — READ, find text across every sheet (#156-A14);
|
||||
* ``analyze_range`` — READ, aggregate stats over an A1 range (#156-A14);
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet;
|
||||
* ``edit_xlsx_structure`` — WRITE, structural CRUD (sheets/rows/columns).
|
||||
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`
|
||||
(or ``save_csv_cells`` / ``mutate_xlsx_structure``), which already carry the
|
||||
#153 P0 guards: per-file lock, atomic replace, formula neutralisation
|
||||
(``allow_formula`` opt-in) and the lossy-write 409. Every write is a WRITE-risk
|
||||
tool, so the registry keeps asking for an explicit confirmation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
AnalyzeRangeInput,
|
||||
AppendXlsxRowsInput,
|
||||
EditXlsxStructureInput,
|
||||
ListXlsxSheetsInput,
|
||||
SearchWorkbookInput,
|
||||
UpdateXlsxCellsInput,
|
||||
XlsxToMarkdownInput,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.spreadsheets")
|
||||
|
||||
# xlsx_to_markdown ceiling: a workbook is a data dump, not prose. The table is
|
||||
# for the LLM context, so both axes are bounded (same spirit as A5's index cap).
|
||||
MAX_MD_ROWS = 100
|
||||
MAX_MD_COLS = 20
|
||||
MAX_MD_CHARS = 20_000
|
||||
|
||||
# #156-A14 — the newer read tools scan more than the markdown table (a search
|
||||
# or an aggregate must not stop at row 100) but stay bounded all the same:
|
||||
# a runaway scan would load a whole ledger into the model's context.
|
||||
MAX_SCAN_ROWS = 5_000
|
||||
MAX_SCAN_COLS = 100
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
MAX_RANGE_CELLS = 10_000
|
||||
MAX_RANGE_VALUES = 200
|
||||
|
||||
# The formats the spreadsheet editor (and now the assistant) can handle.
|
||||
SPREADSHEET_EXTENSIONS = (".xlsx", ".xlsm", ".csv")
|
||||
|
||||
_NUMBER_RE = re.compile(r"^-?\d+(?:[.,]\d+)?$")
|
||||
|
||||
|
||||
def _spreadsheet_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx``/``.xlsm``/``.csv`` path."""
|
||||
path = (path or "").strip()
|
||||
if not path.lower().endswith(SPREADSHEET_EXTENSIONS):
|
||||
raise ToolError(
|
||||
"Extension attendue : .xlsx, .xlsm ou .csv", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
root = get_vault_root(vault)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
return resolve_safe_path(root, path)
|
||||
|
||||
|
||||
def _is_csv(file_path: Path) -> bool:
|
||||
return file_path.suffix.lower() == ".csv"
|
||||
|
||||
|
||||
def _map_service_error(e: ServiceError) -> ToolError:
|
||||
return ToolError(e.message, code=e.code, details=e.details)
|
||||
|
||||
|
||||
def _sheet_titles(file_path: Path) -> list[str]:
|
||||
"""Sheet names of a workbook; a CSV has a single, unnamed “sheet”."""
|
||||
if _is_csv(file_path):
|
||||
return [""]
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
return list(wb.sheetnames)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _read_grid(
|
||||
file_path: Path, sheet: str, max_rows: int, max_cols: int
|
||||
) -> tuple[str, list[list[str]], bool]:
|
||||
"""Read one sheet (or a CSV) as bounded, formatted, trimmed rows.
|
||||
|
||||
Returns ``(title, rows, truncated)``; ``truncated`` is True when real data
|
||||
sits just beyond the row cap (probed one row further) so the caller can say
|
||||
so instead of silently dropping it.
|
||||
"""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
if _is_csv(file_path):
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
stem = file_path.stem
|
||||
if sheet and sheet != stem:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
raw = file_path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=sniff_csv_delimiter(raw))
|
||||
grid: list[list[str]] = []
|
||||
truncated = False
|
||||
for i, row in enumerate(reader):
|
||||
if i >= max_rows:
|
||||
truncated = any(str(c).strip() for c in row)
|
||||
break
|
||||
grid.append([str(c) for c in row][:max_cols])
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return stem, grid, truncated
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
if sheet:
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
ws = wb[sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
grid = []
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=max_rows, max_col=max_cols, values_only=True
|
||||
):
|
||||
grid.append([_fmt(v) for v in row])
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=max_rows + 1,
|
||||
max_row=max_rows + 1,
|
||||
max_col=max_cols,
|
||||
values_only=True,
|
||||
)
|
||||
)
|
||||
truncated = any(any(str(v or "").strip() for v in r) for r in probe)
|
||||
finally:
|
||||
wb.close()
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return title, grid, truncated
|
||||
|
||||
|
||||
def _to_number(text: str) -> float | None:
|
||||
"""Coerce a displayed cell to a float, or ``None`` when it is not one."""
|
||||
t = text.strip().replace("\u00a0", "").replace(" ", "")
|
||||
if not _NUMBER_RE.match(t):
|
||||
return None
|
||||
try:
|
||||
return float(t.replace(",", "."))
|
||||
except ValueError: # pragma: no cover - regex already guarantees the shape
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of a spreadsheet (.xlsx, .xlsm or .csv) with their "
|
||||
"dimensions (rows x columns) and whether the display caps truncate "
|
||||
"them. Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook (or of the CSV)."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ToolError(f"Fichier introuvable: {params.path}", code="not_found")
|
||||
|
||||
extents: list[tuple[str, int, int]] = []
|
||||
if _is_csv(file_path):
|
||||
_, rows, _ = _read_grid(file_path, "", MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
extents.append(
|
||||
(
|
||||
file_path.stem,
|
||||
len(rows),
|
||||
max((len(r) for r in rows), default=0),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Declared dimensions are enough here (and far cheaper than scanning
|
||||
# every row): the caller just needs a size to decide what to read.
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _sheet_extent
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
extents = [
|
||||
(ws.title, *_sheet_extent(ws)) for ws in wb.worksheets
|
||||
]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
sheets = [
|
||||
{
|
||||
"name": name,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
for name, total_rows, total_cols in extents
|
||||
]
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
|
||||
|
||||
@tool(
|
||||
name="xlsx_to_markdown",
|
||||
description=(
|
||||
"Read a sheet of a spreadsheet (.xlsx, .xlsm or .csv) as a bounded "
|
||||
"markdown table (up to 100 rows x 20 columns). Use to inspect "
|
||||
"spreadsheet data before answering or editing."
|
||||
),
|
||||
input_model=XlsxToMarkdownInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str, Any]:
|
||||
"""Render one sheet as a markdown table for the LLM context."""
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, truncated = _read_grid(file_path, params.sheet, MAX_MD_ROWS, MAX_MD_COLS)
|
||||
|
||||
lines: list[str] = []
|
||||
if rows:
|
||||
header = rows[0]
|
||||
lines.append("| " + " | ".join(header) + " |")
|
||||
lines.append("|" + "|".join("---" for _ in header) + "|")
|
||||
for row in rows[1:]:
|
||||
lines.append("| " + " | ".join(row) + " |")
|
||||
table = "\n".join(lines)[:MAX_MD_CHARS]
|
||||
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"sheet": title,
|
||||
"rows": len(rows),
|
||||
"cols": max((len(r) for r in rows), default=0),
|
||||
"truncated": truncated,
|
||||
"markdown": table,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_workbook",
|
||||
description=(
|
||||
"Search a text across every sheet of a spreadsheet (.xlsx, .xlsm or "
|
||||
".csv) and return the matching cells with their sheet and A1 "
|
||||
"reference (max 100 matches). Use it to find where a value lives "
|
||||
"without dumping whole sheets into the context."
|
||||
),
|
||||
input_model=SearchWorkbookInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def search_workbook(ctx: ToolContext, params: SearchWorkbookInput) -> dict[str, Any]:
|
||||
"""Find a needle across all sheets, bounded and counted per sheet."""
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
needle = (params.query or "").strip()
|
||||
if not needle:
|
||||
raise ToolError("Requête vide", code="invalid_arguments")
|
||||
|
||||
titles = _sheet_titles(file_path)
|
||||
if params.sheet:
|
||||
if params.sheet not in titles:
|
||||
raise ToolError(f"Feuille introuvable: {params.sheet}", code="not_found")
|
||||
titles = [params.sheet]
|
||||
|
||||
hay = needle if params.case_sensitive else needle.lower()
|
||||
matches: list[dict[str, Any]] = []
|
||||
by_sheet: dict[str, int] = {}
|
||||
total = 0
|
||||
for title in titles:
|
||||
sheet_title, rows, _ = _read_grid(
|
||||
file_path, title, MAX_SCAN_ROWS, MAX_SCAN_COLS
|
||||
)
|
||||
label = sheet_title or file_path.stem
|
||||
for r_i, row in enumerate(rows, start=1):
|
||||
for c_i, value in enumerate(row, start=1):
|
||||
if not value:
|
||||
continue
|
||||
haystack = value if params.case_sensitive else value.lower()
|
||||
if hay not in haystack:
|
||||
continue
|
||||
total += 1
|
||||
by_sheet[label] = by_sheet.get(label, 0) + 1
|
||||
if len(matches) < MAX_SEARCH_RESULTS:
|
||||
matches.append(
|
||||
{
|
||||
"sheet": label,
|
||||
"cell": f"{get_column_letter(c_i)}{r_i}",
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"query": needle,
|
||||
"total": total,
|
||||
"truncated": total > MAX_SEARCH_RESULTS,
|
||||
"by_sheet": by_sheet,
|
||||
"matches": matches,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="analyze_range",
|
||||
description=(
|
||||
"Aggregate an A1 range of a sheet (.xlsx, .xlsm or .csv): count, sum, "
|
||||
"mean, min and max of the numeric cells, plus a bounded sample of the "
|
||||
"values. Use it to answer a question about a column without reading "
|
||||
"the whole sheet."
|
||||
),
|
||||
input_model=AnalyzeRangeInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def analyze_range(ctx: ToolContext, params: AnalyzeRangeInput) -> dict[str, Any]:
|
||||
"""Numeric aggregates + value sample over an A1 range of the sheet."""
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, _ = _read_grid(file_path, params.sheet, MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
|
||||
label = (params.range or "").strip()
|
||||
if label:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(label.upper())
|
||||
except Exception as e:
|
||||
raise ToolError(f"Plage invalide: {label}", code="invalid_arguments") from e
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_RANGE_CELLS:
|
||||
raise ToolError(
|
||||
f"Plage trop grande (max {MAX_RANGE_CELLS} cellules)",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
selected = [row[min_col - 1 : max_col] for row in rows[min_row - 1 : max_row]]
|
||||
else:
|
||||
selected = rows
|
||||
|
||||
values = [v for row in selected for v in row if isinstance(v, str) and v.strip()]
|
||||
numbers = [n for n in (_to_number(v) for v in values) if n is not None]
|
||||
|
||||
stats: dict[str, Any] = {"count": len(numbers)}
|
||||
if numbers:
|
||||
stats.update(
|
||||
{
|
||||
"sum": round(sum(numbers), 6),
|
||||
"mean": round(sum(numbers) / len(numbers), 6),
|
||||
"min": min(numbers),
|
||||
"max": max(numbers),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"sheet": title,
|
||||
"range": params.range or "",
|
||||
"rows": len(selected),
|
||||
"cols": max((len(r) for r in selected), default=0),
|
||||
"cells": len(values),
|
||||
"numeric": stats,
|
||||
"values": values[:MAX_RANGE_VALUES],
|
||||
"truncated": len(values) > MAX_RANGE_VALUES,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="update_xlsx_cells",
|
||||
description=(
|
||||
"Edit cells of an existing spreadsheet (.xlsx, .xlsm or .csv). "
|
||||
"``cells`` maps A1 references to new values (max 500). A value "
|
||||
"starting with '=' or '@' is stored as TEXT unless allow_formula is "
|
||||
"set (DDE guard). Editing a workbook carrying features openpyxl "
|
||||
"cannot rewrite requires force=true (cached formula results, "
|
||||
"slicers…). A .csv has no sheet: any ``sheet`` value is ignored."
|
||||
),
|
||||
input_model=UpdateXlsxCellsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[str, Any]:
|
||||
"""Wrap the guarded cell-edit service (``.xlsx``/``.xlsm`` or ``.csv``)."""
|
||||
from backend.services.mutations import edit_xlsx_cells, save_csv_cells
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not params.cells:
|
||||
raise ToolError("Aucune cellule fournie", code="invalid_arguments")
|
||||
if not _is_csv(file_path) and not params.sheet:
|
||||
raise ToolError("Feuille requise pour un classeur", code="invalid_arguments")
|
||||
try:
|
||||
if _is_csv(file_path):
|
||||
result = save_csv_cells(params.vault, params.path, dict(params.cells))
|
||||
else:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"sheet": params.sheet,
|
||||
"cells": len(params.cells),
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="append_xlsx_rows",
|
||||
description=(
|
||||
"Append rows at the end of a sheet of an existing spreadsheet "
|
||||
"(.xlsx or .xlsm). Values are typed like in the viewer (numbers, "
|
||||
"TRUE/FALSE, FR dates JJ/MM/AAAA). The workbook is rewritten "
|
||||
"atomically with a backup. Not available for .csv."
|
||||
),
|
||||
input_model=AppendXlsxRowsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str, Any]:
|
||||
"""Append whole rows below the last used row of the sheet."""
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from backend.services.mutations import _coerce_xlsx_value, edit_xlsx_cells
|
||||
|
||||
if not params.rows:
|
||||
raise ToolError("Aucune ligne fournie", code="invalid_arguments")
|
||||
if len(params.rows) > 500:
|
||||
raise ToolError("Trop de lignes (max 500)", code="invalid_arguments")
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if _is_csv(file_path):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de notion de fin de feuille : utilisez "
|
||||
"update_xlsx_cells avec des références A1",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
if params.sheet not in wb.sheetnames:
|
||||
raise ToolError(
|
||||
f"Feuille introuvable: {params.sheet}", code="not_found"
|
||||
)
|
||||
ws = wb[params.sheet]
|
||||
first_free = (ws.max_row or 0) + 1
|
||||
finally:
|
||||
wb.close()
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
|
||||
cells: dict[str, Any] = {}
|
||||
for i, row in enumerate(params.rows):
|
||||
for j, value in enumerate(row):
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
continue
|
||||
ref = f"{get_column_letter(j + 1)}{first_free + i}"
|
||||
cells[ref] = _coerce_xlsx_value(value)
|
||||
if not cells:
|
||||
raise ToolError("Aucune valeur fournie", code="invalid_arguments")
|
||||
|
||||
try:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
cells,
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"sheet": params.sheet,
|
||||
"rows": len(params.rows),
|
||||
"first_row": first_free,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="edit_xlsx_structure",
|
||||
description=(
|
||||
"Change the structure of an existing .xlsx/.xlsm workbook: add, "
|
||||
"rename, duplicate or delete a sheet, or insert/delete rows and "
|
||||
"columns. ``actions`` is an ordered list of "
|
||||
'{"op": "sheet_add"|"sheet_rename"|"sheet_duplicate"|"sheet_delete"|'
|
||||
'"row_insert"|"row_delete"|"col_insert"|"col_delete", …} '
|
||||
"(1 to 50). Deletions drop data and cannot be undone from the "
|
||||
"assistant — confirm with the user first."
|
||||
),
|
||||
input_model=EditXlsxStructureInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def edit_xlsx_structure(
|
||||
ctx: ToolContext, params: EditXlsxStructureInput
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of structural changes through the guarded service."""
|
||||
from backend.services.mutations import mutate_xlsx_structure
|
||||
|
||||
if not params.actions:
|
||||
raise ToolError("Aucune action fournie", code="invalid_arguments")
|
||||
if len(params.actions) > 50:
|
||||
raise ToolError("Trop d'actions (max 50)", code="invalid_arguments")
|
||||
if str(params.path or "").lower().endswith(".csv"):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de structure modifiable", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
result = mutate_xlsx_structure(
|
||||
params.vault, params.path, [dict(a) for a in params.actions], force=params.force
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"actions": len(params.actions),
|
||||
}
|
||||
@@ -22,7 +22,7 @@ Exemples :
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent # racine du dépôt ObsiGate
|
||||
@@ -34,7 +34,8 @@ _ENV_VAR = "OBSIGATE_VERSION"
|
||||
def _run_git(args: list[str]) -> str:
|
||||
"""Run a git command in the repo root; return stdout (stripped) or ''."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
# argv fixe (git + args internes), sans shell : pas d'injection.
|
||||
result = subprocess.run( # nosec B404 B603 B607
|
||||
["git", *args],
|
||||
cwd=str(_ROOT),
|
||||
capture_output=True,
|
||||
|
||||
@@ -280,7 +280,8 @@ class VaultWatcher:
|
||||
for observer in self.observers.values():
|
||||
try:
|
||||
observer.join(timeout=5)
|
||||
except Exception: # nosec B110 — best-effort shutdown, ignore failures
|
||||
# best-effort shutdown, ignore failures (B110) :
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
self.observers.clear()
|
||||
logger.info("VaultWatcher stopped")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Shared VaultWatcher handle (ROADMAP #85, tranche 8).
|
||||
|
||||
Holder extrait de :mod:`backend.main` sans changement de comportement : le
|
||||
lifespan de ``main`` y dépose l'instance (``set_watcher``) et l'y reprend à
|
||||
l'extinction ; le router ``vaults`` la consulte via :func:`get_watcher`
|
||||
(démarrage/arrêt de surveillance à l'ajout/retrait dynamique de vault,
|
||||
état dans ``/api/vaults/status``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.watcher import VaultWatcher
|
||||
|
||||
_watcher: VaultWatcher | None = None
|
||||
|
||||
|
||||
def get_watcher() -> VaultWatcher | None:
|
||||
"""Return the shared VaultWatcher instance (``None`` if disabled)."""
|
||||
return _watcher
|
||||
|
||||
|
||||
def set_watcher(watcher: VaultWatcher | None) -> None:
|
||||
"""Store (or clear) the shared VaultWatcher instance."""
|
||||
global _watcher
|
||||
_watcher = watcher
|
||||
@@ -26,6 +26,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -144,6 +145,12 @@ def _read_secrets() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour des read-modify-write des deux stores
|
||||
# (webhooks + secrets) : perte de mises à jour en cas de mutations
|
||||
# concurrentes.
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _write_secrets(secrets: dict):
|
||||
WEBHOOK_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = WEBHOOK_SECRETS_FILE.with_suffix(".tmp")
|
||||
@@ -156,12 +163,13 @@ def _write_secrets(secrets: dict):
|
||||
|
||||
|
||||
def _store_secret(wh_id: str, secret: str | None) -> None:
|
||||
secrets = _read_secrets()
|
||||
if secret:
|
||||
secrets[wh_id] = secret
|
||||
else:
|
||||
secrets.pop(wh_id, None)
|
||||
_write_secrets(secrets)
|
||||
with _lock:
|
||||
secrets = _read_secrets()
|
||||
if secret:
|
||||
secrets[wh_id] = secret
|
||||
else:
|
||||
secrets.pop(wh_id, None)
|
||||
_write_secrets(secrets)
|
||||
|
||||
|
||||
def _get_secret(wh: dict) -> str | None:
|
||||
@@ -189,52 +197,55 @@ def get_webhooks() -> list:
|
||||
|
||||
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
|
||||
validate_webhook_url(url)
|
||||
webhooks = _read()
|
||||
wh_id = str(uuid.uuid4())
|
||||
wh = {
|
||||
"id": wh_id,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"events": [e for e in events if e in VALID_EVENTS],
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_fired_at": None,
|
||||
}
|
||||
webhooks.append(wh)
|
||||
_write(webhooks)
|
||||
if secret:
|
||||
_store_secret(wh_id, secret)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
wh_id = str(uuid.uuid4())
|
||||
wh = {
|
||||
"id": wh_id,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"events": [e for e in events if e in VALID_EVENTS],
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_fired_at": None,
|
||||
}
|
||||
webhooks.append(wh)
|
||||
_write(webhooks)
|
||||
if secret:
|
||||
_store_secret(wh_id, secret)
|
||||
logger.info(f"Created webhook '{name}' → {url}")
|
||||
return _public_view(wh)
|
||||
|
||||
|
||||
def update_webhook(wh_id: str, updates: dict) -> dict | None:
|
||||
webhooks = _read()
|
||||
for wh in webhooks:
|
||||
if wh["id"] == wh_id:
|
||||
if updates.get("url"):
|
||||
validate_webhook_url(updates["url"])
|
||||
if "secret" in updates:
|
||||
_store_secret(wh_id, updates["secret"])
|
||||
safe_updates = {
|
||||
k: v for k, v in updates.items()
|
||||
if k not in ("id", "secret")
|
||||
}
|
||||
wh.update(safe_updates)
|
||||
_write(webhooks)
|
||||
return _public_view(wh)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
for wh in webhooks:
|
||||
if wh["id"] == wh_id:
|
||||
if updates.get("url"):
|
||||
validate_webhook_url(updates["url"])
|
||||
if "secret" in updates:
|
||||
_store_secret(wh_id, updates["secret"])
|
||||
safe_updates = {
|
||||
k: v for k, v in updates.items()
|
||||
if k not in ("id", "secret")
|
||||
}
|
||||
wh.update(safe_updates)
|
||||
_write(webhooks)
|
||||
return _public_view(wh)
|
||||
return None
|
||||
|
||||
|
||||
def delete_webhook(wh_id: str) -> bool:
|
||||
webhooks = _read()
|
||||
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
|
||||
if len(new_list) == len(webhooks):
|
||||
return False
|
||||
_write(new_list)
|
||||
secrets = _read_secrets()
|
||||
if secrets.pop(wh_id, None) is not None:
|
||||
_write_secrets(secrets)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
|
||||
if len(new_list) == len(webhooks):
|
||||
return False
|
||||
_write(new_list)
|
||||
secrets = _read_secrets()
|
||||
if secrets.pop(wh_id, None) is not None:
|
||||
_write_secrets(secrets)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.16.3"
|
||||
version = "2.45.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.16.3"
|
||||
version = "2.45.2"
|
||||
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.16.3",
|
||||
"version": "2.45.2",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -53,7 +53,12 @@ services:
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
# OBSIGATE_ADMIN_PASSWORD → .env
|
||||
# OBSIGATE_SECURE_COOKIES=true # si derrière reverse proxy HTTPS
|
||||
# OBSIGATE_SECURE_COOKIES : auto par défaut (Secure si https, sinon
|
||||
# pas de flag) — forcer à true uniquement si le proxy termine TLS
|
||||
# sans X-Forwarded-Proto (avec TRUST_PROXY, l'auto suffit).
|
||||
# Reverse proxy devant l'app : IPs d'audit réelles (BUG-030) et
|
||||
# X-Forwarded-Proto honoré pour les cookies Secure (auto).
|
||||
- OBSIGATE_TRUST_PROXY=true
|
||||
- OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
- OLLAMA_MODEL=qwen2.5-coder:1.5b
|
||||
env_file:
|
||||
|
||||
@@ -347,7 +347,7 @@ Pour répondre au besoin de cibler un fournisseur/modèle sans dépendre uniquem
|
||||
| **0 — Fondations** | `backend/tools/` (registry, context, service, audit) + extraction des services métier + tests unitaires | Couche d'outils testable sans IA |
|
||||
| **1 — Function calling in-app** | Abstraction tool-calling multi-provider, agent loop, confirmations UI, SSE réel, outils de navigation | Assistant qui lit/cherche/lit/ouvre/modifie avec confirmation |
|
||||
| **2 — Serveur MCP** | `backend/mcp/server.py` (tools + resources + prompts), **Streamable HTTP** (`/mcp`, auth JWT), confirmation two-step | ObsiGate accessible comme serveur MCP (local + distant, multi-utilisateur) |
|
||||
| **3 — Durcissement** ✅ | Rate limiting (`backend/tools/ratelimit.py`), quotas `BOOKSLM_MAX_*`, redaction systématique des résultats (`backend/tools/redaction.py`), doc OpenAPI (tag/path MCP) + [guide MCP](./MCP_GUIDE.md), tests E2E | Observabilité et sécurité complètes |
|
||||
| **3 — Durcissement** ✅ | Rate limiting (`backend/tools/ratelimit.py`), quotas `BOOKSLM_MAX_*`, redaction systématique des résultats (`backend/tools/redaction.py`), doc OpenAPI (tag/path MCP) + [guide MCP](./GUIDES/MCP.md), tests E2E | Observabilité et sécurité complètes |
|
||||
|
||||
Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités.
|
||||
|
||||
@@ -380,7 +380,7 @@ Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités.
|
||||
- `backend/mcp/confirmations.py` — jetons de confirmation signés (two-step, anti-rejeu)
|
||||
- `backend/tools/ratelimit.py` — rate limiting par jeton/outil (phase F)
|
||||
- `backend/tools/redaction.py` — redaction récursive des résultats d'outils (phase F)
|
||||
- `docs/MCP_GUIDE.md` — guide d'installation et d'utilisation des clients MCP
|
||||
- `docs/GUIDES/MCP.md` — guide d'installation et d'utilisation des clients MCP
|
||||
- `backend/bookslm.py`, `backend/bookslm_routes.py` — assistant contextuel (+ endpoint `/agent`)
|
||||
- `frontend/js/ai.js`, `frontend/js/bookslm.js` — UI IA
|
||||
- `backend/auth/middleware.py` — permissions
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
# 🔌 Guide de l'API REST
|
||||
|
||||
ObsiGate expose une **API REST complète** couvrant toute l'application :
|
||||
vaults, fichiers, recherche, sauvegardes, exports, IA, partage, webhooks et
|
||||
administration. Ce guide explique l'authentification, la création de clés et
|
||||
donne des exemples prêts à l'emploi.
|
||||
|
||||
> **Public :** développeurs, intégrateurs, scripts d'automatisation
|
||||
> **Doc interactive :** `/docs` (Swagger UI) · `/redoc` (ReDoc) · `/openapi.json`
|
||||
> **Voir aussi :** [Serveur MCP](./MCP.md) · [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Base et conventions
|
||||
|
||||
| Élément | Valeur |
|
||||
|---|---|
|
||||
| URL de base | `http://<hôte>:2020` (Docker) ou `http://127.0.0.1:17890` (desktop) |
|
||||
| Préfixe API | `/api` |
|
||||
| Format | JSON (`application/json`) |
|
||||
| Version | suit la version d'ObsiGate (header `X-…`, `/api/health`) |
|
||||
| Erreurs | `{"detail": "..."}` + code HTTP (`400`, `401`, `403`, `404`, `409`, `422`, `500`) |
|
||||
|
||||
Quand l'authentification est **désactivée** (`OBSIGATE_AUTH_ENABLED=false`), tous
|
||||
les endpoints sont accessibles sans jeton (utilisateur anonyme avec accès à tous
|
||||
les vaults).
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentification
|
||||
|
||||
### 2.1 Jeton de session (JWT)
|
||||
|
||||
Obtenu via `POST /api/auth/login`. Le jeton d'accès a une durée de vie courte
|
||||
(`OBSIGATE_ACCESS_TOKEN_TTL`, défaut 3600 s) et un refresh token longue durée est
|
||||
posé en cookie HTTP-only.
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:2020/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"votre_mot_de_passe"}'
|
||||
```
|
||||
|
||||
Réponse (extrait) :
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
"user": { "username": "admin", "role": "admin", "vaults": ["*"] }
|
||||
}
|
||||
```
|
||||
|
||||
Deux façons de présenter le jeton :
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
ou, pour un client navigateur, le cookie HTTP-only avec
|
||||
`credentials: "include"` (le login pose aussi un cookie `access_token`).
|
||||
|
||||
### 2.2 Clés API longue durée (recommandé pour scripts & MCP)
|
||||
|
||||
Une **seule clé** authentifie **l'API REST et le serveur MCP**. Créez-la depuis
|
||||
l'interface (Configurations → **🔑 Clés API & MCP**) ou par API :
|
||||
|
||||
```bash
|
||||
# 1. Se connecter, récupérer le token (section 2.1)
|
||||
# 2. Créer une clé valable 30 jours
|
||||
curl -s -X POST http://localhost:2020/api/auth/tokens \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Script backup","expiry":"30d"}'
|
||||
```
|
||||
|
||||
Réponse (`token` affiché **une seule fois**) :
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJ...",
|
||||
"jti": "…",
|
||||
"name": "Script backup",
|
||||
"created_at": 1790000000,
|
||||
"expires_at": 1792592000,
|
||||
"expiry_key": "30d"
|
||||
}
|
||||
```
|
||||
|
||||
| `expiry` | Durée |
|
||||
|---|---|
|
||||
| `1d` | 1 jour |
|
||||
| `30d` | 1 mois |
|
||||
| `180d` | 6 mois |
|
||||
| `365d` | 1 an |
|
||||
| `never` | sans expiration |
|
||||
|
||||
Gestion :
|
||||
|
||||
| Endpoint | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/auth/tokens` | Lister vos clés (`last_used_at`, statut) |
|
||||
| `POST /api/auth/tokens` | Créer (`{name, expiry}`) |
|
||||
| `DELETE /api/auth/tokens/{jti}` | Révoquer immédiatement (API **et** MCP) |
|
||||
|
||||
> Le JWT brut n'est **jamais persisté** : copiez-le à la création. Plafond :
|
||||
> 50 clés actives par utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## 3. Référence des endpoints
|
||||
|
||||
> Liste non exhaustive — la référence faisant foi est `/openapi.json`. Les
|
||||
> colonnes **Auth** indiquent le niveau requis (`—`, `Oui`, `Admin`).
|
||||
|
||||
### 3.1 Système
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/health` | Santé (statut, version, stats) | GET | — |
|
||||
| `/api/health/detailed` | Santé détaillée | GET | — |
|
||||
| `/api/config` | Lire / écrire la configuration | GET/POST | Oui/Admin |
|
||||
| `/api/diagnostics` | Statistiques index & mémoire | GET | Admin |
|
||||
| `/api/dashboard` | Statistiques du tableau de bord | GET | Oui |
|
||||
| `/api/events` | Flux SSE temps réel | GET | Oui |
|
||||
|
||||
### 3.2 Vaults
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/vaults` | Liste (filtrée par permissions) | GET | Oui |
|
||||
| `/api/vaults/status` | Statut de toutes les vaults | GET | Oui |
|
||||
| `/api/vaults/add` | Ajouter une vault (volume déjà monté) | POST | Admin |
|
||||
| `/api/vaults/{name}` | Supprimer une vault | DELETE | Admin |
|
||||
| `/api/index/reload` | Réindexation complète | GET | Admin |
|
||||
| `/api/index/reload/{vault}` | Réindexer une vault | GET | Oui |
|
||||
| `/api/vaults/{vault}/settings` | Lire / écrire les réglages | GET/POST | Oui |
|
||||
| `/api/attachments/rescan/{vault}` | Rescanner les attachements | POST | Oui |
|
||||
|
||||
### 3.3 Fichiers
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/browse/{vault}?path=` | Naviguer dans les dossiers | GET | Oui |
|
||||
| `/api/file/{vault}?path=` | Contenu rendu (Markdown) | GET | Oui |
|
||||
| `/api/file/{vault}/raw?path=` | Contenu brut | GET | Oui |
|
||||
| `/api/file/{vault}/download?path=` | Télécharger | GET | Oui |
|
||||
| `/api/file/{vault}/save?path=` | Enregistrer | PUT | Oui |
|
||||
| `/api/file/{vault}` | Créer | POST | Oui |
|
||||
| `/api/file/{vault}` | Renommer | PATCH | Oui |
|
||||
| `/api/file/{vault}` | Supprimer | DELETE | Oui |
|
||||
| `/api/directory/{vault}` | Créer / renommer / supprimer un dossier | POST/PATCH/DELETE | Oui |
|
||||
| `/api/move/{vault}` | Déplacer un fichier/dossier | POST | Oui |
|
||||
| `/api/vault/{vault}/batch-upload` | Upload multiple (multipart) | POST | Oui |
|
||||
| `/api/image/{vault}?path=` | Servir une image | GET | Oui |
|
||||
|
||||
### 3.4 Recherche & graphe
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/search` | Recherche simple (legacy) | GET | Oui |
|
||||
| `/api/search/advanced` | Recherche TF-IDF avancée (facettes, tri, pagination, `semantic=`) | GET | Oui |
|
||||
| `/api/search/replace` | Recherche/remplacement multi-fichiers | POST | Oui |
|
||||
| `/api/tags?vault=` | Tags uniques avec compteurs | GET | Oui |
|
||||
| `/api/suggest?q=` | Autocomplétion de titres | GET | Oui |
|
||||
| `/api/tags/suggest?q=` | Autocomplétion de tags | GET | Oui |
|
||||
| `/api/tree-search` | Recherche de fichiers/dossiers | GET | Oui |
|
||||
| `/api/vault/{vault}/paths` | Liste de chemins | GET | Oui |
|
||||
| `/api/graph/{vault}` | Graphe de liens | GET | Oui |
|
||||
|
||||
### 3.5 Sauvegardes
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/file/{vault}/backups` | Backups d'un fichier | GET | Oui |
|
||||
| `/api/file/{vault}/diff` | Diff avec une version | GET | Oui |
|
||||
| `/api/file/{vault}/restore` | Restaurer une version | POST | Oui |
|
||||
| `/api/backups` | Lister les backups | GET | Oui |
|
||||
| `/api/backups/content` | Contenu d'un backup | GET | Oui |
|
||||
| `/api/backups/delete` / `/purge` / `/compress` / `/auto` | Gestion & purge | POST | Oui |
|
||||
|
||||
### 3.6 Exports
|
||||
|
||||
| Endpoint | Description | Méthode |
|
||||
|---|---|---|
|
||||
| `/api/export/html` | Exporter en HTML | GET |
|
||||
| `/api/export/md-bundle` | Exporter en bundle Markdown (ZIP) | GET |
|
||||
| `/api/export/epub` | Exporter en ePub | GET |
|
||||
| `/api/guide/download?format=md\|pdf&lang=fr\|en` | Télécharger le guide intégré | GET |
|
||||
|
||||
### 3.7 PDF
|
||||
|
||||
| Endpoint | Description | Méthode |
|
||||
|---|---|---|
|
||||
| `/api/file/{vault}/pdf/info` | Métadonnées sans transfert | GET |
|
||||
| `/api/file/{vault}/pdf/stream` | Streaming (HTTP Range, 206) | GET |
|
||||
|
||||
### 3.8 IA
|
||||
|
||||
| Endpoint | Description | Méthode |
|
||||
|---|---|---|
|
||||
| `/api/ai/status` | Statut des fournisseurs | GET |
|
||||
| `/api/ai/improve`, `/fix-spelling`, `/summarize`, `/translate`, `/rewrite`, `/to-list`, `/to-table`, `/frontmatter`, `/inline-complete`, `/to-canvas`… | Actions éditeur IA | POST |
|
||||
| `/api/ai/model-capabilities?provider=&model=` | Capacités d'un modèle | GET |
|
||||
| `/api/ai/bookslm/*` | Console IA par répertoire | POST/GET |
|
||||
| `/api/ai/skills` | Lister / créer / supprimer des skills | GET/POST/DELETE |
|
||||
| `/api/config/ai-keys` · `/api/config/tool-keys` | Clés fournisseurs & sources | GET/POST/DELETE |
|
||||
|
||||
### 3.9 Authentification & administration
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/auth/status` | Statut de l'auth | GET | — |
|
||||
| `/api/auth/login` · `/refresh` · `/logout` | Cycle de session | POST | — / Cookie / Oui |
|
||||
| `/api/auth/me` | Profil courant | GET/PATCH | Oui |
|
||||
| `/api/auth/change-password` | Changer le mot de passe | POST | Oui |
|
||||
| `/api/auth/mfa/*` | TOTP, WebAuthn, recovery | POST/GET | Oui |
|
||||
| `/api/auth/tokens` | Clés API (voir §2.2) | GET/POST/DELETE | Oui |
|
||||
| `/api/auth/admin/users` | Lister / créer des utilisateurs | GET/POST | Admin |
|
||||
| `/api/auth/admin/users/{u}` | Modifier / supprimer | PATCH/DELETE | Admin |
|
||||
| `/api/admin/stats` · `/audit` · `/backup-stats` · `/stream` | Monitoring admin | GET | Admin |
|
||||
|
||||
> `PATCH /api/auth/me` accepte `{"avatar": "<data-url>"}` (PNG/JPEG/WebP, 400 000
|
||||
> caractères max, octets magiques contrôlés) ; `{"avatar": ""}` supprime la photo.
|
||||
> La valeur est renvoyée par `GET /api/auth/me` et par le payload `user` du login.
|
||||
|
||||
### 3.10 Partage, webhooks, conflits, plugins, push
|
||||
|
||||
| Endpoint | Description | Méthode |
|
||||
|---|---|---|
|
||||
| `/api/share/{vault}` | Créer un lien de partage public | POST |
|
||||
| `/api/shares` | Lister / supprimer les partages | GET/DELETE |
|
||||
| `/api/webhooks` | CRUD webhooks (HMAC-SHA256) | GET/POST/PATCH/DELETE |
|
||||
| `/api/conflicts` · `/api/conflicts/resolve` | Conflits Syncthing | GET/POST |
|
||||
| `/api/plugins` | Installer / activer / désactiver | GET/POST/DELETE |
|
||||
| `/api/push/*` | Abonnement Web Push (VAPID) | GET/POST/DELETE |
|
||||
|
||||
---
|
||||
|
||||
## 4. Exemples `curl`
|
||||
|
||||
```bash
|
||||
BASE=http://localhost:2020
|
||||
TOKEN=$(curl -s -X POST $BASE/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"secret"}' | jq -r .access_token)
|
||||
|
||||
# Santé
|
||||
curl -s $BASE/api/health
|
||||
|
||||
# Lister les vaults
|
||||
curl -s $BASE/api/vaults -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Naviguer
|
||||
curl -s "$BASE/api/browse/Recettes?path=" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Lire un fichier (rendu Markdown)
|
||||
curl -s "$BASE/api/file/Recettes?path=pizza.md" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Lire en brut
|
||||
curl -s "$BASE/api/file/Recettes/raw?path=pizza.md" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Sauvegarder
|
||||
curl -s -X PUT "$BASE/api/file/Recettes/save?path=pizza.md" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"content":"# Pizza\n\nNouvelle recette."}'
|
||||
|
||||
# Recherche avancée
|
||||
curl -s "$BASE/api/search/advanced?q=tag:cuisine%20pizza&vault=all&limit=20&offset=0&sort=relevance" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Autocomplétion
|
||||
curl -s "$BASE/api/suggest?q=piz&vault=all" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Forcer une réindexation
|
||||
curl -s $BASE/api/index/reload -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
> Le mot de passe peut aussi être fourni par une clé API dans `Authorization`.
|
||||
> Quand l'auth est désactivée, omettez l'en-tête.
|
||||
|
||||
---
|
||||
|
||||
## 5. Temps réel
|
||||
|
||||
### 5.1 SSE — `/api/events`
|
||||
|
||||
Flux d'événements de changement d'index (fichiers créés/supprimés/modifiés), avec
|
||||
reconnexion automatique côté client.
|
||||
|
||||
```bash
|
||||
curl -N "$BASE/api/events"
|
||||
```
|
||||
|
||||
### 5.2 WebSocket — collaboration
|
||||
|
||||
`ws(s)://<hôte>/ws/collab/{vault}/{path}` transporte les mises à jour
|
||||
Yjs/CRDT et la présence (curseurs distants). Authentification par cookie
|
||||
`access_token` ou paramètre `?token=`, avec contrôle d'accès par vault.
|
||||
Voir [Édition & collaboration](./COLLABORATION.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Limites et bonnes pratiques
|
||||
|
||||
- **Rate limiting** : les endpoints de login et les outils IA sont limités ;
|
||||
respectez `retry_after` en cas de `429`.
|
||||
- **Permissions** : chaque endpoint fichier vérifie l'accès au vault et rejette
|
||||
les chemins hors vault (path traversal).
|
||||
- **Clés API** : préférez-les aux mots de passe pour les scripts ; révoquez-les
|
||||
dès qu'elles ne servent plus.
|
||||
- **Gros volumes** : utilisez la pagination (`limit`/`offset`) et le streaming
|
||||
HTTP Range pour les PDF.
|
||||
- **Exports** : `md-bundle` et `epub` renvoient un fichier binaire — utilisez
|
||||
`-o` avec `curl`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dépannage
|
||||
|
||||
| Code | Cause probable |
|
||||
|---|---|
|
||||
| `401` | Jeton absent, expiré ou révoqué |
|
||||
| `403` | Compte sans accès à cette vault / réservé admin |
|
||||
| `404` | Vault, fichier ou chemin inexistant |
|
||||
| `409` | Conflit (fichier déjà existant, etc.) |
|
||||
| `422` | Corps de requête invalide (schéma Pydantic) |
|
||||
| `429` | Rate limit dépassé — voir `retry_after` |
|
||||
| `501` | Export PDF indisponible (WeasyPrint/GTK absent) |
|
||||
@@ -0,0 +1,217 @@
|
||||
# 🤖 Guide Assistant IA & Forge
|
||||
|
||||
ObsiGate intègre un **assistant IA** capable de lire, rechercher et modifier vos
|
||||
notes, ainsi qu'un **éditeur IA** (CodeMirror + toolbar) et une console
|
||||
contextuelle par répertoire (**BooksLM**). Ce guide explique comment les
|
||||
configurer et les utiliser.
|
||||
|
||||
> **Fiches techniques :** [`ai-tools-mcp.md`](../features/ai-tools-mcp.md) ·
|
||||
> [`ai-assistant-commands.md`](../features/ai-assistant-commands.md) ·
|
||||
> [`ai-quick-actions.md`](../features/ai-quick-actions.md) ·
|
||||
> [`forge-assistant.md`](../features/forge-assistant.md) ·
|
||||
> [`bookslm.md`](../features/bookslm.md) ·
|
||||
> [`ai-tools-roadmap.md`](../features/ai-tools-roadmap.md)
|
||||
> **Voir aussi :** [Serveur MCP](./MCP.md) · [API REST](./API_REST.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vue d'ensemble
|
||||
|
||||
L'IA d'ObsiGate se compose de plusieurs surfaces complémentaires :
|
||||
|
||||
| Surface | Rôle |
|
||||
|---|---|
|
||||
| **Éditeur IA** | Toolbar d'actions sur le document ouvert (CodeMirror) |
|
||||
| **Assistant IA** | Panneau de discussion avec *function calling* sur vos vaults |
|
||||
| **BooksLM** | Console IA contextuelle sur un **répertoire** (style NotebookLM) |
|
||||
| **Forge** | Éditeur avancé avec assistant IA intégré |
|
||||
| **Outils (tools)** | Lecture, recherche, écriture, opérations destructives (two-step) |
|
||||
| **MCP** | Exposition des mêmes outils à Claude Desktop, Cursor, Cline… |
|
||||
|
||||
---
|
||||
|
||||
## 2. Configurer un fournisseur
|
||||
|
||||
### 2.1 Fournisseurs supportés
|
||||
|
||||
ObsiGate est **multi-fournisseur** :
|
||||
|
||||
- **DeepSeek**
|
||||
- **OpenRouter**
|
||||
- **Google Gemini**
|
||||
|
||||
Chaque fournisseur se configure au choix :
|
||||
|
||||
1. **Depuis l'interface** — menu → Configurations → **Clés API IA**. La clé saisie
|
||||
est stockée dans `data/api_keys.json` et **prime** sur la variable
|
||||
d'environnement.
|
||||
2. **Par variable d'environnement** — voir `.env.example`.
|
||||
|
||||
### 2.2 Modèle et capacités
|
||||
|
||||
L'interface affiche les **capacités** de chaque modèle (8 indicateurs : vision,
|
||||
tool calling, contexte long, etc.), via
|
||||
`GET /api/ai/model-capabilities?provider=&model=`. Le picker de l'assistant
|
||||
propose une recherche de modèle et une bulle d'information ⓘ.
|
||||
|
||||
Vous pouvez définir un **modèle par défaut** et un fournisseur par défaut dans la
|
||||
configuration. Le fournisseur/modèle est **partagé** entre l'assistant et Forge.
|
||||
|
||||
### 2.3 Tester la configuration
|
||||
|
||||
`POST /api/config/ai-keys/test` vérifie qu'une clé fonctionne. En cas d'échec,
|
||||
un message explicite s'affiche.
|
||||
|
||||
---
|
||||
|
||||
## 3. Éditeur IA (toolbar)
|
||||
|
||||
Quand un document Markdown est ouvert dans l'éditeur, une **toolbar IA** propose
|
||||
des actions qui remplacent ou insèrent du contenu. Actions principales :
|
||||
|
||||
| Action | Effet |
|
||||
|---|---|
|
||||
| **Améliorer** | Relecture et amélioration générale |
|
||||
| **Corriger** | Correction orthographique et grammaticale |
|
||||
| **Raccourcir / Allonger** | Ajuste la longueur du texte |
|
||||
| **Simplifier** | Vulgarise le contenu |
|
||||
| **Ton** | Adapte le registre (formel, neutre…) |
|
||||
| **Traduire** | Traduit la sélection ou le document |
|
||||
| **Expliquer** | Explique un passage |
|
||||
| **Résumer** | Produit un résumé |
|
||||
| **Continuer** | Prolonge le texte |
|
||||
| **Réécrire** | Réécriture personnalisée libre |
|
||||
| **En liste / En tableau** | Convertit en liste à puces ou tableau Markdown |
|
||||
| **Frontmatter** | Génère ou met à jour le frontmatter YAML |
|
||||
| **Complétion inline** | `Ctrl + J` — complétion directement dans l'éditeur |
|
||||
| **En canvas** | Transforme en diagramme canvas |
|
||||
|
||||
> Les actions sont exposées par `backend/ai_routes.py` (préfixe `/api/ai`). Le
|
||||
> contexte ad-hoc (fichiers ouverts, répertoire, recherche, récents) est injecté
|
||||
> automatiquement.
|
||||
|
||||
---
|
||||
|
||||
## 4. Forge et Editer
|
||||
|
||||
- **Editer** ouvre le document dans l'éditeur CodeMirror classique.
|
||||
- **Forge** ouvre l'**éditeur avancé** : mêmes capacités d'édition, mais avec
|
||||
l'**assistant IA partagé** intégré (bouton AI Panel), insertion rapide
|
||||
(`Alt + I`), aide (`F1`) et mode plein écran.
|
||||
|
||||
Dans les deux cas, `Editer` et `Forge` **remplacent** la vue lecture ; revenez en
|
||||
lecture avec `✓` / `×` ou `Échap`. Le panneau de l'assistant reste accessible à
|
||||
côté.
|
||||
|
||||
---
|
||||
|
||||
## 5. Assistant IA & BooksLM
|
||||
|
||||
### 5.1 Discussion avec outils
|
||||
|
||||
L'assistant (panneau latéral) discute et **appelle des outils** pour agir sur
|
||||
vos vaults : `list_vaults`, `read_file`, `search_fulltext`, `get_backlinks`,
|
||||
`list_tags`, etc. Les opérations d'écriture passent par une **confirmation en
|
||||
deux temps** (aperçu + jeton, puis application).
|
||||
|
||||
### 5.2 Contexte `@`
|
||||
|
||||
Tapez `@` pour attacher :
|
||||
|
||||
- un **fichier** (chip de contexte) ;
|
||||
- un **répertoire** (chip de contexte) ;
|
||||
- une **image** (pièce jointe, si le modèle gère la vision).
|
||||
|
||||
Le menu est alimenté par `/api/tree-search` (repli sur la liste des fichiers du
|
||||
vault). Les chips sont retirables et rechargent le contexte.
|
||||
|
||||
### 5.3 Commandes `/` et skills
|
||||
|
||||
Tapez `/` pour ouvrir le **menu de commandes** (navigation `↑`/`↓`/`Entrée`/`Échap`).
|
||||
|
||||
**30 skills intégrés**, répartis par familles :
|
||||
|
||||
| Famille | Exemples |
|
||||
|---|---|
|
||||
| Base | `/research`, `/resume`, `/reformuler`, `/correction`, `/brainstorm`, `/plan`, `/ask`, `/meeting-note`, `/livrable` |
|
||||
| Extraction & structuration | `/extract`, `/timeline`, `/glossary`, `/tag` |
|
||||
| Transformation & adaptation | `/translate`, `/adapt`, `/clean`, `/summary-progressive` |
|
||||
| Analyse critique & décision | `/critique`, `/compare`, `/prioritize`, `/swot`, `/debate` |
|
||||
| Apprentissage & mémorisation | `/quiz`, `/reading-note`, `/qa-generator` |
|
||||
| Méta-gestion & confidentialité | `/link`, `/anonymize`, `/estimate` |
|
||||
|
||||
Chaque skill applique un bloc de règles commun (français, notes traitées comme
|
||||
données, anti-hallucination, conservation des noms/dates/chiffres).
|
||||
|
||||
**Skills utilisateur** : `/create-new-skill` ouvre une modale et persiste le
|
||||
skill dans `data/skills.json` (par utilisateur). Ils sont listés par
|
||||
`GET /api/ai/skills` et supprimables.
|
||||
|
||||
**Commandes admin** (exécutées localement, sans LLM) : `/help`, `/providers`,
|
||||
`/provider <nom>`, `/model <nom>`, `/keys`.
|
||||
|
||||
### 5.4 Actions rapides
|
||||
|
||||
Un catalogue de **25 actions** en 6 catégories est proposé sous forme de boutons
|
||||
contextuels (« Résumer en 3 points », « Checklist d'actions », « Générer le
|
||||
frontmatter », « Expliquer le code », « Fusionner », « Traduire »…). Un tiroir
|
||||
**« Toutes les actions »** permet de rechercher dans le catalogue.
|
||||
|
||||
### 5.5 Deep Research
|
||||
|
||||
Le mode **Deep Research** enchaîne recherche web et synthèse. Il est activé via
|
||||
le panneau **« + »** de l'assistant (fichiers, contextes, skills, Deep Research).
|
||||
|
||||
### 5.6 Historique
|
||||
|
||||
Les conversations sont **persistées côté backend** et accessibles depuis la
|
||||
sidebar « Historique IA », avec filtre de recherche.
|
||||
|
||||
---
|
||||
|
||||
## 6. Outils (function calling)
|
||||
|
||||
Les outils sont définis dans `backend/tools/` — **source unique de vérité**,
|
||||
partagée par l'assistant in-app et le serveur MCP.
|
||||
|
||||
| Catégorie | Outils |
|
||||
|---|---|
|
||||
| Vaults / navigation | `list_vaults`, `list_directory`, `list_all_files` |
|
||||
| Lecture | `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` |
|
||||
| Recherche | `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` |
|
||||
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
|
||||
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
|
||||
| Web / sources connectées | `web_search`, `fetch_url`, sources Gitea/GitHub… |
|
||||
|
||||
Les mutations suivent un flux **two-step** : `propose_<tool>` renvoie un aperçu
|
||||
et un **jeton signé à usage unique**, puis `apply_<tool>` exécute.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sécurité
|
||||
|
||||
- **Permissions par vault** appliquées à chaque outil.
|
||||
- **Anti path-traversal** via `resolve_safe_path`.
|
||||
- **Confirmation two-step** pour toute mutation.
|
||||
- **Toggle `aiDestructiveTools`** par vault : le désactiver bloque
|
||||
rename/move/replace/delete, sans bloquer create/edit/append.
|
||||
- **Backup automatique** avant chaque opération destructive.
|
||||
- **Rate limiting** par identité et par outil.
|
||||
- **Redaction des secrets** dans tous les retours d'outils.
|
||||
- **Audit** de chaque appel (`data/audit.log`, action `ai_tool_call`).
|
||||
|
||||
Détails : [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) et
|
||||
[`MCP.md`](./MCP.md) §5.
|
||||
|
||||
---
|
||||
|
||||
## 8. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| « Aucun fournisseur configuré » | Saisir une clé API (Configurations → Clés API IA) et la tester |
|
||||
| L'IA n'a pas accès à un fichier | Vérifier `list_vaults` et les permissions du compte |
|
||||
| L'image est refusée | Le modèle ne supporte pas la vision (400) — choisir un modèle multimodal |
|
||||
| Une mutation reste bloquée | Vérifier `aiDestructiveTools` et le flux `propose_` → `apply_` |
|
||||
| Quota d'outils atteint | Respecter `OBSIGATE_TOOL_RATE_LIMIT` / `retry_after` |
|
||||
| Réponse tronquée | Ajuster `BOOKSLM_MAX_TOOL_READ_BYTES` / le modèle |
|
||||
@@ -0,0 +1,234 @@
|
||||
# 🔒 Guide Authentification & sécurité
|
||||
|
||||
ObsiGate embarque un système d'authentification optionnel **JWT + Argon2id**,
|
||||
un contrôle d'accès **par vault**, du MFA (TOTP, WebAuthn, codes de secours) et
|
||||
des mécanismes de durcissement. Ce guide couvre l'activation, la gestion des
|
||||
comptes et les bonnes pratiques.
|
||||
|
||||
> **Public :** administrateurs · **Voir aussi :**
|
||||
> [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md) ·
|
||||
> [API REST](./API_REST.md) · [MCP](./MCP.md) · [Déploiement Docker](./DEPLOIEMENT_DOCKER.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vue d'ensemble
|
||||
|
||||
- **Désactivée par défaut** (`OBSIGATE_AUTH_ENABLED=false`) — compatible avec
|
||||
toutes les installations existantes.
|
||||
- Quand elle est activée, l'écran de connexion s'affiche et chaque endpoint
|
||||
vérifie l'utilisateur et ses permissions.
|
||||
- Les données d'auth (`users.json`, `secret.key`, `api_tokens.json`) vivent dans
|
||||
`/app/data` — **montez ce dossier en volume** pour les persister.
|
||||
|
||||
---
|
||||
|
||||
## 2. Activer l'authentification
|
||||
|
||||
### 2.1 Fichier `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```bash
|
||||
OBSIGATE_AUTH_ENABLED=true
|
||||
OBSIGATE_ADMIN_USER=admin
|
||||
OBSIGATE_ADMIN_PASSWORD=votre_mot_de_passe # vide = auto-généré (voir logs)
|
||||
# OBSIGATE_SECURE_COOKIES=false # true si derrière HTTPS
|
||||
```
|
||||
|
||||
### 2.2 `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
env_file:
|
||||
- .env
|
||||
```
|
||||
|
||||
> **Ne mettez jamais de mot de passe dans `docker-compose.yml` !** Utilisez
|
||||
> toujours `.env` (non committé).
|
||||
|
||||
### 2.3 Premier démarrage
|
||||
|
||||
Si aucun utilisateur n'existe, ObsiGate crée un compte admin et affiche le mot de
|
||||
passe **une seule fois dans les logs** :
|
||||
|
||||
```bash
|
||||
docker compose logs obsigate | grep -A4 "FIRST"
|
||||
```
|
||||
|
||||
```
|
||||
============================================================
|
||||
FIRST STARTUP — Admin account created automatically
|
||||
Username : admin
|
||||
Password : xK9mQ3pLr7wN2jT5
|
||||
CHANGE THIS PASSWORD on first login!
|
||||
============================================================
|
||||
```
|
||||
|
||||
Changez-le immédiatement (menu profil → *Changer le mot de passe*).
|
||||
|
||||
Vous pouvez aussi ajouter une **photo de profil** : *Configurations → Profil →
|
||||
Choisir une image* (PNG, JPG ou WEBP, 8 Mo maximum — recadrée en carré 256 px).
|
||||
Elle remplace les initiales dans le cercle du compte en bas de la sidebar et peut
|
||||
être supprimée à tout moment depuis la même section.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gestion des utilisateurs
|
||||
|
||||
### 3.1 Interface d'administration
|
||||
|
||||
Un compte **admin** voit une icône 🛡️ dans le header. Le panneau permet de :
|
||||
|
||||
- lister tous les utilisateurs ;
|
||||
- créer / modifier / supprimer des comptes ;
|
||||
- assigner les vaults accessibles par utilisateur ;
|
||||
- activer / désactiver des comptes.
|
||||
|
||||
### 3.2 Ligne de commande
|
||||
|
||||
```bash
|
||||
# Créer un utilisateur
|
||||
docker exec obsigate python backend/create_admin.py create alice MotDePasse --role user --vaults Recettes IT
|
||||
|
||||
# Créer un admin avec accès total
|
||||
docker exec obsigate python backend/create_admin.py create bob SecretPass --role admin --vaults "*"
|
||||
|
||||
# Lister
|
||||
docker exec obsigate python backend/create_admin.py list
|
||||
|
||||
# Supprimer
|
||||
docker exec obsigate python backend/create_admin.py delete alice
|
||||
```
|
||||
|
||||
### 3.3 Contrôle d'accès par vault
|
||||
|
||||
| Valeur `vaults` | Accès |
|
||||
|---|---|
|
||||
| `["*"]` | Toutes les vaults (y compris futures) — défaut admin |
|
||||
| `["Recettes", "IT"]` | Uniquement ces vaults |
|
||||
| `[]` | Aucun accès |
|
||||
|
||||
Les permissions sont revérifiées à chaque requête (et à chaque connexion
|
||||
WebSocket de collaboration).
|
||||
|
||||
---
|
||||
|
||||
## 4. MFA (authentification multifacteur)
|
||||
|
||||
ObsiGate propose trois secondes facteurs, configurables par l'utilisateur.
|
||||
|
||||
### 4.1 TOTP (application d'authentification)
|
||||
|
||||
1. Menu profil → **Sécurité** → *Configurer TOTP* (`POST /api/auth/mfa/totp/setup`).
|
||||
2. Scannez le QR code avec Google Authenticator, Authy, etc.
|
||||
3. Validez le code (`POST /api/auth/mfa/totp/enable`).
|
||||
4. Désactivation : `POST /api/auth/mfa/totp/disable` (mot de passe requis).
|
||||
|
||||
### 4.2 Clés de sécurité & biométrie (WebAuthn)
|
||||
|
||||
- Enregistrement : `POST /api/auth/mfa/webauthn/register/options` puis
|
||||
`POST /api/auth/mfa/webauthn/register`.
|
||||
- Connexion : `POST /api/auth/mfa/webauthn/options` puis `/verify`.
|
||||
- Gestion des clés : `GET /api/auth/mfa/webauthn/credentials`,
|
||||
`POST /api/auth/mfa/webauthn/credentials/remove`.
|
||||
|
||||
> Le *relying party* (domaine) est **dérivé de la requête** (hôte exact, port
|
||||
> inclus) ; derrière un reverse proxy, activez `OBSIGATE_TRUST_PROXY=true` pour
|
||||
> que `X-Forwarded-Host/Proto` soient pris en compte.
|
||||
|
||||
### 4.3 Codes de secours
|
||||
|
||||
À l'activation du MFA, des **codes de récupération** sont générés. Utilisez-en un
|
||||
via `POST /api/auth/mfa/recovery` si vous perdez votre second facteur. Conservez-
|
||||
les hors ligne.
|
||||
|
||||
### 4.4 Statut
|
||||
|
||||
`GET /api/auth/mfa/status` indique les facteurs actifs pour le compte courant.
|
||||
|
||||
---
|
||||
|
||||
## 5. Clés API & MCP
|
||||
|
||||
Pour les scripts et les clients externes, créez une **clé API longue durée**
|
||||
(1 j, 1 mois, 6 mois, 1 an, sans fin) depuis Configurations → 🔑 **Clés API &
|
||||
MCP**. Une seule clé authentifie l'API REST **et** le serveur MCP.
|
||||
|
||||
- Le secret n'est **affiché qu'une fois** (pattern GitHub) et n'est jamais persisté.
|
||||
- La révocation est **immédiate** des deux côtés.
|
||||
- Une colonne « dernière utilisation » (throttlée) aide à repérer les clés
|
||||
dormantes.
|
||||
|
||||
Détails : [API REST §2.2](./API_REST.md#22-clés-api-longue-durée-recommandé-pour-scripts--mcp)
|
||||
et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Mécanismes de durcissement
|
||||
|
||||
| Mécanisme | Détail |
|
||||
|---|---|
|
||||
| **Path traversal** | Chaque endpoint fichier valide que le chemin résolu reste dans la vault |
|
||||
| **Rate limiting** | 10 tentatives de login max par IP / 15 min + lockout par compte |
|
||||
| **Rate limiting MFA** | Appliqué aux endpoints TOTP/WebAuthn/recovery |
|
||||
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
|
||||
| **Backup automatique** | Avant chaque modification/suppression dans `.obsigate-backup/` |
|
||||
| **Redaction** | Masquage des JWT, clés API, tokens dans les aperçus et retours d'outils |
|
||||
| **CSP** | `object-src`, `base-uri`, `form-action`, `frame-ancestors` restreints |
|
||||
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
|
||||
| **Utilisateur non-root** | Conteneur sous `obsigate` (UID 1000) |
|
||||
| **Volumes read-only** | Vaults montées `:ro` par défaut |
|
||||
| **Atomic writes** | `users.json`, `shares.json`, `webhooks.json` écrits en tmp+replace |
|
||||
| **Symlinks ignorés** | L'index n'indexe pas les liens symboliques |
|
||||
|
||||
### Politique de mot de passe
|
||||
|
||||
Une politique minimale est validée à la création d'un compte. Choisissez des mots
|
||||
de passe longs et uniques ; activez le MFA pour les comptes admin.
|
||||
|
||||
---
|
||||
|
||||
## 7. Variables d'environnement
|
||||
|
||||
| Variable | Description | Défaut |
|
||||
|---|---|---|
|
||||
| `OBSIGATE_AUTH_ENABLED` | Activer l'authentification | `false` |
|
||||
| `OBSIGATE_ADMIN_USER` | Nom de l'admin auto-créé | `admin` |
|
||||
| `OBSIGATE_ADMIN_PASSWORD` | Mot de passe admin (vide = auto-généré) | *(auto)* |
|
||||
| `OBSIGATE_SECURE_COOKIES` | Cookie `Secure` (HTTPS uniquement) | `false` |
|
||||
| `OBSIGATE_ACCESS_TOKEN_TTL` | Durée de vie du token d'accès (s) | `3600` |
|
||||
| `OBSIGATE_REFRESH_TOKEN_TTL` | Durée de vie du refresh token (s) | `2592000` |
|
||||
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Tentatives de login max par IP | `10` |
|
||||
| `OBSIGATE_ACCOUNT_MAX_ATTEMPTS` | Tentatives de login max par compte | `10` |
|
||||
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Fenêtre de rate limiting (s) | `900` |
|
||||
| `OBSIGATE_TRUST_PROXY` | Faire confiance à `X-Forwarded-For` / `Host` | `false` |
|
||||
|
||||
Toutes ces variables sont documentées dans `.env.example`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Déploiement sécurisé (checklist)
|
||||
|
||||
- [ ] `OBSIGATE_AUTH_ENABLED=true` sur toute instance exposée.
|
||||
- [ ] Mot de passe admin fort, changé après le premier démarrage.
|
||||
- [ ] MFA activé pour les comptes admin.
|
||||
- [ ] HTTPS via reverse proxy + `OBSIGATE_SECURE_COOKIES=true`.
|
||||
- [ ] `OBSIGATE_TRUST_PROXY=true` **uniquement** derrière un proxy de confiance.
|
||||
- [ ] Volume `./data:/app/data` monté et **sauvegardé**.
|
||||
- [ ] Vaults montées en `:ro` (lecture seule) sauf besoin d'écriture.
|
||||
- [ ] Clés API révoquées dès qu'elles ne servent plus.
|
||||
- [ ] Accès réseau restreint (VPN / pare-feu) si possible.
|
||||
|
||||
---
|
||||
|
||||
## 9. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| Login bloqué `429` | Rate limit : attendre la fenêtre (`OBSIGATE_LOGIN_WINDOW_SECONDS`) |
|
||||
| WebAuthn refuse l'enregistrement | Domaine/port non dérivés — activer `OBSIGATE_TRUST_PROXY` derrière un proxy |
|
||||
| TOTP « challenge inattendu » | Relancer la cérémonie ; les 5 derniers challenges sont acceptés |
|
||||
| Perte du second facteur | Utiliser un code de secours (`/api/auth/mfa/recovery`) |
|
||||
| Sessions perdues au redémarrage | Le volume `./data` n'est pas monté |
|
||||
| Clé API `401` | Clé expirée ou révoquée — en créer une nouvelle |
|
||||
@@ -0,0 +1,86 @@
|
||||
# 📝 Guide Édition & collaboration temps réel
|
||||
|
||||
Plusieurs utilisateurs peuvent éditer le **même document Markdown
|
||||
simultanément**, façon Google Docs, grâce à Yjs (CRDT) et à un canal WebSocket.
|
||||
Ce guide explique le fonctionnement et l'utilisation.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Fiche technique :**
|
||||
> [`features/collaboration.md`](../features/collaboration.md)
|
||||
> **Voir aussi :** [Prise en main](./PRISE_EN_MAIN.md) · [API REST](./API_REST.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Ce que fait la collaboration
|
||||
|
||||
- **Fusion sans conflit** via **Yjs (CRDT)** : deux personnes peuvent taper au
|
||||
même endroit, aucune modification n'est perdue.
|
||||
- **Curseurs distants colorés** et sélections visibles dans CodeMirror, étiquetés
|
||||
avec le nom de chaque utilisateur.
|
||||
- **Indicateur de présence** dans l'en-tête de l'éditeur (avatars + statut de
|
||||
connexion).
|
||||
- **Reconnexion automatique** (backoff exponentiel) : l'état est fusionné au retour.
|
||||
- **Persistance serveur** : le document est écrit sur disque **2 s** après la
|
||||
dernière modification.
|
||||
|
||||
---
|
||||
|
||||
## 2. Utilisation
|
||||
|
||||
Aucune configuration n'est nécessaire :
|
||||
|
||||
1. Ouvrez le même fichier dans **deux navigateurs** (ou deux fenêtres).
|
||||
2. Passez en mode **Editer** (ou **Forge**) dans les deux.
|
||||
3. Tapez : les modifications apparaissent en temps réel des deux côtés, avec les
|
||||
curseurs de chacun.
|
||||
|
||||
> L'édition collaborative nécessite que la vault soit **accessible en écriture**
|
||||
> (le volume Docker doit être monté **sans** `:ro` pour les vaults modifiables).
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport & protocole
|
||||
|
||||
| Élément | Valeur |
|
||||
|---|---|
|
||||
| Endpoint | `ws(s)://<hôte>/ws/collab/{vault}/{chemin}` |
|
||||
| Authentification | Cookie `access_token` (ou paramètre `?token=`) |
|
||||
| Autorisation | Contrôle d'accès **par vault** appliqué à chaque connexion |
|
||||
| Protocole | Yjs / CRDT — updates + awareness (curseurs) |
|
||||
| Persistance | Écriture disque débouncée (2 s) côté serveur |
|
||||
|
||||
Le canal est mis à niveau à partir de la même origine que l'application. Derrière
|
||||
un reverse proxy, autorisez les **upgrades WebSocket** et augmentez
|
||||
`proxy_read_timeout` (voir [Déploiement Docker](./DEPLOIEMENT_DOCKER.md)).
|
||||
|
||||
---
|
||||
|
||||
## 4. Sécurité
|
||||
|
||||
- L'accès au document est **revérifié à la connexion** (permissions du compte).
|
||||
- Un utilisateur sans droit sur la vault ne peut pas rejoindre la session.
|
||||
- Les échanges passent par le même domaine que l'application (pas de serveur
|
||||
tiers).
|
||||
|
||||
---
|
||||
|
||||
## 5. Limitations & bonnes pratiques
|
||||
|
||||
- La collaboration vise les fichiers **Markdown**.
|
||||
- Évitez d'éditer le même fichier simultanément depuis ObsiGate **et** une
|
||||
application de synchronisation externe (risque de conflits au niveau fichier).
|
||||
- Le document est écrit après un court délai ; attendez la fin de la sauvegarde
|
||||
avant de fermer brutalement l'onglet.
|
||||
- En cas de conflit de synchronisation externe (Syncthing), l'écran
|
||||
**Conflits** (`/api/conflicts`) aide à résoudre.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| Les curseurs des autres n'apparaissent pas | Vérifier le WebSocket (proxy sans support `Upgrade`) |
|
||||
| Reconnecté sans cesse | Réseau instable ou timeout proxy trop court |
|
||||
| Modifications non persistées | Vault montée en lecture seule (`:ro`) ? |
|
||||
| `401` à la connexion | Session expirée — se reconnecter |
|
||||
| Accès refusé | Le compte n'a pas la permission sur cette vault |
|
||||
@@ -0,0 +1,221 @@
|
||||
# 🐳 Guide de déploiement Docker
|
||||
|
||||
Ce guide couvre l'installation, la configuration et l'exploitation d'ObsiGate
|
||||
avec Docker / Docker Compose, y compris le reverse proxy HTTPS et les mises à jour.
|
||||
|
||||
> **Public :** administrateurs, ops
|
||||
> **Voir aussi :** [Prise en main](./PRISE_EN_MAIN.md) ·
|
||||
> [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) ·
|
||||
> [`DEVELOPMENT_AND_RELEASES.md`](../DEVELOPMENT_AND_RELEASES.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prérequis
|
||||
|
||||
| Composant | Version minimale |
|
||||
|---|---|
|
||||
| Docker | ≥ 20.10 |
|
||||
| docker-compose | ≥ 2.0 |
|
||||
| Espace disque | ~200 Mo pour l'image |
|
||||
|
||||
Systèmes supportés : Linux (Ubuntu, Debian…), macOS (Intel & Apple Silicon),
|
||||
Windows (Docker Desktop), NAS compatibles Docker (Synology, QNAP…).
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration de `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
obsigate:
|
||||
build:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2020:8080" # port local 2020 → conteneur 8080
|
||||
volumes:
|
||||
- /home/user/Documents/Obsidian-Recettes:/vaults/Recettes:ro
|
||||
- /home/user/Documents/Obsidian-IT:/vaults/IT:ro
|
||||
- ./data:/app/data # persistance auth/config/backups
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
- VAULT_1_PATH=/vaults/Recettes
|
||||
- VAULT_2_NAME=IT
|
||||
- VAULT_2_PATH=/vaults/IT
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
env_file:
|
||||
- .env # secrets (mot de passe admin…)
|
||||
```
|
||||
|
||||
> **Important :** les chemins de vaults doivent être **absolus** et montés en
|
||||
> **lecture seule** (`:ro`) sauf si vous voulez autoriser l'édition depuis
|
||||
> ObsiGate. Le dossier `./data` doit être **persistant**.
|
||||
|
||||
### Variables de vault
|
||||
|
||||
| Variable | Description | Exemple |
|
||||
|---|---|---|
|
||||
| `VAULT_N_NAME` | Nom affiché | `Recettes` |
|
||||
| `VAULT_N_PATH` | Chemin dans le conteneur | `/vaults/Recettes` |
|
||||
| `VAULT_N_ATTACHMENTS_PATH` | Dossier d'attachements (optionnel) | `Assets/Images` |
|
||||
| `VAULT_N_SCAN_ATTACHMENTS` | Scanner les images au démarrage | `true` |
|
||||
|
||||
**Nommage :** lettres, chiffres et tirets uniquement ; le nom doit correspondre au
|
||||
chemin interne.
|
||||
|
||||
---
|
||||
|
||||
## 3. Construire et lancer
|
||||
|
||||
### 3.1 Script `build.sh` (recommandé)
|
||||
|
||||
```bash
|
||||
chmod +x build.sh # une seule fois
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Le script :
|
||||
|
||||
1. vérifie Docker et Docker Compose (versions) ;
|
||||
2. valide `docker-compose.yml` (présence + syntaxe) ;
|
||||
3. contrôle chaque volume monté (avertit si la source n'existe pas) ;
|
||||
4. construit l'image (multi-stage, ~180 Mo) ;
|
||||
5. démarre le conteneur ;
|
||||
6. affiche le statut puis les logs en temps réel.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--help`, `-h` | Aide complète |
|
||||
| `--build-only` | Construire sans démarrer |
|
||||
| `--no-cache` | Rebuild complet sans cache **(défaut)** |
|
||||
| `--cache` | Utiliser le cache Docker (plus rapide) |
|
||||
| `--progress=plain` / `--progress=tty` | Sortie verbeuse / interactive |
|
||||
|
||||
### 3.2 Alternative manuelle
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3.3 Exploitation
|
||||
|
||||
```bash
|
||||
docker compose down # arrêter
|
||||
docker compose up -d # redémarrer sans rebuild
|
||||
docker compose logs -f # logs temps réel
|
||||
docker compose logs --tail=100 obsigate
|
||||
```
|
||||
|
||||
> **Compatibilité Docker :** l'image utilise une variante `uvicorn` minimale et
|
||||
> `fastapi 0.110.3` pour éviter des dépendances natives optionnelles
|
||||
> (`watchfiles`, `uvloop`, `httptools`, `fastapi-cli`…) qui échouent sur Alpine,
|
||||
> ARM ou i386.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reverse proxy & HTTPS
|
||||
|
||||
ObsiGate sert du HTTP en clair ; placez un reverse proxy devant pour TLS.
|
||||
|
||||
### 4.1 Nginx (exemple)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name obsigate.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/obsigate.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/obsigate.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:2020;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade; # WebSocket collab
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s; # SSE / WebSocket
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Variables à activer derrière un proxy
|
||||
|
||||
```bash
|
||||
OBSIGATE_SECURE_COOKIES=true # cookie Secure (HTTPS uniquement)
|
||||
OBSIGATE_TRUST_PROXY=true # confiance à X-Forwarded-For / Host
|
||||
```
|
||||
|
||||
> N'activez `OBSIGATE_TRUST_PROXY` **que** derrière un proxy de confiance, sinon
|
||||
> l'adresse IP client peut être usurpée (rate limiting, audit).
|
||||
|
||||
Cloudflare Tunnel, Caddy et Traefik fonctionnent de la même façon (pensez au
|
||||
support WebSocket et aux longs timeouts pour le SSE).
|
||||
|
||||
---
|
||||
|
||||
## 5. Healthcheck & supervision
|
||||
|
||||
L'image intègre un healthcheck sur `/api/health` (statut, version, stats). Vous
|
||||
pouvez aussi l'interroger depuis l'hôte :
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:2020/api/health
|
||||
curl -s http://localhost:2020/api/health/detailed # admin
|
||||
```
|
||||
|
||||
`/api/admin/stream` fournit un flux d'administration (admin uniquement).
|
||||
|
||||
---
|
||||
|
||||
## 6. Mises à jour
|
||||
|
||||
```bash
|
||||
git pull
|
||||
./build.sh # reconstruit et redémarre
|
||||
```
|
||||
|
||||
Vos données (`./data`) et vos vaults (volumes `:ro`) sont conservées. Pour un
|
||||
rebuild propre sans cache : `./build.sh --no-cache`.
|
||||
|
||||
> **Version :** le fichier `VERSION` à la racine est la source unique de vérité ;
|
||||
> l'image et l'UI affichent la même version. Voir
|
||||
> [`DEVELOPMENT_AND_RELEASES.md`](../DEVELOPMENT_AND_RELEASES.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Sauvegardes
|
||||
|
||||
- **Données applicatives** : sauvegardez `./data` (utilisateurs, clés, partages,
|
||||
webhooks, jetons).
|
||||
- **Vos notes** : ObsiGate n'écrit dans les vaults que si elles sont montées en
|
||||
écriture. Un backup automatique interne est créé dans `.obsigate-backup/` avant
|
||||
chaque modification (rotation 10 Mo d'audit).
|
||||
- **Backups desktop** : voir [Desktop](./DESKTOP.md).
|
||||
|
||||
---
|
||||
|
||||
## 8. Multi-plateforme
|
||||
|
||||
L'image est publiée pour `linux/amd64`, `linux/arm64`, `linux/arm/v7` et
|
||||
`linux/386`. Sur un NAS ou un Raspberry Pi, choisissez la variante correspondante
|
||||
(Buildx / `platform:` dans le compose).
|
||||
|
||||
---
|
||||
|
||||
## 9. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| Port déjà utilisé | `sudo netstat -tulpn \| grep 2020` puis changer `ports: "2021:8080"` |
|
||||
| Vault introuvable | Chemin absolu, permissions de lecture, redémarrer après modif |
|
||||
| Build qui échoue | `docker system prune -f` puis `./build.sh --progress=plain` |
|
||||
| Logs | `docker compose logs -f obsigate` |
|
||||
| Widgets temps réel inopérants derrière un proxy | Autoriser les upgrades WebSocket et augmenter `proxy_read_timeout` |
|
||||
| Login « insecure cookie » | Passer en HTTPS ou retirer `OBSIGATE_SECURE_COOKIES` |
|
||||
@@ -0,0 +1,201 @@
|
||||
# 🖥️ Guide de l'application desktop (Tauri)
|
||||
|
||||
ObsiGate Desktop est une application native construite avec
|
||||
[Tauri](https://tauri.app/) (Rust + webview système). Elle embarque le backend
|
||||
Python et le frontend dans un exécutable autonome — **zéro Docker, zéro ligne de
|
||||
commande**.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Statut :** version 2.x, binaires en
|
||||
> cours de stabilisation (build depuis les sources recommandé)
|
||||
> **Fiche technique :** [`features/desktop-tauri.md`](../features/desktop-tauri.md) ·
|
||||
> **Checklist E2E :** [`DESKTOP_E2E_CHECKLIST.md`](../DESKTOP_E2E_CHECKLIST.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Fonctionnalités natives
|
||||
|
||||
| Fonctionnalité | Web | Desktop |
|
||||
|---|---|---|
|
||||
| Accès fichiers local | Via upload | Natif (sélecteur de dossier) |
|
||||
| Thème système | Manuel | Auto (suit l'OS clair/sombre) |
|
||||
| Notifications | Service Worker | Natif OS |
|
||||
| Association `.md` | ❌ | ✅ « Ouvrir avec ObsiGate » |
|
||||
| Icône de barre des tâches (tray) | ❌ | ✅ |
|
||||
| Auto-update | ❌ | ✅ (vérifie les releases Gitea) |
|
||||
| Mode hors-ligne | Limité | Complet (backend local) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Téléchargement des binaires
|
||||
|
||||
Les releases sont publiées sur
|
||||
[Gitea](https://git.dracodev.net/Projets/ObsiGate/releases) :
|
||||
|
||||
| Plateforme | Formats |
|
||||
|---|---|
|
||||
| **Linux** | `.deb` + `.AppImage` |
|
||||
| **Windows** | `.msi` + `.exe` (NSIS) |
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
# .deb (Debian / Ubuntu / Deepin)
|
||||
sudo dpkg -i obsigate_2.0.0_amd64.deb
|
||||
# Lancer : ObsiGate depuis le menu applications, ou `obsigate-desktop`
|
||||
|
||||
# .AppImage (toute distribution)
|
||||
chmod +x ObsiGate_2.0.0_amd64.AppImage
|
||||
./ObsiGate_2.0.0_amd64.AppImage
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```cmd
|
||||
:: Double-cliquer sur ObsiGate_2.0.0_x64.msi (ou le setup NSIS)
|
||||
:: Ou lancer ObsiGate depuis le menu Démarrer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Démarrage
|
||||
|
||||
1. **Lancez l'application** depuis le menu ou la ligne de commande.
|
||||
2. Le backend Python démarre automatiquement sur `127.0.0.1:17890`
|
||||
(splash « Démarrage… » pendant le boot).
|
||||
3. La fenêtre s'ouvre et charge l'interface ObsiGate.
|
||||
4. **Premier lancement** : sélectionnez le dossier de vos vaults Obsidian via le
|
||||
sélecteur natif.
|
||||
5. Pour fermer : icône tray → **Quitter** (arrêt propre du backend).
|
||||
|
||||
---
|
||||
|
||||
## 4. Construire depuis les sources
|
||||
|
||||
Guide détaillé : [`desktop/README.md`](../../desktop/README.md).
|
||||
|
||||
### 4.1 Prérequis communs
|
||||
|
||||
| Outil | Version | Installation |
|
||||
|---|---|---|
|
||||
| Rust (cargo) | ≥ 1.75 | `rustup` |
|
||||
| Tauri CLI | ≥ 2.0 | `cargo install tauri-cli` |
|
||||
| Git | — | — |
|
||||
| Dépendances système Linux | — | `sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev` |
|
||||
|
||||
> **Important — staging :** `tauri.conf.json` embarque `backend/**` et
|
||||
> `frontend/**` **depuis le dossier `desktop/`**. Les scripts de build copient
|
||||
> automatiquement `../backend` et `../frontend` dans `desktop/` avant
|
||||
> `cargo tauri build`. Sans ce staging, le build échoue avec
|
||||
> « glob pattern backend/**/* path not found ».
|
||||
|
||||
### 4.2 Windows — `build-windows.bat`
|
||||
|
||||
```cmd
|
||||
REM Prérequis (via Scoop) : rustup, curl, git
|
||||
scoop install rustup curl git
|
||||
rustup default stable
|
||||
cargo install tauri-cli
|
||||
|
||||
cd desktop
|
||||
build-windows.bat
|
||||
```
|
||||
|
||||
Étapes du script :
|
||||
|
||||
1. Tue les processus Python résiduels (`taskkill /F /IM python.exe`).
|
||||
2. Télécharge **Python 3.11 embed** (python.org) → `desktop\python-embed\` +
|
||||
active pip (`python311._pth`).
|
||||
3. `pip install -r ..\backend\requirements.txt` dans l'embed.
|
||||
4. **Staging** : copie `..\backend` et `..\frontend` dans `desktop\`.
|
||||
5. `cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis`.
|
||||
6. Copie `python-embed` à côté de l'exécutable pour le mode dev local.
|
||||
7. Nettoie les dossiers stagés.
|
||||
|
||||
→ **Artefact :** `desktop\target\x86_64-pc-windows-msvc\release\bundle\nsis\ObsiGate_2.0.0_x64-setup.exe`
|
||||
|
||||
### 4.3 Linux — `build-linux.sh`
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
chmod +x build-linux.sh
|
||||
./build-linux.sh
|
||||
```
|
||||
|
||||
Étapes du script :
|
||||
|
||||
1. Vérifie Rust + Tauri CLI, installe les dépendances système (apt).
|
||||
2. Crée un venv `desktop/python-embed/venv` + `pip install -r ../backend/requirements.txt`.
|
||||
3. **Staging** : copie `../backend` et `../frontend` dans `desktop/`.
|
||||
4. `cargo tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage`.
|
||||
5. Copie le runtime (`python-embed/`, `backend/`, `frontend/`) à côté de l'exécutable.
|
||||
|
||||
→ **Artefacts :**
|
||||
|
||||
- `desktop/target/x86_64-unknown-linux-gnu/release/bundle/deb/obsigate_2.0.0_amd64.deb`
|
||||
- `desktop/target/x86_64-unknown-linux-gnu/release/bundle/appimage/ObsiGate_2.0.0_amd64.AppImage`
|
||||
|
||||
---
|
||||
|
||||
## 5. Builds CI/CD automatiques
|
||||
|
||||
Le workflow [`.gitea/workflows/desktop-build.yml`](../../.gitea/workflows/desktop-build.yml)
|
||||
construit les binaires desktop à chaque push sur `main` touchant `desktop/**`,
|
||||
`frontend/**` ou `backend/**` (et manuellement via `workflow_dispatch`), sur des
|
||||
**runners self-hosted** :
|
||||
|
||||
| Job | Runner | Artefacts (30 jours) |
|
||||
|---|---|---|
|
||||
| `build-windows` | `[self-hosted, windows, desktop]` | `desktop/target/release/bundle/msi/*.msi` |
|
||||
| `build-linux` | `[self-hosted, linux, desktop]` | `*.AppImage` + `*.deb` |
|
||||
|
||||
Les artefacts sont téléchargeables depuis la page **Actions** du run Gitea ; la
|
||||
publication en **Gitea Release** est prévue sur les tags `v*`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Architecture desktop
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Tauri (Rust) │
|
||||
│ ├─ Webview (webview système) │
|
||||
│ │ └─ Frontend (HTML/JS/CSS) │
|
||||
│ └─ Sidecar Python │
|
||||
│ └─ uvicorn backend.main:app │
|
||||
│ └─ port 127.0.0.1:17890 │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Cycle de vie : Tauri spawn le backend Python → health check → splash → webview.
|
||||
À la fermeture : arrêt propre du backend (SIGTERM / kill).
|
||||
|
||||
---
|
||||
|
||||
## 7. Mises à jour
|
||||
|
||||
L'application vérifie les **releases Gitea** et propose la mise à jour (updater
|
||||
Tauri signé). Le manifeste `latest.json` est généré automatiquement.
|
||||
|
||||
> La **signature de code Windows** n'est pas retenue (pas de certificat) : le
|
||||
> binaire peut déclencher un avertissement SmartScreen. Alternatives possibles :
|
||||
> SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV.
|
||||
|
||||
---
|
||||
|
||||
## 8. Logs & dépannage
|
||||
|
||||
Les logs du backend sont écrits dans :
|
||||
|
||||
- **Windows** : `%APPDATA%\ObsiGate\logs\backend.log`
|
||||
- **Linux** : `~/.config/obsigate/logs/backend.log`
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| « Backend ne répond pas » | Vérifier le port `17890` (conflit) et relancer |
|
||||
| Build « glob pattern backend/**/* not found » | Le staging n'a pas été fait — utiliser les scripts fournis |
|
||||
| Le sélecteur de dossier ne s'ouvre pas | Permissions système / dialogue natif bloqué |
|
||||
| Fenêtre blanche | Consulter `backend.log` ; le backend a peut-être échoué au boot |
|
||||
| Mise à jour non proposée | Vérifier la connectivité aux releases Gitea |
|
||||
|
||||
Voir aussi [Prise en main](./PRISE_EN_MAIN.md) et
|
||||
[Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md).
|
||||
@@ -0,0 +1,191 @@
|
||||
# 🧩 Guide MCP (Model Context Protocol)
|
||||
|
||||
ObsiGate expose ses vaults à des **clients MCP externes** (Claude Desktop, Cursor,
|
||||
Cline, tout client compatible MCP) via un serveur **Streamable HTTP** monté sur
|
||||
`/mcp`. Les outils sont les **mêmes** que ceux de l'assistant in-app : la couche
|
||||
`backend/tools/` est la source unique de vérité.
|
||||
|
||||
> **Statut :** livré (#79 phase E + F) · **Dernière mise à jour :** 2026-09
|
||||
> **Voir aussi :** [`features/ai-tools-mcp.md`](../features/ai-tools-mcp.md) ·
|
||||
> [`AI_ARCHITECTURE_GUIDE.md`](../AI_ARCHITECTURE_GUIDE.md) ·
|
||||
> [API REST](./API_REST.md) · [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prérequis
|
||||
|
||||
1. Une instance ObsiGate accessible (locale ou distante).
|
||||
2. Une **clé API** (recommandé) ou un **jeton JWT** valide
|
||||
(`Authorization: Bearer <token>`). Une seule clé fonctionne pour l'API REST
|
||||
**et** le MCP. Créez-la depuis l'interface (Configurations → 🔑 Clés API & MCP)
|
||||
ou via `POST /api/auth/tokens` — voir [API REST §2.2](./API_REST.md#22-clés-api-longue-durée-recommandé-pour-scripts--mcp).
|
||||
3. Si l'authentification est désactivée (`OBSIGATE_AUTH_ENABLED=false`), le
|
||||
serveur MCP accepte un utilisateur anonyme disposant de tous les vaults.
|
||||
|
||||
> Le transport `stdio` n'est pas encore supporté ; utilisez le transport HTTP
|
||||
> (un pont local type `mcp-remote` si votre client ne gère pas nativement le
|
||||
> Streamable HTTP distant).
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoint & protocole
|
||||
|
||||
| Élément | Valeur |
|
||||
|---|---|
|
||||
| URL | `https://<obsigate>/mcp` |
|
||||
| Transport | Streamable HTTP (`POST` JSON-RPC 2.0, `Accept: application/json, text/event-stream`) |
|
||||
| Auth | `Authorization: Bearer <JWT>` |
|
||||
| Protocole MCP | `2025-03-26` (négocié à l'`initialize`) |
|
||||
| Réponses | JSON (`json_response=True`) |
|
||||
|
||||
Handshake minimal :
|
||||
|
||||
```bash
|
||||
curl -sS https://obsigate.example/mcp \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-03-26","capabilities":{},
|
||||
"clientInfo":{"name":"curl","version":"1.0"}}}'
|
||||
```
|
||||
|
||||
La réponse contient l'en-tête `Mcp-Session-Id` à réutiliser pour les appels
|
||||
suivants (`tools/list`, `tools/call`, `resources/read`, …).
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration des clients
|
||||
|
||||
### Claude Desktop (via pont `mcp-remote`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsigate": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y", "mcp-remote",
|
||||
"https://obsigate.example/mcp",
|
||||
"--header", "Authorization: Bearer ${OBSIGATE_TOKEN}"
|
||||
],
|
||||
"env": { "OBSIGATE_TOKEN": "eyJ..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
`.cursor/mcp.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsigate": {
|
||||
"url": "https://obsigate.example/mcp",
|
||||
"headers": { "Authorization": "Bearer eyJ..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Client générique (config raccourcie)
|
||||
|
||||
```json
|
||||
{"mcpServers": {"obsigate": {
|
||||
"url": "http://localhost:2020/mcp",
|
||||
"headers": {"Authorization": "Bearer <clé API>"}
|
||||
}}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Primitives exposées
|
||||
|
||||
### 4.1 Tools
|
||||
|
||||
Les outils de **lecture/recherche** sont exposés directement. Les outils
|
||||
**d'écriture/destructifs** sont exposés via une paire **two-step** :
|
||||
`propose_<tool>` (aperçu + jeton de confirmation, aucune modification) puis
|
||||
`apply_<tool>` (consomme le jeton et exécute).
|
||||
|
||||
| Catégorie | Outils |
|
||||
|---|---|
|
||||
| Vaults / navigation | `list_vaults`, `list_directory`, `list_all_files` |
|
||||
| Lecture | `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` |
|
||||
| Recherche | `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` |
|
||||
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
|
||||
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
|
||||
|
||||
Flux d'une mutation :
|
||||
|
||||
```text
|
||||
1. tools/call { name: "propose_edit_file",
|
||||
arguments: { vault, path, content } }
|
||||
→ { tool, arguments, diff, confirmation_token, expires_in }
|
||||
|
||||
2. (l'utilisateur / l'agent valide)
|
||||
|
||||
3. tools/call { name: "apply_edit_file",
|
||||
arguments: { confirmation_token } }
|
||||
→ { ok: true, data: { ... } }
|
||||
```
|
||||
|
||||
Le jeton est **signé (JWT), à usage unique et à durée de vie limitée**
|
||||
(`OBSIGATE_MCP_CONFIRMATION_TTL`, défaut 300 s). Un rejeu renvoie `token_reused`.
|
||||
|
||||
### 4.2 Resources
|
||||
|
||||
| URI | Contenu |
|
||||
|---|---|
|
||||
| `vault://<name>` | Vault accessible (métadonnées, nombre de fichiers) |
|
||||
| `vault://<name>/<path>` | Contenu d'un fichier (lecture seule, **secrets redactés**) |
|
||||
|
||||
### 4.3 Prompts
|
||||
|
||||
`summarize-directory`, `generate-note`, `find-related`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sécurité
|
||||
|
||||
- **Permissions par vault** : `check_vault_access` est appliqué à chaque outil
|
||||
et chaque resource ; un utilisateur ne voit que ses vaults.
|
||||
- **Anti path-traversal** : `resolve_safe_path` rejette tout chemin hors du vault.
|
||||
- **Confirmation two-step** pour toute mutation (jeton signé, usage unique).
|
||||
- **Toggle par vault** `aiDestructiveTools` (défaut : activé) : le désactiver
|
||||
bloque rename/move/replace/delete tout en laissant create/edit/append.
|
||||
- **Backup automatique** avant chaque opération destructive.
|
||||
- **Rate limiting** : par jeton et par outil
|
||||
(`OBSIGATE_TOOL_RATE_LIMIT`, `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL`,
|
||||
`OBSIGATE_TOOL_RATE_WINDOW`). Une limite dépassée renvoie le code `rate_limited`.
|
||||
- **Redaction des secrets** : les résultats d'outils (lectures, diffs, extraits
|
||||
de recherche) sont nettoyés avant tout retour au client.
|
||||
- **Audit** : chaque appel est journalisé (`data/audit.log`, action
|
||||
`ai_tool_call`) avec arguments sensibles résumés.
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `OBSIGATE_MCP_CONFIRMATION_TTL` | `300` | Durée de vie (s) des jetons de confirmation |
|
||||
| `OBSIGATE_TOOL_RATE_LIMIT` | `60` | Appels d'outils max par identité et par fenêtre |
|
||||
| `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL` | = global | Appels max par outil et par fenêtre |
|
||||
| `OBSIGATE_TOOL_RATE_WINDOW` | `60` | Longueur de la fenêtre (s) |
|
||||
| `BOOKSLM_MAX_TOOL_CALLS` | `25` | Quota d'appels d'outils par run d'agent |
|
||||
| `BOOKSLM_MAX_TOOL_READ_BYTES` | `200000` | Taille max renvoyée par `read_file` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
| Symptôme | Cause probable / remède |
|
||||
|---|---|
|
||||
| `401 Authentification requise` | En-tête `Authorization: Bearer` absent ou jeton expiré |
|
||||
| `vault_access_denied` | Le jeton n'a pas accès à ce vault (`vaults` / `_token_vaults`) |
|
||||
| `destructive_tools_disabled` | `aiDestructiveTools=false` pour ce vault |
|
||||
| `confirmation_required` | Appeler d'abord `propose_<tool>` puis `apply_<tool>` |
|
||||
| `token_reused` / `invalid_confirmation` | Jeton déjà consommé ou expiré → refaire un `propose_` |
|
||||
| `rate_limited` | Quota dépassé ; respecter `retry_after` |
|
||||
| Le client ne se connecte pas | Vérifier le transport Streamable HTTP / le pont `mcp-remote` |
|
||||
@@ -0,0 +1,242 @@
|
||||
# 🚀 Guide de prise en main
|
||||
|
||||
Ce guide vous fait passer d'une installation fraîche à une utilisation courante
|
||||
d'ObsiGate : première connexion, découverte de l'interface, navigation dans vos
|
||||
vaults Obsidian et raccourcis essentiels.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Durée de lecture :** ~10 min
|
||||
> **Voir aussi :** [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) ·
|
||||
> [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) ·
|
||||
> [API REST](./API_REST.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Qu'est-ce qu'ObsiGate ?
|
||||
|
||||
ObsiGate est une **porte d'entrée web ultra-légère** vers vos vaults Obsidian.
|
||||
Il indexe vos notes en mémoire, les rend accessibles depuis n'importe quel
|
||||
navigateur (ordinateur, tablette, téléphone) et ajoute une couche moderne :
|
||||
recherche avancée, lecture Markdown, liens `[[wikilinks]]`, images, PDF,
|
||||
Excalidraw, Mermaid, assistant IA, collaboration temps réel.
|
||||
|
||||
Points clés :
|
||||
|
||||
- **Aucune modification de vos vaults** : les volumes sont montés en lecture seule (`:ro`) par défaut.
|
||||
- **Pas de base de données** : tout l'état tient dans des fichiers JSON sous `data/`.
|
||||
- **Temps réel** : un watcher surveille le système de fichiers et met l'index à jour à chaud.
|
||||
- **Multi-vault** : plusieurs vaults peuvent être affichés et recherchés simultanément.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prérequis
|
||||
|
||||
| Composant | Version | Remarque |
|
||||
|---|---|---|
|
||||
| Docker | ≥ 20.10 | ou Node/`uv` pour un lancement manuel |
|
||||
| docker-compose | ≥ 2.0 | inclus avec Docker Desktop |
|
||||
| Navigateur | récent | Chrome, Edge, Firefox, Safari |
|
||||
|
||||
Vous aurez aussi besoin du **chemin absolu** de chaque vault Obsidian sur la
|
||||
machine qui héberge Docker.
|
||||
|
||||
---
|
||||
|
||||
## 3. Lancer ObsiGate en 3 étapes
|
||||
|
||||
> La procédure complète (reverse proxy, HTTPS, mises à jour) est détaillée dans le
|
||||
> [Guide de déploiement Docker](./DEPLOIEMENT_DOCKER.md).
|
||||
|
||||
### 3.1 Cloner le dépôt
|
||||
|
||||
```bash
|
||||
git clone https://git.dracodev.net/Projets/ObsiGate.git
|
||||
cd ObsiGate
|
||||
```
|
||||
|
||||
### 3.2 Déclarer vos vaults
|
||||
|
||||
Éditez `docker-compose.yml` pour monter vos dossiers (chemins absolus, lecture seule) :
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /home/user/Documents/Obsidian-Recettes:/vaults/Recettes:ro
|
||||
- /home/user/Documents/Obsidian-IT:/vaults/IT:ro
|
||||
- ./data:/app/data # persistance auth/config
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
- VAULT_1_PATH=/vaults/Recettes
|
||||
- VAULT_2_NAME=IT
|
||||
- VAULT_2_PATH=/vaults/IT
|
||||
```
|
||||
|
||||
Créez le fichier de secrets à partir du modèle :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Éditez .env (mot de passe admin, options d'auth…)
|
||||
```
|
||||
|
||||
### 3.3 Construire et démarrer
|
||||
|
||||
```bash
|
||||
chmod +x build.sh # une seule fois
|
||||
./build.sh
|
||||
```
|
||||
|
||||
`build.sh` vérifie Docker, valide les volumes, construit l'image et démarre le
|
||||
conteneur. Ouvrez ensuite **http://localhost:2020**.
|
||||
|
||||
> Options utiles : `./build.sh --help`, `./build.sh --cache` (rebuild rapide),
|
||||
> `./build.sh --build-only` (construire sans démarrer).
|
||||
|
||||
---
|
||||
|
||||
## 4. Premier accès
|
||||
|
||||
### 4.1 Si l'authentification est désactivée (défaut)
|
||||
|
||||
Vous arrivez directement sur l'interface. Toutes les fonctionnalités sont
|
||||
accessibles sans compte — **à réserver à un usage sur réseau de confiance**.
|
||||
|
||||
### 4.2 Si l'authentification est activée
|
||||
|
||||
L'écran de connexion s'affiche. Au **tout premier démarrage**, ObsiGate crée un
|
||||
compte admin et affiche le mot de passe **une seule fois dans les logs** :
|
||||
|
||||
```bash
|
||||
docker compose logs obsigate | grep -A4 "FIRST"
|
||||
```
|
||||
|
||||
Changez ce mot de passe dès la première connexion (menu → profil →
|
||||
*Changer le mot de passe*). La gestion complète des comptes, du MFA et des
|
||||
permissions est décrite dans le
|
||||
[Guide Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md).
|
||||
|
||||
---
|
||||
|
||||
## 5. Découvrir l'interface
|
||||
|
||||
L'interface se compose de trois zones principales.
|
||||
|
||||
### 5.1 L'en-tête (header)
|
||||
|
||||
| Élément | Rôle |
|
||||
|---|---|
|
||||
| 🔍 **Barre de recherche globale** | Recherche dans toutes les vaults autorisées |
|
||||
| Filtre | Restreint la recherche (type, tag, vault…) |
|
||||
| Sélecteur de vault | Bascule l'arborescence sur une vault ou « Toutes les vaults » |
|
||||
| Utilisateur | Nom du compte connecté (si auth activée) |
|
||||
| Version | Version courante d'ObsiGate |
|
||||
| ⚙️ **Options** | Configuration, thème, guide d'utilisation, administration |
|
||||
|
||||
### 5.2 La barre latérale (sidebar)
|
||||
|
||||
Elle regroupe les vues principales via des icônes :
|
||||
|
||||
- **Arborescence** — parcourt les dossiers et fichiers de la vault sélectionnée.
|
||||
- **Graphe** — vue force-directed des liens entre notes.
|
||||
- **Récents** — derniers fichiers ouverts.
|
||||
- **Signets** — vos fichiers et recherches enregistrés.
|
||||
- **Partagés** — liens de partage public que vous avez créés.
|
||||
|
||||
Un champ **« Filtrer fichiers… »** restreint l'arborescence en temps réel, et le
|
||||
bouton **Aa** ajuste l'affichage des libellés.
|
||||
|
||||
En bas de la sidebar (si l'authentification est activée), la **section compte**
|
||||
affiche votre avatar (ou vos initiales), votre nom et votre rôle ; un clic ouvre
|
||||
le profil. La photo se choisit dans **Configurations → Profil** (*Choisir une
|
||||
image* : PNG, JPG ou WEBP — recadrée en carré 256 px, affichée dans le cercle de
|
||||
la sidebar) ; le bouton *Se déconnecter* est juste à côté.
|
||||
|
||||
### 5.3 La zone de contenu
|
||||
|
||||
Elle affiche l'onglet actif : tableau de bord **Statistiques**, **Bookmarks**,
|
||||
**Récents**, **Partagés**, ou le document ouvert. Les documents s'ouvrent dans
|
||||
des **onglets** (avec possibilité de vue multi-panneaux / split view).
|
||||
|
||||
---
|
||||
|
||||
## 6. Navigation et lecture
|
||||
|
||||
1. **Déployez une vault** dans la sidebar (clic sur son nom).
|
||||
2. **Cliquez sur un dossier** pour l'ouvrir, sur un **fichier** pour l'afficher.
|
||||
3. Le **breadcrumb** en haut du document permet de remonter rapidement.
|
||||
4. Les **wikilinks** `[[note]]` sont cliquables ; les images et diagrammes
|
||||
s'affichent automatiquement.
|
||||
5. Utilisez **Ctrl + clic** sur un lien pour l'ouvrir en aperçu rapide selon le
|
||||
contexte, ou ouvrir le graphe centré sur un nœud.
|
||||
|
||||
### Créer et modifier
|
||||
|
||||
- **Bouton « Editer »** : ouvre le document dans l'éditeur Markdown (CodeMirror).
|
||||
- **Bouton « Forge »** (éditeur avancé) : ouvre la version enrichie avec
|
||||
assistant IA intégré. Voir [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md).
|
||||
- **Nouveau fichier / dossier** : depuis les actions de la sidebar ou la palette
|
||||
de commandes.
|
||||
- **Sauvegarde** : `Ctrl + S` (et auto-sauvegarde dans l'éditeur IA).
|
||||
|
||||
> Selon le mode, la lecture et l'édition se remplacent : `Editer` et `Forge`
|
||||
> prennent la place de la vue lecture ; revenez avec `✓` / `×` ou `Échap`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rechercher
|
||||
|
||||
La recherche est un point fort d'ObsiGate : index inversé TF-IDF, stemming
|
||||
français, normalisation des accents, facettes et pagination. La syntaxe complète
|
||||
(`tag:`, `#`, `vault:`, `title:`, `path:`, `ext:`, phrases exactes) est décrite
|
||||
dans le [Guide Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md).
|
||||
|
||||
Démarrage rapide :
|
||||
|
||||
- Tapez dans la barre de recherche, `Ctrl + K` pour y revenir.
|
||||
- `/` focalise la recherche hors champ de saisie.
|
||||
- `/` + `↑`/`↓` navigue dans les suggestions.
|
||||
|
||||
---
|
||||
|
||||
## 8. Apparence et confort
|
||||
|
||||
- **Thème clair/sombre** : bascule persistée en `localStorage` ; le desktop suit
|
||||
aussi le thème du système.
|
||||
- **Thèmes** : clair, sombre, contraste élevé, sépia — import/export possible.
|
||||
- **Responsive** : l'interface s'adapte au mobile (éditeur tactile, barre
|
||||
d'outils flottante).
|
||||
- **PWA** : installable comme application native, mode hors-ligne partiel.
|
||||
|
||||
Voir [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md).
|
||||
|
||||
---
|
||||
|
||||
## 9. Raccourcis clavier essentiels
|
||||
|
||||
| Action | Raccourci |
|
||||
|---|---|
|
||||
| Palette de commandes | `Ctrl + Shift + Space` |
|
||||
| Palette de fichiers (navigation rapide) | `Ctrl + Alt + Space` |
|
||||
| Focus barre de recherche | `Ctrl + K` |
|
||||
| Recherche rapide (hors champ texte) | `/` |
|
||||
| Sauvegarder le fichier ouvert | `Ctrl + S` |
|
||||
| Rechercher dans le document | `Ctrl + F` |
|
||||
| Completion IA inline (éditeur) | `Ctrl + J` |
|
||||
| Insertion rapide (éditeur Forge) | `Alt + I` |
|
||||
| Fermer l'éditeur / modale | `Échap` |
|
||||
| Aide de l'éditeur Forge | `F1` |
|
||||
| Naviguer dans les suggestions | `↑` / `↓` |
|
||||
| Lancer la recherche / valider | `Entrée` |
|
||||
|
||||
> Le panneau **Raccourcis & Astuces** du tableau de bord Statistiques récapitule
|
||||
> ces raccourcis directement dans l'application.
|
||||
|
||||
---
|
||||
|
||||
## 10. Et ensuite ?
|
||||
|
||||
| Objectif | Guide |
|
||||
|---|---|
|
||||
| Mieux chercher, lire PDF/Excel et Excalidraw | [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) |
|
||||
| Utiliser l'IA intégrée | [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) |
|
||||
| Éditer à plusieurs | [Édition & collaboration](./COLLABORATION.md) |
|
||||
| Sécuriser l'accès | [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) |
|
||||
| Automatiser via API/MCP | [API REST](./API_REST.md) · [MCP](./MCP.md) |
|
||||
| Installer l'application native | [Desktop (Tauri)](./DESKTOP.md) |
|
||||
@@ -0,0 +1,136 @@
|
||||
# 📱 Guide PWA & mode hors-ligne
|
||||
|
||||
ObsiGate est une **Progressive Web App (PWA)** : installez-la comme une
|
||||
application native, consultez vos notes **hors-ligne**, recevez des
|
||||
notifications et synchronisez vos modifications à la reconnexion.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Guides techniques :**
|
||||
> [`PWA_GUIDE.md`](../PWA_GUIDE.md) · [`INSTALLATION_PWA.md`](../INSTALLATION_PWA.md)
|
||||
> **Voir aussi :** [Prise en main](./PRISE_EN_MAIN.md) · [Édition & collaboration](./COLLABORATION.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Qu'est-ce que la PWA d'ObsiGate ?
|
||||
|
||||
Une PWA combine le meilleur du web et du natif :
|
||||
|
||||
- **Installation** sur l'écran d'accueil, sans store.
|
||||
- **Mode hors-ligne** : interface et dernières données consultées mises en cache.
|
||||
- **Notifications** : alertes de mise à jour et Web Push.
|
||||
- **Performance** : chargement rapide via cache intelligent.
|
||||
- **Multi-plateforme** : desktop, mobile, tablette.
|
||||
|
||||
---
|
||||
|
||||
## 2. Installer la PWA
|
||||
|
||||
### Desktop (Chrome, Edge, Brave)
|
||||
|
||||
1. Ouvrez ObsiGate dans le navigateur.
|
||||
2. Cliquez sur l'icône d'installation dans la barre d'adresse (➕ / ⬇️).
|
||||
3. Cliquez sur **Installer** dans la popup.
|
||||
4. ObsiGate apparaît dans vos applications.
|
||||
|
||||
*Alternative :* menu ⋮ → **Installer ObsiGate…**
|
||||
|
||||
### Android (Chrome)
|
||||
|
||||
1. Ouvrez ObsiGate dans Chrome.
|
||||
2. Menu ⋮ → **Ajouter à l'écran d'accueil**.
|
||||
3. Confirmez.
|
||||
|
||||
### iOS / iPadOS (Safari)
|
||||
|
||||
1. Ouvrez ObsiGate dans Safari.
|
||||
2. Bouton Partager 📤 → **Sur l'écran d'accueil**.
|
||||
3. Nommez l'application puis **Ajouter**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mode hors-ligne
|
||||
|
||||
Le **Service Worker** (`frontend/sw.js`) met en cache :
|
||||
|
||||
- l'interface (HTML, CSS, JavaScript, manifeste) ;
|
||||
- les ressources statiques (icônes, polices) ;
|
||||
- les dernières données API consultées.
|
||||
|
||||
### Stratégies de cache
|
||||
|
||||
| Ressource | Stratégie |
|
||||
|---|---|
|
||||
| Code (HTML/JS/CSS/manifest) | **Network-first** (cache en secours hors-ligne) |
|
||||
| API | **Network-first** (+ cache hors-ligne) |
|
||||
| Autres assets (images, polices) | **Stale-while-revalidate** |
|
||||
| Nettoyage | Purge des caches d'une version antérieure à l'activation |
|
||||
|
||||
> Le choix **network-first** est délibéré : les assets ne sont pas fingerprintés,
|
||||
> un cache-first servirait indéfiniment un ancien build sur mobile.
|
||||
|
||||
### File de synchronisation & conflits
|
||||
|
||||
- Les modifications faites hors-ligne sont stockées (IndexedDB) et rejouées à la
|
||||
reconnexion.
|
||||
- Les conflits éventuels sont détectés et peuvent être résolus (écran
|
||||
**Conflits**, `GET /api/conflicts`).
|
||||
|
||||
### Tester hors-ligne
|
||||
|
||||
1. DevTools (F12) → onglet **Network**.
|
||||
2. Cochez **Offline**.
|
||||
3. Rechargez : l'application doit fonctionner avec le cache.
|
||||
|
||||
---
|
||||
|
||||
## 4. Notifications (Web Push)
|
||||
|
||||
- Abonnement à partir de l'interface (permission navigateur requise).
|
||||
- Endpoints : `GET /api/push/vapid-public-key`,
|
||||
`POST /api/push/subscribe`, `DELETE /api/push/subscribe`,
|
||||
`GET /api/push/subscriptions`.
|
||||
- Les notifications sont signées **VAPID** et peuvent prévenir de changements
|
||||
(collaboration, mises à jour).
|
||||
|
||||
---
|
||||
|
||||
## 5. Mises à jour
|
||||
|
||||
- Vérification régulière des mises à jour.
|
||||
- Notification quand une nouvelle version est disponible.
|
||||
- Mise à jour en un clic, **sans perte de données**.
|
||||
- Le numéro `SW_VERSION` invalide l'ancien cache à chaque livraison.
|
||||
|
||||
### Forcer une mise à jour (console)
|
||||
|
||||
```javascript
|
||||
navigator.serviceWorker.getRegistration().then(reg => reg.update());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Débogage
|
||||
|
||||
### Vérifier l'installation
|
||||
|
||||
Chrome DevTools → onglet **Application** :
|
||||
|
||||
- **Manifest** : métadonnées ;
|
||||
- **Service Workers** : enregistrement ;
|
||||
- **Cache Storage** : contenu du cache.
|
||||
|
||||
### Désinstaller le Service Worker
|
||||
|
||||
```javascript
|
||||
navigator.serviceWorker.getRegistrations().then(regs => regs.forEach(r => r.unregister()));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Limites
|
||||
|
||||
- Le hors-ligne dépend des données déjà mises en cache.
|
||||
- Les actions d'écriture hors-ligne s'appliquent à la reconnexion (pas en temps
|
||||
réel).
|
||||
- iOS applique des contraintes spécifiques (persistance, notifications).
|
||||
|
||||
Voir [Édition & collaboration](./COLLABORATION.md) pour le temps réel.
|
||||
@@ -0,0 +1,54 @@
|
||||
# 📚 Guides d'utilisation ObsiGate
|
||||
|
||||
Bienvenue dans le répertoire des **guides utilisateur** d'ObsiGate. Chaque guide est
|
||||
autonome, écrit en français et illustré d'exemples concrets (commandes, configuration,
|
||||
captures conceptuelles).
|
||||
|
||||
> **Vous découvrez ObsiGate ?** Commencez par le **[Guide de prise en main](./PRISE_EN_MAIN.md)**.
|
||||
> Une aide rapide est aussi intégrée directement dans l'application (menu Options →
|
||||
> **Guide d'utilisation**, FR/EN, téléchargeable en Markdown et PDF).
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Sommaire des guides
|
||||
|
||||
| Guide | Public | Contenu |
|
||||
|---|---|---|
|
||||
| 🚀 [Prise en main](./PRISE_EN_MAIN.md) | Tous | Premier lancement, interface, navigation, vaults, raccourcis |
|
||||
| 🔍 [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteurs PDF/Excel, diagrammes |
|
||||
| 🤖 [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) | Tous | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
|
||||
| 📝 [Édition & collaboration](./COLLABORATION.md) | Tous | Édition simultanée, curseurs distants, persistance |
|
||||
| 📱 [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md) | Tous | Installation PWA, cache, file de synchronisation, notifications |
|
||||
| 🔌 [API REST](./API_REST.md) | Développeurs | Authentification, clés API, endpoints, exemples `curl`, SSE |
|
||||
| 🧩 [Serveur MCP](./MCP.md) | Développeurs / IA | Brancher Claude Desktop, Cursor, Cline… sur vos vaults |
|
||||
| 🔒 [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) | Admin | Utilisateurs, MFA, permissions par vault, bonnes pratiques |
|
||||
| 🐳 [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) | Admin / Ops | `docker-compose`, volumes, reverse proxy, mises à jour |
|
||||
| 🖥️ [Application desktop (Tauri)](./DESKTOP.md) | Tous | Installation, premier lancement, build depuis les sources |
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Par où commencer ?
|
||||
|
||||
- **Je veux juste utiliser l'application** → [Prise en main](./PRISE_EN_MAIN.md)
|
||||
- **Je veux sécuriser mon instance** → [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md)
|
||||
- **Je veux brancher une IA** → [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) puis [MCP](./MCP.md)
|
||||
- **Je veux scripter/automatiser** → [API REST](./API_REST.md)
|
||||
- **Je veux héberger sur un serveur** → [Déploiement Docker](./DEPLOIEMENT_DOCKER.md)
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation associée
|
||||
|
||||
| Type | Où |
|
||||
|---|---|
|
||||
| Vue d'ensemble produit | [`README.fr.md`](../../README.fr.md) · [`README.md`](../../README.md) |
|
||||
| Conception détaillée par fonctionnalité | [`docs/features/`](../features/) |
|
||||
| Standards de code | [`docs/CONTRIBUTING.md`](../CONTRIBUTING.md) |
|
||||
| Méthode de livraison (Definition of Done) | [`docs/DELIVERY_WORKFLOW.md`](../DELIVERY_WORKFLOW.md) |
|
||||
| Roadmap / travail à venir | [`docs/ROADMAP.md`](../ROADMAP.md) |
|
||||
| Historique des versions | [`CHANGELOG.md`](../../CHANGELOG.md) |
|
||||
| API interactive (Swagger / ReDoc) | `/docs` · `/redoc` (instance ObsiGate) |
|
||||
|
||||
> **Convention :** ce répertoire est la **porte d'entrée utilisateur**. Le *comment*
|
||||
> (utilisation) vit ici ; le *pourquoi* (conception technique) vit dans
|
||||
> [`docs/features/`](../features/). Ne jamais dupliquer le détail technique des fiches.
|
||||
@@ -0,0 +1,425 @@
|
||||
# 🔍 Guide Recherche, PDF, Excel & Excalidraw
|
||||
|
||||
ObsiGate va au-delà de la simple lecture : recherche puissante, rendu des
|
||||
documents riches (PDF, diagrammes) et indexation de leur contenu pour que tout
|
||||
soit retrouvable.
|
||||
|
||||
> **Public :** tous les utilisateurs
|
||||
> **Fiches techniques :** [`features/semantic-search.md`](../features/semantic-search.md) ·
|
||||
> [`features/pdf.md`](../features/pdf.md) · [`features/excalidraw.md`](../features/excalidraw.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Recherche plein texte (TF-IDF)
|
||||
|
||||
Le moteur d'ObsiGate s'appuie sur un **index inversé** et un scoring **TF-IDF**
|
||||
avec :
|
||||
|
||||
- **Boost titre** — une correspondance dans le titre pèse 3× plus.
|
||||
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
|
||||
- **Stemming français** — les variantes des mots sont rapprochées.
|
||||
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
|
||||
- **Facettes** — compteurs par vault et par tag sur les résultats.
|
||||
- **Pagination** — 50 résultats par page.
|
||||
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
|
||||
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
|
||||
- **Historique** — les 50 dernières recherches sont conservées en `localStorage`.
|
||||
|
||||
La recherche s'effectue **sans I/O disque** : le contenu est déjà en mémoire.
|
||||
|
||||
---
|
||||
|
||||
## 2. Syntaxe de requête
|
||||
|
||||
| Opérateur | Description | Exemple |
|
||||
|---|---|---|
|
||||
| `tag:<nom>` | Filtre par tag | `tag:recette docker` |
|
||||
| `#<nom>` | Raccourci de tag | `#linux serveur` |
|
||||
| `vault:<nom>` | Filtre par vault | `vault:IT kubernetes` |
|
||||
| `title:<texte>` | Filtre par titre | `title:pizza` |
|
||||
| `path:<texte>` | Filtre par chemin | `path:recettes/soupes` |
|
||||
| `ext:<type>` | Filtre par type de fichier | `ext:md kubernetes` |
|
||||
| `"phrase exacte"` | Recherche d'une phrase | `tag:"multi mots"` |
|
||||
|
||||
Les opérateurs sont **combinables** :
|
||||
|
||||
```text
|
||||
tag:linux vault:IT ext:md serveur web
|
||||
```
|
||||
|
||||
Cette requête cherche « serveur web » dans les fichiers Markdown de la vault
|
||||
`IT` portant le tag `linux`.
|
||||
|
||||
### Filtres par extension
|
||||
|
||||
| Extension | Contenu |
|
||||
|---|---|
|
||||
| `ext:md` | Notes Markdown |
|
||||
| `ext:py`, `ext:sh`, `ext:js` | Scripts et code |
|
||||
| `ext:pdf` | Documents PDF (texte extrait) |
|
||||
| `ext:excalidraw` | Diagrammes Excalidraw (texte extrait) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Autocomplétion et suggestions
|
||||
|
||||
- **`/api/suggest`** — suggère des titres de fichiers.
|
||||
- **`/api/tags/suggest`** — suggère des tags.
|
||||
- Navigation clavier : `↑` / `↓` puis `Entrée` ; `Échap` ferme les suggestions.
|
||||
|
||||
### Raccourcis de recherche
|
||||
|
||||
| Raccourci | Action |
|
||||
|---|---|
|
||||
| `Ctrl + K` / `Cmd + K` | Focaliser la barre de recherche |
|
||||
| `/` | Focaliser la recherche (hors champ texte) |
|
||||
| `↑` / `↓` | Naviguer dans les suggestions |
|
||||
| `Entrée` | Sélectionner la suggestion active ou lancer la recherche |
|
||||
| `Échap` | Fermer les suggestions / quitter la recherche |
|
||||
|
||||
Recherches sauvegardées et signets sont disponibles via l'API
|
||||
(`/api/saved-searches`, `/api/bookmarks`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Recherche sémantique (optionnelle)
|
||||
|
||||
Au classement TF-IDF peut s'ajouter un classement **par embeddings**, fusionné
|
||||
via la méthode **RRF** (Reciprocal Rank Fusion). Activation : touche `~`
|
||||
(ou `Alt + S`) dans la recherche.
|
||||
|
||||
Deux modes :
|
||||
|
||||
1. **Sans dépendance** — un *embedder* par hachage fournit une base utilisable
|
||||
immédiatement.
|
||||
2. **Embeddings réels** — installez `backend/requirements-semantic.txt` et/ou
|
||||
renseignez les variables `OBSIGATE_EMBEDDING_*` pour utiliser
|
||||
`all-MiniLM-L6-v2`.
|
||||
|
||||
Détails et configuration :
|
||||
[`features/semantic-search.md`](../features/semantic-search.md).
|
||||
|
||||
---
|
||||
|
||||
## 5. Support PDF
|
||||
|
||||
### Lecture
|
||||
|
||||
Les fichiers PDF de vos vaults s'affichent **en ligne** dans le navigateur via le
|
||||
visualiseur PDF natif (iframe + `<embed>`). Le fichier est **streamé** en HTTP
|
||||
Range (`206 Partial Content`) : les gros PDF se chargent progressivement.
|
||||
|
||||
### Recherche
|
||||
|
||||
Le texte est **extrait à l'indexation** (`pypdf` / `pymupdf`), donc le contenu
|
||||
des PDF est recherchable via la recherche plein texte. Utilisez `ext:pdf` pour
|
||||
limiter les résultats aux PDF.
|
||||
|
||||
### Métadonnées
|
||||
|
||||
`GET /api/file/{vault}/pdf/info` renvoie les métadonnées (pages, titre, auteur)
|
||||
**sans transférer** le document.
|
||||
|
||||
```bash
|
||||
curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
||||
```
|
||||
|
||||
### Limites
|
||||
|
||||
- **Pas d'OCR** : les PDF scannés (images) ne sont pas recherchables.
|
||||
- Pas d'annotation ni d'édition du PDF lui-même.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tableurs Excel (XLSX)
|
||||
|
||||
### Affichage et édition
|
||||
|
||||
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
|
||||
feuille, des onglets pour naviguer entre elles (toujours visibles, même à
|
||||
une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de
|
||||
commandes regroupe les actions en sections (Formules · Insertion · Vue ·
|
||||
Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est
|
||||
modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie.
|
||||
**Enregistrer** envoie les cellules modifiées à
|
||||
`PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille, avec
|
||||
**backup automatique** du fichier avant écriture, et une écriture
|
||||
**atomique** (le classeur n'est jamais laissé à moitié écrit).
|
||||
|
||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
|
||||
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
|
||||
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
|
||||
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
|
||||
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
|
||||
|
||||
### Avertissement avant enregistrement
|
||||
|
||||
Certains classeurs contiennent des éléments qu'ObsiGate ne sait pas
|
||||
réécrire : **valeurs calculées** mises en cache par Excel, segments
|
||||
(slicers), chronologies, contrôles de formulaire, connexions/requêtes,
|
||||
XML personnalisé, signature numérique, commentaires enrichis, macros.
|
||||
L'ouverture affiche alors un bandeau qui les liste, et la première
|
||||
sauvegarde demande confirmation dans une fenêtre intégrée au thème de
|
||||
l'application. Si vous refusez, rien n'est écrit.
|
||||
|
||||
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
|
||||
|
||||
Si le classeur est modifié ailleurs entre-temps (autre poste, Excel,
|
||||
synchronisation…), ObsiGate n'interrompt pas votre travail : un bandeau vous
|
||||
propose de **réessayer**. Le bouton **Réessayer** relit d'abord le fichier pour
|
||||
récupérer la version courante, puis rejoue l'enregistrement — vos
|
||||
modifications restent en place pendant tout ce temps.
|
||||
|
||||
### Formules
|
||||
|
||||
Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme
|
||||
texte** (une formule injectée s'exécuterait à l'ouverture du fichier dans
|
||||
Excel). Le bouton `f(x)` de la barre d'outils active les vraies formules pour
|
||||
la session en cours.
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx" -H "Content-Type: application/json" -d '{"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": false, "force": false}'
|
||||
```
|
||||
|
||||
- `allow_formula` : `true` pour écrire une vraie formule (`=B1*2`).
|
||||
- `force` : `true` pour enregistrer malgré les éléments non préservés
|
||||
(sinon l'API répond **409** `xlsx_lossy_content`).
|
||||
- `if_match` (facultatif) : la **version** du fichier attendue, telle que la
|
||||
lecture la renvoie (`xlsx_revision` ou `revision`). Si le fichier a changé
|
||||
depuis, l'écriture est refusée (**409** `conflict`, `details.reason =
|
||||
"stale_revision"`) au lieu d'écraser le travail de l'autre écrivain ;
|
||||
relisez le fichier puis renvoyez la nouvelle version. Les trois routes
|
||||
d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête
|
||||
`If-Match` ou le champ `if_match` et renvoient la version à jour dans
|
||||
`revision`.
|
||||
|
||||
### Feuilles volumineuses et lecture par fenêtres
|
||||
|
||||
Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille. Quand
|
||||
une feuille dépasse ce plafond, un bandeau **« Feuille tronquée »**
|
||||
l'annonce explicitement (par exemple « 500 lignes affichées sur 520 »)
|
||||
au lieu de présenter une table courte comme complète — le classeur,
|
||||
lui, n'est jamais modifié. La ligne d'en-têtes de colonnes reste
|
||||
visible pendant le défilement vertical.
|
||||
|
||||
Côté API, `GET /api/file/{vault}/xlsx/sheet` sert une feuille **par
|
||||
fenêtres de lignes**, y compris au-delà du plafond d'affichage — les
|
||||
coordonnées A1 renvoyées sont celles de la feuille réelle :
|
||||
|
||||
```bash
|
||||
curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=Budget&offset=500&limit=200"
|
||||
```
|
||||
|
||||
- `offset` : première ligne renvoyée (0-based) ; `limit` : nombre de
|
||||
lignes (1 à 1 000 par requête).
|
||||
- La réponse porte `total_rows`, `truncated` et `has_more` pour paginer.
|
||||
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
|
||||
n'est ni un `.xlsx` ni un `.xlsm`.
|
||||
|
||||
### Fonctions avancées
|
||||
|
||||
**Barre de formule, zone Nom et navigation clavier** — au-dessus du tableau, la
|
||||
**zone Nom** affiche l'adresse de la cellule active (`B12`) ou de la plage
|
||||
sélectionnée (`A1:B3`) et elle est **éditable** (« Atteindre ») : saisissez une
|
||||
référence puis `Entrée` pour y aller (`B12`, `A1:B3`, `$A$1`, ou `Feuille2!A1`
|
||||
pour changer d'onglet) ; une référence inconnue est refusée avec un message et
|
||||
l'adresse précédente est restaurée. La barre de formule reflète la cellule
|
||||
active et propose les **noms de fonctions** courants pendant la saisie.
|
||||
|
||||
`Tab`/`Maj+Tab` et les flèches circulent entre les cellules, `Maj+flèches` étend
|
||||
la sélection, `Entrée` valide, `Maj+Entrée` insère un saut de ligne **dans** la
|
||||
cellule, `F2` ouvre la cellule en édition, `Suppr` vide la sélection,
|
||||
`Échap` restaure la valeur d'origine ; `Origine`/`Fin` vont au bord de la ligne,
|
||||
`Ctrl+Origine`/`Ctrl+Fin` aux coins de la feuille affichée,
|
||||
`PgPréc`/`PgSuiv` font défiler d'un écran, `Ctrl+flèches` saute au bout de la
|
||||
plage de données, `Ctrl+A` sélectionne toute la feuille affichée et `Ctrl+S`
|
||||
enregistre. Tant que la cellule n'est pas en cours d'édition, `Suppr`
|
||||
efface la sélection plutôt qu'un caractère.
|
||||
|
||||
**Presse-papiers de plage** — copier, couper et coller un **bloc** de cellules
|
||||
(`Ctrl+C`, `Ctrl+X`, `Ctrl+V`, ou les entrées correspondantes du menu
|
||||
contextuel) : coller un bloc copié ici **ou depuis Excel** remplit la plage à
|
||||
partir de la cellule active et la laisse sélectionnée. Le collage est du
|
||||
**texte** (formules et valeurs recopiées telles quelles) et reste **annulable** ;
|
||||
« couper » efface la source après le collage (un collage sur place ne l'efface
|
||||
pas). Un bloc plus large que la grille affichée est tronqué, avec un message.
|
||||
|
||||
**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient
|
||||
sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
|
||||
|
||||
**Sélection et menu contextuel** — cliquer une cellule l'active, **glisser**
|
||||
ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
|
||||
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
|
||||
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
|
||||
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
|
||||
contenu.
|
||||
|
||||
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
|
||||
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
|
||||
la recherche (`Ctrl+F` du panneau) parcourt **toutes les feuilles** : le compteur
|
||||
indique le nombre de feuilles concernées et passer sur une correspondance
|
||||
**active l'onglet** qui la contient.
|
||||
|
||||
La sortie propose quatre formats, toujours sur le **contenu affiché** (et jamais
|
||||
sur les valeurs calculées en cache) :
|
||||
|
||||
- **CSV** (bouton `CSV`) — exporte la **sélection** quand une plage de plusieurs
|
||||
cellules est active (le nom du fichier reprend la plage, ex.
|
||||
`Fruits-A1B2.csv`), sinon la feuille entière ;
|
||||
- **Markdown** et **HTML** (menu **Exporter**) — tableau markdown ou document
|
||||
HTML autonome, mêmes règles de sélection ;
|
||||
- **Imprimer** (menu **Exporter**) — imprime la feuille ou la sélection seule,
|
||||
sans le ruban ni les panneaux de l'application.
|
||||
|
||||
Rien de tout cela ne modifie le classeur.
|
||||
|
||||
**Structure** — le menu **Structure** de la barre d'outils ajoute,
|
||||
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
|
||||
colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
|
||||
automatique et confirmation, comme pour l'édition des cellules).
|
||||
|
||||
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
|
||||
**sélection courante** (une cellule ou une plage) :
|
||||
|
||||
- **caractère** — gras, italique, souligné, effacer la mise en forme ;
|
||||
- **alignement** — gauche, centré, droite ;
|
||||
- **couleurs** — couleur de police et couleur de fond (sélecteur natif, aucune
|
||||
palette imposée) ;
|
||||
- **format de nombre** — général, nombre, pourcentage, devise, date, texte ;
|
||||
- **structure** — fusionner / défusionner les cellules, figer / libérer les
|
||||
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
|
||||
plage).
|
||||
|
||||
L'écriture passe par `PUT …/xlsx/style`, avec les mêmes garanties que l'édition
|
||||
des cellules : backup automatique, écriture atomique, confirmation si
|
||||
l'opération détruirait des éléments non préservables (graphiques, valeurs
|
||||
calculées en cache…) et **détection d'un écrivain externe** (`If-Match` →
|
||||
message « Réessayer »). Un `.csv` ne portant pas de mise en forme, le bouton
|
||||
n'y est pas proposé (il est également absent d'une grille en lecture seule).
|
||||
La lecture restitue par ailleurs les couleurs, polices, cellules fusionnées et
|
||||
volets figés du fichier ; l'ancrage de la zone figée est conservé au défilement.
|
||||
Les **commentaires**, liens hypertexte, validation de données, mise en forme
|
||||
conditionnelle et bordures restent hors périmètre.
|
||||
|
||||
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
|
||||
**macros sont préservées** à l'enregistrement (y compris le chargement des
|
||||
lignes au-delà du plafond) ; `.xls` et `.ods` s'affichent en **lecture
|
||||
seule** ; un `.csv` s'ouvre dans la même grille et se réécrit conformément à
|
||||
la RFC 4180 (les guillemets et séparateurs sont échappés). Le **séparateur
|
||||
du CSV est détecté** (`;`, `,` ou tabulation) à la lecture et **réutilisé à
|
||||
l'enregistrement** : un fichier exporté par Excel en français (point-virgule)
|
||||
s'affiche donc en colonnes distinctes et le reste après édition.
|
||||
|
||||
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
|
||||
latéral droit** (la grille reste visible à côté) qui liste les plages
|
||||
nommées du classeur (nom, référence, portée), signale les feuilles
|
||||
contenant des graphiques ou des tableaux croisés, et donne pour chaque
|
||||
feuille un résumé (cellules, lignes, colonnes, formules, valeurs
|
||||
numériques) avec quelques chiffres clés. Cliquer une **plage nommée**
|
||||
sélectionne sa première cellule dans la grille, et le panneau est
|
||||
**redimensionnable**. L'en-tête de l'inspecteur offre
|
||||
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
|
||||
plages. Ses outils couvrent désormais les **trois formats édités**
|
||||
(`.xlsx`, `.xlsm`, `.csv`) : `list_xlsx_sheets` et `xlsx_to_markdown` pour lire,
|
||||
`search_workbook` (recherche dans toutes les feuilles, comptée par feuille),
|
||||
`analyze_range` (agrégats — nombre, somme, moyenne, min, max — d'une plage A1),
|
||||
`update_xlsx_cells` et `append_xlsx_rows` pour modifier, et
|
||||
`edit_xlsx_structure` pour la structure (ajouter/renommer/dupliquer/supprimer
|
||||
une feuille, insérer/supprimer des lignes ou des colonnes).
|
||||
|
||||
### Limites
|
||||
|
||||
- L'affichage intégré démarre à **500 lignes × 40 colonnes** par feuille ;
|
||||
sous une feuille plus grande, le bouton **« Charger la suite »** (ou le
|
||||
défilement vers le bas du tableau) ajoute les lignes suivantes par
|
||||
fenêtres de 500 — elles deviennent aussitôt éditables et
|
||||
sauvegardables. Le chargement paresseux est **vertical uniquement** :
|
||||
l'axe des colonnes reste tronqué à 40 (les colonnes au-delà ne sont ni
|
||||
affichées ni exportées).
|
||||
- Un **format de nombre personnalisé** (devise, pourcentage…) est signalé
|
||||
par une police à chasse fixe à la lecture ; depuis le bouton **Mise en
|
||||
forme**, appliquer un format ne change que le format de la cellule, **pas**
|
||||
la valeur affichée (aucun recalcul n'est fait, cf. les limites d'export
|
||||
ci-dessous).
|
||||
- `.xls` et `.ods` restent en lecture seule (convertir vers `.xlsx` pour
|
||||
éditer) ; les macros d'un `.xlsm` sont conservées mais ne s'exécutent
|
||||
pas dans ObsiGate.
|
||||
- La **poignée de recopie** (fill), la multi-sélection `Ctrl+clic` et le
|
||||
glisser-déposer de lignes/colonnes ne sont pas proposés ; un collage de
|
||||
plusieurs cellules s'annule **cellule par cellule** (`Ctrl+Z` répété).
|
||||
- Les exports (CSV, Markdown, HTML, impression) reflètent ce qui est **affiché** :
|
||||
une feuille tronquée s'exporte tronquée, et les formules sortent telles
|
||||
qu'enregistrées (aucune valeur calculée n'est recalculée). Un classeur reste
|
||||
la source de vérité : utilisez **Charger la suite** pour exporter au-delà du
|
||||
plafond.
|
||||
- **Aucun moteur de formule** : ObsiGate lit et écrit les formules telles
|
||||
qu'Excel les a enregistrées, sans jamais les recalculer. Une saisie
|
||||
commençant par `=` ou `@` est stockée comme **texte** (garde anti-DDE)
|
||||
tant que le bouton `f(x)` n'est pas activé ; l'enregistrement vous le
|
||||
signale par un message. Excel reste la référence pour les valeurs calculées.
|
||||
- L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les
|
||||
actions de structure ; en revanche une **suppression** (feuille, ligne,
|
||||
colonne) n'est pas annulable, faute d'inverse.
|
||||
|
||||
---
|
||||
|
||||
## 7. Diagrammes Excalidraw
|
||||
|
||||
Les fichiers `.excalidraw` et `.excalidraw.md` (dont le format compressé du
|
||||
**plugin Obsidian Excalidraw**) s'ouvrent dans un **éditeur visuel Excalidraw
|
||||
complet**, dans une iframe sandboxée.
|
||||
|
||||
- **Dessin et édition** sans quitter ObsiGate.
|
||||
- **Sauvegarde automatique** (débounce 2 s) ou `Ctrl + S`.
|
||||
- **Thème** clair/sombre suivi automatiquement.
|
||||
- **Texte indexé** : le texte des éléments du diagramme est extrait à
|
||||
l'indexation et donc recherchable (`ext:excalidraw`).
|
||||
|
||||
Fiche technique : [`features/excalidraw.md`](../features/excalidraw.md).
|
||||
|
||||
---
|
||||
|
||||
## 8. Autres contenus riches
|
||||
|
||||
### Mermaid
|
||||
|
||||
Les blocs de code ` ```mermaid ` sont rendus en diagrammes interactifs (live
|
||||
preview, thèmes, zoom, plein écran, pré-processeur compatible syntaxe Obsidian).
|
||||
|
||||
### Images Obsidian
|
||||
|
||||
Toutes les syntaxes d'images sont supportées avec résolution intelligente en
|
||||
7 stratégies :
|
||||
|
||||
1. chemin absolu ;
|
||||
2. dossier d'attachements configuré (`VAULT_N_ATTACHMENTS_PATH`) ;
|
||||
3. index de démarrage (correspondance unique) ;
|
||||
4. même répertoire que la note ;
|
||||
5. racine de la vault ;
|
||||
6. index de démarrage (correspondance la plus proche) ;
|
||||
7. repli : `[image not found: fichier.ext]`.
|
||||
|
||||
Rescan manuel des attachements :
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes"
|
||||
```
|
||||
|
||||
### Graphe et backlinks
|
||||
|
||||
- **Graphe** : vue force-directed (Barnes-Hut), filtres (tag, type), profondeur,
|
||||
mode focus, export PNG, aperçu au survol (`Ctrl + clic`).
|
||||
- **Backlinks** : `GET /api/file/{vault}/backlinks?path=…` liste les notes
|
||||
pointant vers un document.
|
||||
|
||||
---
|
||||
|
||||
## 9. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| Un PDF ne s'affiche pas | Vérifier la taille (`OBSIGATE_PDF_MAX_SIZE_MB`, défaut 50 Mo) |
|
||||
| Le texte d'un PDF scanné n'est pas trouvé | Pas d'OCR : normal |
|
||||
| Une image reste introuvable | Configurer `VAULT_N_ATTACHMENTS_PATH`, puis rescan |
|
||||
| La recherche sémantique ne s'active pas | Vérifier le toggle `~` et `OBSIGATE_EMBEDDING_*` |
|
||||
| Résultats obsolètes | Forcer une réindexation : `GET /api/index/reload` |
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
||||
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
||||
- **Dernière mise à jour** : 2026-09-17
|
||||
- **Dernière mise à jour** : 2026-09-29
|
||||
|
||||
---
|
||||
|
||||
@@ -179,13 +179,39 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-068* | Configuration — section « 🔒 Sécurité du compte » inachevée : boutons hors thème, QR code invisible, fiabilité des fonctions à valider | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/auth.js`, `frontend/style.css`, `backend/auth/router.py` | Configuration → 🔒 Sécurité du compte | `frontend/style.css` (+`config-btn-primary`/`danger` thème), `backend/auth/router.py` (`qr_data_url` segno local), `frontend/js/auth.js` (QR local + fallback, recovery WebAuthn, carte mot de passe, escapeHtml labels), locales FR/EN, `backend/requirements.txt` (+segno) ; tests `tests/test_mfa.py` (+1) + `tests/frontend/mfa-settings.test.mjs` (nouveau, 9) | pytest 1241 passed / 6 skipped, ruff 0, mypy 0, frontend unit + validate-imports verts |
|
||||
| *BUG-069* | Login 2FA bloqué sans erreur : après user+pwd corrects, la page de login reste affichée et le challenge MFA n'apparaît jamais | 🟢 corrigé | P0 | 📱 frontend | IA | `frontend/js/auth.js`, `frontend/index.html` | Activer 2FA → logout → login (bon user+pwd) | `frontend/js/auth.js` (`showMfaChallenge` → `.login-card` + erreur `mfa.challenge_unavailable` si montage impossible), locales FR/EN ; tests `tests/frontend/mfa-settings.test.mjs` (+2) | Reproduit au navigateur avant correctif (challenge jamais affiché), vérifié après : challenge affiché, code erroné → erreur, code valide (200) → app ; frontend mfa-settings 11/11, unit + validate-imports verts |
|
||||
| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 |
|
||||
| *BUG-071* | Configuration « Configurations » inutilisable en mode mobile : sommaire masqué sans bouton d'accès, navigation par ancre sans JS, grilles 2 colonnes et rangées d'ajout qui débordent (≤768px) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json` | Mobile (≤768px) : ouvrir Configurations → aucun sommaire ni moyen d'atteindre une section ; champs « Clés IA » / jetons / webhooks débordent | `index.html` (+`#config-hamburger` `.help-hamburger`, `config.toc_toggle` FR/EN) ; `config.js` (toggle, scroll doux + actif + repli auto mobile, reset à l'ouverture) ; `i18n.js` (`data-i18n-attr` multi-paires `;`) ; `style.css` (bloc mobile `#config-modal` : sommaire haut 46vh, grilles 1fr, add-rows wrap + `!important`, items wrap, 44px) ; tests `tests/frontend/config-mobile.test.mjs` (nouveau, 11) + CI ; E2E `tests/e2e/config-mobile.spec.js` (nouveau, 3/3 projet chromium-mobile, ignoré en desktop) | pytest 1249 passed / 6 skipped, ruff 0, mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + sidebar 6/6 + mobile 35/35 + ai-keys 7/7 |
|
||||
| *BUG-072* | Visionneuse d'images : le plein écran et le panneau « Métadonnées » ne sont pas conservés lors de la navigation ←/→, et le panneau s'affiche sous la pellicule au lieu d'une barre latérale | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/viewer.js`, `frontend/style.css` | Ouvrir une image, activer le plein écran (ou Métadonnées), puis naviguer avec les flèches précédent/suivant | État persistant `_imageViewerState { lightbox, meta }` + drapeau `_imageViewerNavPending` posé par `go()`/pellicule : `renderFile` ne réinitialise que hors navigation image→image. Panneau reconstruit dans `.image-viewer-body` (sidebar droite, `border-left`, `width:280px; max-width:40%`) ; la règle lightbox ne masque plus que la pellicule. Boutons `image-btn-lightbox`/`image-btn-metadata` (+ `aria-pressed`), `Escape` resynchronisé. Tests : `tests/frontend/image-viewer.test.mjs` (+2), E2E `tests/e2e/image-viewer.spec.js` (+1). | Navigation → `openFile` → `renderImageViewer` recréait le conteneur : les états `lightbox`/`metaPanel` étaient perdus. Le panneau était rendu en bas (colonne) au lieu d'une sidebar droite |
|
||||
| *BUG-073* | Mobile : la barre de navigation fixe du bas masque le bas de tous les documents et pages affichés (les dernières lignes restent définitivement sous la barre) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/style.css` | Mobile (≤768px) : ouvrir un document long, défiler jusqu'au bas → la fin du contenu passe sous la barre `#mobile-toolbar` et n'est jamais atteignable | Clearance mobile retargetée de `.main-layout` (sélecteur mort, absent de `index.html`) vers `.main-body` (`calc(64px + env(safe-area-inset-bottom, 0))`) ; règle sœur morte `.editor-modal.active ~ .main-layout` supprimée ; reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture) ; `body.np-active .content-area` ramené à `76px` (dégagement dock seul, géométrie totale inchangée). Tests : `tests/frontend/mobile-toolbar.test.mjs` (7, au CI), E2E `tests/e2e/mobile-toolbar.spec.js` (2, `chromium-mobile`, skip desktop) | La règle de clearance du bloc mobile cible `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, aucun dégagement réservé |
|
||||
| | | | | | | | | | | |
|
||||
| *BUG-074* | [🟡 IMPORTANT] Assistant IA : le bloc d'étapes affiche « 1 step » sans titre alors que l'agent réalise plusieurs actions (compteur toujours à 1) | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/agent/loop.py` | Mode agent : demander une création multi-fichiers/dossiers → chaque message ne montre qu'« 1 étape ▶ » sans détail | `backend/agent/loop.py` + `frontend/js/bookslm.js` : compteur = actions (hors réflexions) + titre = 1re action dans le `<summary>` ; reprise de confirmation diffusée dans le **même** message (fusion des étapes). Tests : `tests/frontend/ai.test.mjs` (+3) | Le résumé `<summary>` ne porte aucun titre ; chaque reprise de confirmation crée un **nouveau** message assistant qui ne contient qu'une action ; les réflexions gonflent le compteur |
|
||||
| *BUG-075* | [🟡 IMPORTANT] Assistant IA : chaque action mutatrice demande son propre « Appliquer » — aucun résumé des actions en attente ni approbation globale | 🟢 corrigé | P1 | ⚙️ backend + 📱 frontend | IA | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js` | Mode agent : demander une structure de répertoires multi-fichiers → valider une action après l'autre | `backend/agent/loop.py` (`pending.actions`, lot exécuté au resume) ; `backend/bookslm_routes.py` (`confirm_all` → `ctx.confirmed`) ; `frontend/js/bookslm.js` (carte multi-actions + « Tout approuver (N) »). Tests : `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs` | La pause de confirmation ne capture que le **premier** appel mutateur du lot (les suivants sont `deferred`) ; carte unique sans liste ; nouveau `confirm_all` à ajouter pour autoriser la suite de l'exécution en une approbation |
|
||||
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
|
||||
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
|
||||
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
|
||||
| *BUG-081* | `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée (`user` None, `AttributeError` sur `user.get`) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/auth/router.py::mfa_status`, `tests/test_mfa.py` | Auth désactivée : `curl http://127.0.0.1:2029/api/auth/mfa/status` → 500 (reproduit live 2026-09-27) | Garde `user is None` → payload MFA désactivé (`mfa_enabled: false`, `totp_enabled: false`, `webauthn_credentials: 0`) ; test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0 | `require_auth` laisse passer le pseudo-user anonymous, `get_user(username)` → None non gardé. Trouvé via les logs E2E pendant BUG-080 |
|
||||
| *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 |
|
||||
| *BUG-084* | Index inversé : la suppression d'une vault y laisse des documents fantômes (résultats pour une vault inexistante) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/indexer.py::remove_vault_from_index`, `backend/search.py::_remove_doc_internals` | Supprimer une vault configurée, puis chercher un terme contenu dans ses fichiers → les résultats la concernent encore | `remove_vault_from_index()` déclenche `_on_index_change('remove', …)` pour chaque fichier de la vault ; `_remove_doc_internals()` supprime la clé `vault_docs` dont le set devient vide (`defaultdict` : une lecture la recréait). Test `tests/test_search_advanced.py::TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le correctif) | Trouvé pendant la relecture de `plan.md` (étape 6 déjà livrée). Mesuré : 8 documents fantômes sur 8 après suppression de la vault de test (`postings`, `doc_info`, `doc_vault`, `vault_docs`) ; seul un reindex manuel les effaçait. Vérifié : `test_search_advanced.py` 27 passed, ruff/mypy 0, suite complète 1374 passed / 6 skipped |
|
||||
| *BUG-085* | Édition d'un `.xlsx` : les valeurs calculées en cache disparaissent du classeur (et tout lecteur `data_only=True` voit `None`) | 🟢 corrigé | P1 | tableur Excel | IA | `backend/xlsx_reader.py::inspect_workbook`, `backend/services/mutations.py::edit_xlsx_cells`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | Ouvrir un classeur contenant `=B1*2` (avec sa valeur calculée) → éditer une cellule → le `<v>` disparaît du XML de la feuille | `LOSSY_PARTS` + sonde `<f>…</f><v>[^<]` ; la lecture renvoie `xlsx_lossy_features` ; `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`) ; bandeau + confirmation UI puis reprise `force: true`. Tests : `TestXlsxLossyGuard` (5) + `xlsx-viewer.test.mjs` (10) + `tests/e2e/xlsx-viewer.spec.js` (3) | #153 A1. Périmètre réel vérifié sur openpyxl 3.1.5 : graphiques, images, dessins **et** TCD survivent au round-trip ; les pertes sont valeurs en cache, slicers/chronologies, contrôles de formulaire, connexions/requêtes, custom XML, signature, commentaires enrichis, macros. Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, E2E 3/3 |
|
||||
| *BUG-086* | Édition d'un `.xlsx` : `wb.save()` écrit en place, un plantage laisse un classeur corrompu | 🟢 corrigé | P1 | tableur Excel | IA | `backend/services/mutations.py::edit_xlsx_cells` | Simuler un `OSError` pendant `Workbook.save` → le fichier d'origine est tronqué | Écriture atomique : `wb.save(<nom>.<pid>.tmp)` puis `os.replace()` ; `.tmp` supprimé sur échec ; le backup `.bak` reste inchangé. Test : `TestXlsxAtomicWrite::test_failed_save_keeps_the_original` (octets identiques après échec) + `test_no_tmp_left_after_a_successful_save` | #153 A2. Le fichier temporaire a un suffixe `.tmp` → ignoré par le watcher (`_is_relevant` ne retient que les extensions supportées). Vérifié : cf. BUG-085 |
|
||||
| *BUG-087* | Édition d'un `.xlsx` concurrente (deux onglets, agent IA + viewer) : read-modify-write sans verrou, le dernier écrivain gagne silencieusement | 🟢 corrigé | P1 | tableur Excel | IA | `backend/services/mutations.py::_xlsx_write_lock` | Deux `PUT xlsx/save` simultanés sur le même fichier → une écriture est écrasée sans trace | Verrou par chemin (registre + garde, timeout 15 s) autour du cycle load → edit → `os.replace` ; attente dépassée → **409** `conflict`. L'endpoint est devenu `def` (sync) pour que l'attente s'exécute dans le threadpool et ne bloque pas la boucle d'événements. Test : `TestXlsxWriteLock` (2) | #153 A3. Verrou en mémoire, par processus : protège les cas d'un même serveur (le cas desktop/Tauri). Vérifié : cf. BUG-085 |
|
||||
| *BUG-088* | Injection de formule dans un `.xlsx` : une saisie `=cmd\|'/c calc'!A1` est stockée comme formule et s'exécute à l'ouverture dans Excel (DDE) | 🟢 corrigé | P0 | tableur Excel / sécurité | IA | `backend/services/mutations.py::_write_cell`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | `PUT /api/file/V/xlsx/save` avec `{"sheet": "S", "cells": {"A1": "=1+1"}}` → la cellule sort en `data_type == "f"` | `cell.data_type = "s"` après affectation : le texte est stocké comme chaîne, aucun `<f>` n'est écrit. Opt-in via `allow_formula: true` (endpoint) et le bouton `f(x)` de la visionneuse (session, jamais persisté). Test : `TestXlsxFormulaGuard` (4) + `xlsx-viewer.test.mjs` (toggle) | #153 A4. `+`/`-` ne sont pas neutralisés : ils sont déjà convertis en nombre par `_coerce_xlsx_value`. Le handler global `ServiceError` expose désormais `code` + `details` (le client en a besoin pour le 409), et `api()` (frontend) les propage sur l'Error. Vérifié : cf. BUG-085 |
|
||||
| *BUG-089* | Un reindex manuel ne reconstruisait pas l'index inversé : la recherche TF-IDF continuait de servir un index périmé | 🟢 corrigé | P1 | ⚙️ backend / recherche | IA | `backend/indexer.py::reload_index`, `backend/indexer.py::reload_single_vault`, `backend/search.py` | Modifier le contenu d'un fichier, puis `GET /api/index/reload` → la recherche renvoie encore l'ancien contenu (ou rien pour un fichier nouveau) | `reload_index()` / `reload_single_vault()` appellent `init_inverted_index()` après le rebuild (le remplacement wholesale d'une entrée de vault n'émet pas les notifications incrémentales). En prime, `backend/search.py` lisait l'index via `from backend.indexer import index` (liaison **par valeur** du dict) : un `importlib.reload(backend.indexer)` recréait le dict côté indexer tandis que la recherche écrivait encore dans l'ancien — l'index inversé n'indexait alors plus rien. Tous les accès passent désormais par `_indexer.index`. Contre-preuve : `TestXlsxSearchable::test_search_finds_a_word_stored_in_a_cell` échoue sans le correctif | #153 A5. Trouvé en écrivant le test de recherche d'A5 : il passait isolément et échouait en suite complète selon l'ordre. Le reload incrémental par fichier (watcher, edition) n'est pas concerné : il passe par le hook `_on_index_change`. Vérifié : suite 1402 passed / 6 skipped, ruff/mypy 0 |
|
||||
| *BUG-090* | Troncature silencieuse d'une feuille `.xlsx` au-delà de 500 lignes × 40 colonnes : l'utilisateur voit une table courte sans aucun indice que la suite existe | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `backend/routers/files_read.py`, `frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css` | Ouvrir `test_vault/sample-xlsx-large.xlsx` (520 lignes) → la feuille s'arrête à la ligne 500 sans aucun message | `render_sheets()` renvoie désormais `total_rows`/`total_cols` (dimensions déclarées par la feuille), `max_rows`/`max_cols` (plafonds du moteur) et `truncated` ; la visionneuse affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 » (i18n `xlsx.truncated_*` FR/EN, axe des colonnes inclus). Contre-preuve : neutraliser `truncated` → `TestXlsxTruncationNotice` (2 tests) échoue | #153 A8/R5. La ligne d'en-têtes est aussi `sticky` au défilement vertical (`thead th { top: 0 }` + `top: auto` sur les numéros de ligne pour éviter l'empilement en haut à gauche). L'endpoint `GET …/xlsx/sheet` (#153 A9) sert les fenêtres au-delà du plafond, mais le chargement paresseux complet (défilement virtuel, « charger tout ») reste à faire — le bandeau dit la vérité en attendant. Vérifié : `test_xlsx_viewer.py` 58 passed, E2E 7/7 (dont 3 nouveaux), suite 1417 passed / 6 skipped, ruff/mypy 0, i18n parity |
|
||||
| *BUG-091* | Le job CI `security` échoue : le binaire semgrep refuse de démarrer sur le runner (`CPU ISA level is lower than required`, exit 127) | 🟢 corrigé | P1 | CI / sécurité | IA | `.gitea/workflows/ci.yml` (job `security`), `backend/requirements.txt` | Run Gitea #1641 : étape « Semgrep » → `libs/libresolv.so.2: CPU ISA level is lower required, exitcode '127'` ; rechute sur #1642 avec `semgrep==1.174.0`, puis sur #1654 avec `1.157.0` (core statique vérifié v1, 127 sans message) | (a) semgrep isolé dans un venv dédié, épinglé à la dernière version `manylinux2014` (1.157.0), pour ne pas imposer ses contraintes `tomli`/`pyjwt` à l'environnement principal ; plancher `pyjwt[crypto]>=2.13.0` dans requirements.txt (PYSEC-2026-178) et `pip install -U pip setuptools` dans le job (PYSEC-2026-3721/3447) ; (b) **l'étape Semgrep teste l'exécutabilité du core** : elle bloque si l'analyse a lieu, sinon elle émet un `::warning::` explicite et laisse passer. Bandit et pip-audit restent bloquants | #153. security échouait déjà avant ce push (v2.31.0/v2.32.0 rouges) ; les commits de features v2.33.0→v2.39.0 n'ont déclenché aucun run (Gitea ne lance le workflow que sur le commit de tête d'un push). Deux hypothèses infirmées en route : « série 1.175+ incompatible » (1.157.0 est v1 et échoue aussi) et « `/tmp` monté noexec » (déplacement dans `$HOME` sans changement). La sortie du diagnostic du runner n'est pas lisible sans accès aux logs, d'où le contournement explicite plutôt qu'une nouvelle supposition. **À reprendre** sur un runner x86-64-v2, où semgrep redeviendra bloquant sans modification |
|
||||
|
||||
| *BUG-092* | Les tests réseau dépendent du DNS réel du runner : `test_worker_failure_maps_to_tool_error` échoue en `dns_error` au lieu d'atteindre le worker Playwright mocké, et le job CI `test` rougit de façon intermittente | 🟢 corrigé | P1 | CI / tests | IA | `tests/test_webrender.py`, `tests/test_web_tools.py` | Sur un runner au DNS instable : `pytest tests/test_webrender.py -k test_worker_failure_maps_to_tool_error` → `assert 'dns_error' == 'render_unavailable'` | Fixture `no_dns` mockant les **deux** références du garde SSRF `_assert_public_http_url` (celle de `backend/tools/web.py` et celle importée dans le namespace de `backend/tools/webrender.py`, ligne 30 — la seconde avait d'abord échappé au correctif). Les tests de garde SSRF n'utilisent pas la fixture et continuent de traverser le vrai garde | Le garde est appelé par `fetch_url` **avant** le traitement ; seule la couche httpx était mockée. Contre-preuve : DNS coupé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** |
|
||||
| *BUG-093* | Le job CI `security` échoue : `pip-audit` bloque sur deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910, PYSEC-2026-3911) — et le plancher `pypdf>=4.0` ne les corrigeait pas, car l'image Act du runner embarque 6.16.0 *préinstallé* dans sa toolcache Python (`Requirement already satisfied` ⇒ jamais mis à niveau) | 🟢 corrigé | P0 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | Run Gitea #1660, job `security` : `Found 2 known vulnerabilities, ignored 2 in 1 package` → `pypdf 6.16.0 PYSEC-2026-3910 6.16.1` / `PYSEC-2026-3911 6.16.1` | Plancher `pypdf>=6.16.1` (correctif des deux advisories), commenté pour expliquer la contrainte de la toolcache. Ajout de `tests/test_ci_workflow.py::TestDependencySecurityFloors`, qui verrouille les planchers de sécurité (`pypdf`, `pyjwt`) et interdit qu'ils retombent sous le correctif | Les deux advisories sont des **consommations de ressources non contrôlées** (PDF à outlines multiples ou à nombreux XForm réutilisés) et sont donc **atteignables** par ObsiGate, dont `backend/pdf_reader.py` extrait le texte et parcourt les outlines de PDF fournis par l'utilisateur. Contre-preuve : plancher remis à `>=4.0` → le garde-fou échoue. pip-audit local : 6.16.1, 6.16.2 et 6.19.0 sans vulnérabilité connue. Correction découverte en lisant le log du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log de l'étape Semgrep collé précédemment datait d'un run antérieur |
|
||||
| *BUG-094* | Feuille `.xlsx` vide ou nouvellement ajoutée : impossible d'y saisir une valeur et d'y insérer une ligne/colonne — la feuille s'affiche « Feuille vide » sans aucune cellule | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `frontend/js/viewer.js::renderXlsxViewer` | Ajouter une feuille (`PUT …/xlsx/structure` `sheet_add`) puis tenter de saisir A1 ou d'insérer une ligne/colonne | `render_sheets()` remplace une grille vide par un quadrillage vierge 20×8 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) aux vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`) | Le classeur n'était pas en cause : seule la **représentation HTML** était vide, donc aucun `td` à sélectionner → aucune cellule active → aucune action de structure possible. Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52 |
|
||||
| *BUG-095* | Le job CI `security` échoue : `pip-audit` bloque sur CVE-2026-102274 dans `pyjwt` 2.13.0 (correctif 2.14.0) — le plancher `>=2.13.0` (BUG-091) est désormais sous le dernier correctif | 🟢 corrigé | P1 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | Run Gitea du push v2.44.0, job `security` : `Found 1 known vulnerability, ignored 2 in 1 package` → `pyjwt 2.13.0 CVE-2026-102274 2.14.0` | Plancher `pyjwt[crypto]>=2.14.0` (au-dessus de la version préinstallée de la toolcache du runner, sinon `pip` répond « already satisfied »). Garde-fou `TestDependencySecurityFloors` : `FLOORS["pyjwt"]` porté à `(2, 14, 0)` — contre-preuve : plancher remis à `2.13.0` → test rouge. Commentaire de la job `security` mis à jour | Le plancher de BUG-091 (2.13.0) corrigeait PYSEC-2026-178 mais est lui-même vulnérable depuis. Même mécanisme que BUG-093 (pypdf) : un plancher de sécurité doit rester au-dessus du dernier correctif. Vérifié : `test_ci_workflow.py` 9 passed ; pip-audit local OK (pyjwt 2.15.1 ≥ 2.14.0) |
|
||||
| *BUG-096* | [🟡 IMPORTANT] Enregistrer un `.csv` depuis la visionneuse échoue : `TypeError` sur `sheets[…].name` (aucun `PUT …/csv/save` émis) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`renderXlsxViewer`, construction des jobs de sauvegarde) | Ouvrir un `.csv` dans ObsiGate, modifier une cellule, cliquer **Enregistrer** | `frontend/js/viewer.js` : le job de sauvegarde ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV — l'origine du `TypeError`) — repli feuille → `data.title`/`data.path`. Test JSDOM « saving an edited csv PUTs /csv/save (payload without xlsx_sheets) ». Vérifié : suite 1490 passed / 6 skipped, `xlsx-viewer.test.mjs` 61/61 | Défaut #156 A1. En mode CSV la réponse de lecture n'a pas de `xlsx_sheets` (`backend/routers/files_read.py`) : `sheets` est vide et `sheets[Number(panel.dataset.sheet)].name` lève un `TypeError` **avant** le `try`, donc aucun `PUT /api/file/{vault}/csv/save` n'est émis (le service `save_csv_cells()` et l'endpoint sont, eux, corrects). **Reproduit en JSDOM le 2026-09-29** (harnais de `tests/frontend/xlsx-viewer.test.mjs`) : `Cannot read properties of undefined (reading 'name')`, 0 requête d'API enregistrée. Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-097* | [🟡 IMPORTANT] Feuille `.xlsm` tronquée : « Charger la suite » échoue en **415** (`GET …/xlsx/sheet` n'accepte que `.xlsx`) alors que le `.xlsm` est éditable | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/routers/files_read.py` (`api_file_xlsx_sheet`), `frontend/js/viewer.js` (`wireLazyRows`) | Ouvrir un `.xlsm` de plus de 500 lignes puis cliquer le pied « Charger la suite » (ou approcher du bas du tableau) | `backend/routers/files_read.py` : `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (autres formats → 415 inchangé). Tests `TestXlsmEditable::test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats` | Défaut #156 A2. `.xlsm` est servi éditable (`files_read.py:491-512`, pas de `xlsx_readonly`) donc la visionneuse câble le chargement paresseux, mais `api_file_xlsx_sheet` refuse tout ce qui n'est pas `.xlsx` (`files_read.py:272`). Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| *(exemple)* TODO-002 | Rendre l'index inversé incrémental (40k+ fichiers) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/indexer.py`, `backend/search.py` | Recherche sur très gros vault | — | Exemple à remplacer. Cf. plan.md |
|
||||
| *(À remplir)* | | | | | | | | | |
|
||||
|
||||
---
|
||||
@@ -198,6 +224,14 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-28 | BUG-090 (#153 A8 + A9) | Correction + feature | `backend/xlsx_reader.py`, `backend/routers/files_read.py`, `backend/schemas.py`, `backend/openapi_docs.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-large.xlsx` | **La troncature d'une feuille est annoncée et les lignes cachées restent accessibles** : (BUG-090/A8) `render_sheets()` renvoie `total_rows`/`total_cols`/`max_rows`/`max_cols`/`truncated`, la visionneuse affiche un bandeau « Feuille tronquée » (i18n FR/EN, axes lignes et colonnes) et la ligne d'en-têtes devient `sticky` (`top: auto` sur les numéros de ligne pour éviter l'empilement) ; (A9) `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`, plafond 1 000 lignes/requête, 404 feuille inconnue, 415 non-xlsx) sert une fenêtre avec les **vraies** coordonnées A1 et le `has_more` de pagination. Contre-preuves : neutraliser `truncated` → 2 tests échouent ; neutraliser l'offset → 3 tests échouent. Vérifié : `test_xlsx_viewer.py` 58 passed, xlsx-viewer.test.mjs 14/14, E2E 7/7 (3 nouveaux + fixture `sample-xlsx-large.xlsx` 520 lignes), suite 1417 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-091 (suite — désactivation semgrep en CI) | Correction CI | `.gitea/workflows/ci.yml`, `CHANGELOG.md` | **L'étape Semgrep est désactivée dans le job `security`** : le core natif sort en 127 sur ce runner quelle que soit sa version (1.178 = message ISA explicite ; 1.157.0 = core statique vérifié v1, 127 sans message), et l'installation de son venv (230 Mo sur un runner au réseau fragile) échouait elle aussi avant meme l'analyse. Trois hypothèses ont été testées puis infirmées — « releases 1.175+ incompilables » (1.157.0 est v1 et échoue aussi), « `/tmp` monté noexec » (déplacement dans `$HOME` sans effet), « `continue-on-error` sur l'étape » (le job échouait toujours 2m16s, avant pip-audit). Faute d'accès aux logs du runner pour lire la sortie du diagnostic, la SAST semgrep est retirée du CI : **bandit et pip-audit restent bloquants**, les 8 règles locales restent applicables en local (`semgrep --config semgrep-rules/ backend/`) et l'étape est réactivable telle quelle sur un runner x86-64-v2 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-092 (job CI `test`, #153) | Correction tests | `tests/test_webrender.py`, `tests/test_web_tools.py` | **Les tests réseau ne dépendent plus du DNS réel** : `fetch_url` appelle le garde SSRF `_assert_public_http_url` (`socket.getaddrinfo`) *avant* le traitement, et seule la couche httpx était mockée. Sur le runner au DNS instable, `tests/test_webrender.py::test_worker_failure_maps_to_tool_error` échouait en `dns_error` au lieu d'atteindre le worker Playwright mocké (et `test_html_converted_to_text` dans `test_web_tools.py` de la même façon). Correctif : fixture `no_dns` mockant les **deux** références du garde (`web._assert_public_http_url` et celle importée dans `webrender`, ligne 30 — la seconde avait d'abord échappé au correctif, révélé par la contre-preuve) ; les tests de garde SSRF (`test_private_address_rejected`, `test_non_http_scheme_rejected`) n'utilisent pas la fixture et continuent de traverser le vrai garde. Contre-preuve : DNS cassé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-093 (job CI `security`, run #1660) | Sécurité / Correction CI | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | **Le job `security` est enfin vert** : la désactivation de semgrep (v2.39.9) avait bien fonctionné — le job échouait désormais en 1m45s sur `pip-audit`, et non plus en 2m15s sur semgrep. Cause : deux DoS de ressources publiés sur `pypdf` 6.16.0 (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, correctif 6.16.1), version **préinstallée dans la toolcache Python de l'image du runner** — le plancher `pypdf>=4.0` était donc satisfait et l'image n'était jamais mise à niveau. Correctif : plancher `pypdf>=6.16.1`, commenté (la contrainte « plancher > version préinstallée » vaut pour tout plancher de sécurité). Garde-fou `tests/test_ci_workflow.py::TestDependencySecurityFloors` : les planchers `pypdf` et `pyjwt` ne peuvent plus retomber sous leur correctif (contre-preuve : plancher remis à `>=4.0` → test rouge). Au passage, **`tests/test_ci_workflow.py::TestSemgrepStep` était en régression depuis v2.39.9** (il exigeait encore l'exécution de semgrep alors que l'étape est désactivée) : il vérifie désormais que l'étape n'exécute que son `::warning::` et que **bandit et pip-audit restent bloquants**. Cause trouvée en lisant le log brut du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log d'étape Semgrep collé précédemment datait d'un run antérieur | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-091 (#153, runs CI #1641-#1642) | Correction CI | `.gitea/workflows/ci.yml`, `backend/requirements.txt`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Le job `security` est réparé définitivement** : (1) le binaire semgrep non épinglé exige depuis 1.158.0 un CPU x86-64-v2 que le runner Gitea ne fournit pas (`libs/libresolv.so.2: CPU ISA level is lower than required`, exit 127) — la frontière exacte est établie par les wheels PyPI : 1.157.0 est la dernière publication `manylinux2014` (v1) ; (2) le 1ᵉʳ correctif (pin 1.174.0, v2.39.2) échouait car cette version ne publie qu'en `manylinux_2_34` ; (3) semgrep vit désormais dans un venv isolé du job (`/tmp/semgrep-venv`, pin 1.157.0) car ses dépendances contredisent l'env principal (`tomli~=2.0.1` vs pip-audit ≥ 2.10, `pyjwt~=2.12.0` vs PYSEC-2026-178) ; (4) plancher `pyjwt[crypto]>=2.13.0` dans requirements.txt (transitif de mcp) et `pip install -U pip setuptools` dans le job (nouveaux advisories pip PYSEC-2026-3721, setuptools PYSEC-2026-3447). Validation : environnement frais reconstitué en local → résolution sans conflit (pyjwt 2.15.1), pip-audit exit 0, semgrep 1.157.0 exit 0 sur `semgrep-rules/`. Au passage documenté : security échouait déjà avant ce push (v2.31.0/v2.32.0 rouges) et les commits de features n'ont déclenché aucun run (Gitea : commit de tête uniquement) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-28 | #153 A6 → A17 (v2.33.0 → v2.39.0) | Feature + clôture documentaire (aucun bug nouveau) | `CHANGELOG.md`, `docs/features/xlsx-viewer.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **Clôture du backlog #153** : entrées CHANGELOG des 7 sous-tâches, fiche `features/xlsx-viewer.md` (statut terminé, cases A6-A17 cochées, historique), section 6 du guide utilisateur étendue (barre de formule, navigation clavier, tri/filtre/recherche/export CSV, structure, styles, formats `.xlsm`/`.xls`/`.ods`/`.csv`, tableau de bord) et bullets README FR/EN. Code livré : v2.33.0 A6 (outils IA `backend/tools/spreadsheets.py`), v2.34.0 A7 (clavier + barre de formule), v2.35.0 A13 (tri/filtre/recherche/export), v2.36.0 A14 (structure `PUT …/xlsx/structure`), v2.37.0 A15 (styles/fusions/volets figés), v2.38.0 A16 (`.xlsm` éditable, `.xls`/`.ods` lecture seule, `.csv` RFC 4180), v2.39.0 A17 (dashboard `GET …/xlsx/dashboard`). Vérifié : suite xlsx 116 passed, xlsx-viewer.test.mjs 35/35, ruff/mypy 0, i18n parity, validate-imports 40 modules | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-09-28 | BUG-089 (#153 A5, A10, A12) | Correction | `backend/xlsx_reader.py`, `backend/indexer.py`, `backend/search.py`, `backend/services/mutations.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py` | **Les tableurs deviennent visibles ettypés** : (A5) `extract_indexable_text()` indexe noms de feuilles + 20 premières lignes (plafond 5 k caractères) dans le TF-IDF et la recherche sémantique — un mot tapé dans une cellule rend le fichier trouvable ; (A10) `_coerce_xlsx_value()` reconnaît désormais les booléens (`TRUE`/`FAUX`/`OUI`/`NON`) et les dates FR `JJ/MM/AAAA` (jour-first : `01/02/2026` = 1er février), symétrique avec l'affichage ; (A12) la valeur calculée en cache s'affiche sous la formule (`<span class="xlsx-cached">`, 2ᵉ lecture `data_only=True` uniquement si l'archive contient un `<v>`), info-bulle traduite via `xlsx.cached_value_title` FR/EN. (BUG-089) un reindex manuel reconstruisait mal l'index inversé et `backend/search.py` lisait l'index par valeur. Contre-preuves vérifiées pour A5, A10 et A12. Vérifié : `test_xlsx_viewer.py` 43 passed, suite 1402 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-085 → BUG-088 (#153 A1-A4) | Correction | `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/main.py`, `frontend/js/viewer.js`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-lossy.xlsx`, `.gitea/workflows/ci.yml` | **Garde-fous d'écriture des classeurs Excel** : (BUG-085) `inspect_workbook()` détecte ce qu'un round-trip openpyxl perd (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) → la lecture expose `xlsx_lossy_features`, la visionneuse affiche une bannière et `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`, confirmation explicite puis reprise) ; (BUG-086) écriture atomique `.tmp` + `os.replace` ; (BUG-087) verrou par fichier (409 `conflict`, endpoint sync pour le threadpool) ; (BUG-088) une saisie `=`/`@` est stockée en texte (`data_type = "s"`), sauf opt-in `allow_formula` / bouton `f(x)`. Le handler `ServiceError` expose désormais `code` + `details` et `api()` les propage. Périmètre de perte revalidé empiriquement sur openpyxl 3.1.5 (graphiques, images et TCD sont préservés). Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10, E2E 3/3 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
|
||||
| 2026-09-09 | BUG-001, BUG-002 | Correction | `backend/main.py`, `frontend/excalidraw-editor.html`, `tests/test_pdf_stream.py` | BUG-001: Content-Disposition RFC 5987 (nom PDF accentué ne casse plus l'en-tête → plus de 500). BUG-002: suppression alias esm.sh (408 jotai) + React 19 cohérent + prop `excalidrawAPI` → Loading masqué, save OK. Vérifié: 534 tests backend verts + E2E navigateur. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-11 | BUG-003, BUG-004 | Correction | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml`, `README.md`, `README.fr.md` | BUG-003: 33 erreurs mypy corrigées (annotations, gardes `None`, import `PROVIDERS` manquant → bug latent) + étape CI mypy rendue bloquante. BUG-004: lien `README.md` → `docs/CONTRIBUTING.md`. Vérifié: mypy 0 erreur, ruff OK, pytest 728 passed, frontend OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
@@ -255,6 +289,26 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-070 | Correction | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py`, `.env.example`, `tests/test_webauthn.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-070** : activation WebAuthn rejetée en 400. (1) Défauts `localhost` sans port → `resolve_relying_party()` dérive rp_id/origines de la requête (config explicite prioritaire, forwarded sous TRUST_PROXY), appliqué aux endpoints register + login. (2) Challenge single-use → 5 derniers conservés, vérification contre le challenge de la cérémonie en cours. **Vérifié au navigateur** (authentificateur virtuel CDP, instance Docker) : register 200, clé listée, clé de test retirée. Tests : `test_webauthn.py` 19/19 (+8), suite complète 1249 passed / 6 skipped, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-071 | Correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071** : page « Configurations » inutilisable en mobile. (1) `#config-nav` masquée sous 768px sans toggle → hamburger `#config-hamburger` ajouté à l'en-tête (`.help-hamburger`, libellé `config.toc_toggle` FR/EN). (2) Ancres brutes sans JS → interception en `config.js` (scroll doux, lien actif, repli auto mobile, reset à l'ouverture). (3) Débordements 360px → bloc CSS mobile `#config-modal` (sommaire haut 46vh, grilles 1fr, add-rows wrap + largeurs inline neutralisées, items wrap, cibles 44px). `data-i18n-attr` multi-paires (`;`). Vérifié : `config-mobile.test.mjs` 11/11 (nouveau, au CI), pytest 1249 passed / 6 skipped, ruff/mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + ai-sidebar 6/6 + sidebar-filters 8/8 + mobile-editor 35/35 + config-ai-keys 7/7. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-072 | Correction | `frontend/js/viewer.js`, `frontend/style.css`, `tests/frontend/image-viewer.test.mjs`, `tests/e2e/image-viewer.spec.js`, `scripts/run-e2e-local.ps1` (nouveau), `package.json`, `AGENTS.md`, `README.md`, `README.fr.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-072** : dans la visionneuse d'images (#108-D), le plein écran (lightbox) et le panneau « Métadonnées » étaient perdus dès qu'on changeait d'image avec ←/→ (ou la pellicule), car `openFile` → `renderFile` recrée entièrement `renderImageViewer`. (1) **Persistance** : état module `_imageViewerState { lightbox, meta }` restauré à chaque rendu ; un drapeau `_imageViewerNavPending` posé par `go()` et le clic de vignette indique à `renderFile` que le rendu suivant est une navigation image→image (pas de réinitialisation) — toute autre ouverture repart à zéro. (2) **Panneau latéral** : `.image-meta-panel` déplacé dans un nouveau `.image-viewer-body` en flex row, à droite de `.image-stage` (`border-left`, `width:280px; max-width:40%`, défilement vertical) au lieu d'une bande sous la pellicule ; la règle lightbox ne masque plus que la pellicule. Boutons stables `image-btn-lightbox`/`image-btn-metadata` + `aria-pressed`, `Escape` resynchronise l'état. Tests statiques `image-viewer.test.mjs` (+2) et E2E Playwright (+1). **Diagnostic E2E** : `npm run test:e2e` bloquait car `bash` résout vers WSL (HS, Ubuntu `Stopped`, `HCS_E_CONNECTION_TIMEOUT`) et git-bash est bloqué par App Control → lanceur PowerShell ajouté. Vérifié : `image-viewer.spec.js` 4/4, **suite `chromium-desktop` complète 103 passed / 6 skipped (10,3 min)** via `scripts/run-e2e-local.ps1`, `image-viewer.test.mjs` 12/12, unit 10/10, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-073 | Correction | `frontend/style.css`, `tests/frontend/mobile-toolbar.test.mjs` (nouveau), `tests/e2e/mobile-toolbar.spec.js` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-073** : en mobile (≤768px), la barre fixe `#mobile-toolbar` (64px + safe-area) recouvrait le bas de tous les documents/pages — fin de contenu inaccessible. (1) **Cause** : la règle de clearance du bloc `@media (max-width: 768px)` ciblait `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, zéro dégagement ; règle sœur morte `.editor-modal.active ~ .main-layout { padding-bottom: 0 }` supprimée (overlay plein écran / nécessaire en édition inline). (2) **Correctif** : `.main-body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)) }`, reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture), `body.np-active .content-area` ramené de `calc(64px+safe+76px)` à `76px` (dégagement dock seul — géométrie totale identique, pas de double comptage avec `.main-body`). Tests : `mobile-toolbar.test.mjs` (7 statiques, ajouté au CI), E2E `mobile-toolbar.spec.js` (géométrie + scroll fin de `ANALYSE_REVIEW.md`, skip hors viewport ≤768). Vérifié : `mobile-toolbar` 7/7, JSDOM 14 suites 0 échec, **E2E `chromium-mobile` BUG-073 2/2 + régressions mobile-editor/config-mobile 6/6**, **suite `chromium-desktop` complète 106 passed / 9 skipped (11,4 min)**, pytest 1302 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-24 | #114 | Feature | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`, `tests/e2e/config-mobile.spec.js`, `docs/features/settings-mobile-114.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **#114 — Configuration, refonte mobile-responsive (≤768px)**. (1) **Drawer sommaire** : `#config-nav` en panneau `position: fixed` (`min(320px, 88vw)`, z-index 40) sous backdrop `#config-modal.config-toc-open::before` (z-index 35) ; bouton `#config-toc-close` ; backdrop/Échap ferment le drawer d'abord puis la modale ; `_setConfigNav` bascule la classe + conserve le `display` inline de reset. (2) **Modale plein écran** : `100dvh` + `padding: 0`, `border-radius: 0`. (3) **Tactile** : boutons/liens ≥44px, inputs/selects `16px` + `min-height: 44px` (anti-zoom iOS, scopé `#config-modal`), rangée `.config-actions-row` sticky column + safe-area, formulaires 1 colonne, MFA 1 colonne + code full-width, wrap webhook/token/share/diag/avatar/webauthn. (4) **Dettes HTML/i18n** : `.config-actions-row` replacée dans `#cfg-backend-settings` (`</section>` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `<h2>`, `#plugins-settings-container` supprimé, doublons `.config-btn-add` + règle morte `.mfa-recovery-input` purgés, `#mt-explorer` → `data-i18n="settings.explorer"` ; i18n : clés mortes `settings.{backend,backend_hint,restart_badge,save,plugins}` supprimées, `settings.explorer` + `config.toc_close` ajoutées, `settings.tabs` FR = « Onglets ». Vérifié : `config-mobile.test.mjs` 27/27 (au CI), unit 11/11, validate-imports 40 modules, pytest 1302 passed / 6 skipped, ruff/mypy 0, E2E `chromium-mobile` 5/5. | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-09-24 | BUG-074 → BUG-077 | Correction | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot assistant IA (mode agent)** : (BUG-075) confirmation par lot — `pending.actions` regroupe toutes les mutations d'un tour LLM, un unique bouton « Tout approuver (N) » envoie `confirm_all` (`ToolContext.confirmed`) et n'interrompt plus à chaque action ; les lectures du lot s'exécutent aussitôt. (BUG-074) résumé du bloc d'étapes avec titre de la 1re action + compteur limité aux actions, reprise diffusée dans le même message (fini le « 1 étape » fragmenté). (BUG-076) refresh de l'arborescence débouncé sur les events `tool` mutateurs + `_notifyFileWritten` étendu aux documents (xlsx/docx/csv/pdf). (BUG-077) le bouton d'envoi devient « Stop » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »). Guide/i18n FR/EN + `ai.stop`/`ai.stopped`/`ai.confirm_actions`/`ai.action_apply_all` ; `SW_VERSION` v25. Vérifié : pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11, ai 100/100, editor-inline 44/44, mobile-editor 35/35, ai-sidebar 6/6, forge 32/32, pane-manager 9/9, sw 8/8. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-24 | BUG-079 | Correction | `backend/main.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-079** : `GET /api/diagnostics` renvoyait 500 « dictionary changed size during iteration ». Le handler itérait `inv.word_index.values()` et `index.items()` en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur. Correctif : **snapshot avant itération** (`list(index.items())`, `inv.word_index.copy()`) — copie C atomique sous le GIL, pas de verrou ajouté. Test de non-régression déterministe (`RaceDict` fait grossir le dict pendant l'itération ; échoue sans le correctif, passe avec). Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 (80 fichiers), validate-imports 40 modules, unit 12/12. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-080, BUG-081 | Correction + enregistrement | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-080** : run E2E local pendu toute la nuit → harnais anti-blocage : `npx --yes` (plus de prompt interactif), install Chromium sautée si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124), `globalTimeout` Playwright (15 min local / 30 min CI, `E2E_GLOBAL_TIMEOUT_MS`), pidfile resynchronisé sur le vrai owner du port + `stop` qui tue l'arbre complet (orphelins 81180/81936 nettoyés, port 2029 libéré). Diagnostic : double processus systématique (parent `.venv` parqué + enfant qui sert — environnemental, aussi sur flowdeck/3.13). **BUG-081** (ouvert, non traité) : `GET /api/auth/mfa/status` → 500 auth désactivée (`user` None, `router.py:827`, reproduit live). Vérifié : `test_e2e_harness.py` 8/8, cycle start/stop live (pidfile cohérent, port libéré). | 🟢 corrigé (en attente vérif utilisateur) ; BUG-081 🔴 ouvert |
|
||||
| 2026-09-27 | BUG-082 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-082** : `lint` rouge (`ERR_MODULE_NOT_FOUND: jsdom`, rouge depuis `7bee4a2`) — les fichiers de l'étape frontend racine à import statique `jsdom` (`upload.test.mjs`, puis `config-ai-keys.test.mjs` révélé par le CI après le 1er fix), alors que `jsdom` n'est installé que dans `tests/frontend/node_modules` (étape JSDOM). Les deux déplacés dans l'étape JSDOM (les deux branches) ; garde-fou `test_ci_workflow.py` généralisé (aucun fichier racine à import statique jsdom + suites verrouillées en JSDOM, contre-preuve OK). Vérifié : étape racine verte (11 suites) + `upload` et `config-ai-keys` verts depuis `tests/frontend/`. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-083 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-083** : job `security` rouge — le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée (`unexpected EOF while looking for matching '"'"`, `/var/run/act/workflow/4` ligne 2). Seul `run:` du workflow avec un `#` (les `#` des noms d'étapes Bandit/Npm audit sont inoffensifs, ces étapes passent). Correctif : echo sans `#` (réf `#87` en commentaire YAML). Garde-fou `test_ci_workflow.py` (aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM — BUG-082) + contre-preuve sur l'ancien `ci.yml`. Vérifié : 56 passed. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-081 | Correction | `backend/auth/router.py`, `tests/test_mfa.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-081** : `GET /api/auth/mfa/status` répondait 500 quand l'auth est désactivée — le pseudo-user `anonymous` n'a aucune entrée en store (`get_user` → `None`, `AttributeError` sur `user.get`). Garde `user is None` → payload « MFA désactivé ». Test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-094 | Correction | `backend/xlsx_reader.py`, `frontend/js/viewer.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-094 — une feuille vide ou nouvellement ajoutée devient éditable et manipulable.** `render_sheets()` substitue une grille vierge 20×8 (`EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) quand la feuille ne porte aucune cellule, avec de vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`). Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-095 | Correction CI / sécurité | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Le job `security` repasse au vert** : `pyjwt` 2.13.0 est vulnérable (CVE-2026-102274, correctif 2.14.0) et le plancher de BUG-091 (`>=2.13.0`) était donc sous le dernier correctif. Plancher porté à `pyjwt[crypto]>=2.14.0` (au-dessus de la toolcache du runner, sinon `pip` répond « already satisfied »), garde-fou `TestDependencySecurityFloors` mis à jour (contre-preuve : plancher remis à 2.13.0 → test rouge), commentaire de la job `security` aligné. Vérifié : `test_ci_workflow.py` 9 passed, pip-audit local OK (pyjwt 2.15.1). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-096 → BUG-099 (#156 A1-A4) | Correction | `frontend/js/viewer.js`, `frontend/locales/{fr,en}.json`, `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/test_xlsx_viewer.py`, `tests/test_xlsx_formats.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `docs/ISSUES_TODOLIST.md` | **Les 4 défauts P0 du tableur sont corrigés.** (BUG-096) l'enregistrement d'un `.csv` depuis la visionneuse ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV) : le `TypeError` partait **avant** tout appel réseau, aucun `PUT …/csv/save` n'était émis. (BUG-097) `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` — le chargement des lignes au-delà du plafond fonctionne pour les classeurs macro. (BUG-098) `sniff_csv_delimiter()` détecte `;`/`,`/tabulation et `save_csv_cells()` réutilise le délimiteur : un CSV français s'affiche en colonnes distinctes et **le reste** après édition. (BUG-099) sonde de perte **par feuille** (4 Mo) + plafond global (32 Mo) et clé `cached_values_unverified` quand le budget est épuisé — plus de perte silencieuse possible des valeurs calculées. Tests : `TestXlsxCachedValueProbe` (3, contre-preuve : budget épuisé signalé au lieu de `[]`), `TestCsvDelimiter` + 3 tests CSV, fenêtre `.xlsm` (+ refus `.csv`), 1 test JSDOM CSV. Vérifié : suite **1490 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), `xlsx-viewer.test.mjs` 61/61, validate-imports 40 modules, unit 12/12, i18n parity. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | #156 A5-A7 (P1) | Fonctionnalité (sans nouveau défaut) | `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | **P1 livré** — (A5) presse-papiers de plage : copier/couper/coller un bloc TSV au clavier et au menu contextuel, presse-papiers interne + miroir système, remplissage multi-cellules, insertion **texte** échappée, débordement signalé, annulable ; (A6) clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) ; (A7) zone Nom éditable (« Atteindre ») + liste de fonctions. Vérifié : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux), E2E Playwright. | ✅ livré |
|
||||
| 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré |
|
||||
| 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) |
|
||||
|
||||
---
|
||||
|
||||
@@ -265,7 +319,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| # | Titre | Date résolution | Résolu par | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| *(aucun pour l'instant)* | | | | | |
|
||||
| *BUG-083* | Job CI `security` rouge : le runner Gitea Act tronque le script `pip-audit` au premier `#` (citation de l'echo non fermée → `unexpected EOF while looking for matching '"'`) | 2026-09-27 | Utilisateur | `run:` assaini (echo sans `#`, réf `#87` en commentaire YAML) ; `tests/test_ci_workflow.py` (2 tests : aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM) ; vérifié : 56 passed (ci_workflow + e2e_harness + version), contre-preuve OK sur l'ancien `ci.yml` | Seul `run:` du workflow contenant un `#` (`see #87` dans l'echo). Les `#` des noms d'étapes (Bandit, Npm audit) sont inoffensifs (ces étapes passent). Correctif : echo sans `#`, réf `#87` en commentaire YAML |
|
||||
| *BUG-082* | CI `lint` rouge : suites frontend à import statique `jsdom` exécutées dans l'étape racine où `jsdom` n'est jamais installé | 2026-09-27 | Utilisateur | `upload.test.mjs` + `config-ai-keys.test.mjs` déplacés dans l'étape JSDOM (les deux branches) ; garde-fou `test_ci_workflow.py` (aucun fichier racine à import statique jsdom + suites verrouillées en JSDOM) ; vérifié : étape racine verte + `upload` et `config-ai-keys` verts depuis `tests/frontend/` | `jsdom` ne vit que dans `tests/frontend/node_modules` (installé par l'étape JSDOM). Correctif : déplacer les suites concernées dans l'étape JSDOM |
|
||||
| *BUG-080* | [🔴 BLOQUANT] E2E locaux bloqués toute la nuit : `npm run test:e2e:ps` ne termine jamais (serveurs orphelins sur le port 2029, `npx playwright install` sans `--yes` ni garde-fou, suite ~130 tests sans timeout global) | 2026-09-27 | Utilisateur | `run-e2e-local` : `npx --yes`, skip install Chromium si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124) ; `playwright.config.ts` : `globalTimeout` 15 min local / 30 min CI (`E2E_GLOBAL_TIMEOUT_MS`) ; `e2e-server.ps1` : pidfile = vrai owner du port, `stop` tue l'arbre complet. Tests : `tests/test_e2e_harness.py` (8/8), cycle start/stop live (pidfile cohérent, port libéré) | Constat 2026-09-27 : `e2e-server.ps1 start` OK (READY 12 s) mais run suivant pendu toute la nuit ; 2 python orphelins (PID 81180 parent + 81936 sur le port, pidfile périmé). Double processus systématique (parent `.venv` parqué + enfant qui sert — aussi sur flowdeck/3.13 : environnemental, sans impact après correctif). Trouvé au passage : BUG-081 (`/api/auth/mfa/status` → 500 auth désactivée) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,182 +1,10 @@
|
||||
# ObsiGate — Guide MCP (Model Context Protocol)
|
||||
# Guide MCP — déplacé
|
||||
|
||||
> **Statut :** livré (#79 phase E + F) · **Dernière mise à jour :** 2026-09-11
|
||||
> **Voir aussi :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) ·
|
||||
> [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) · [ROADMAP.md](./ROADMAP.md)
|
||||
> Ce guide a été déplacé dans le répertoire des guides utilisateur :
|
||||
> **[docs/GUIDES/MCP.md](./GUIDES/MCP.md)**.
|
||||
|
||||
ObsiGate expose ses vaults à des **clients MCP externes** (Claude Desktop, Cursor,
|
||||
tout client compatible MCP) via un serveur **Streamable HTTP** monté sur `/mcp`.
|
||||
Les outils sont les **mêmes** que ceux de l'assistant in-app : la couche
|
||||
`backend/tools/` est la source unique de vérité.
|
||||
Le serveur MCP d'ObsiGate (`/mcp`) expose les mêmes outils que l'assistant IA à
|
||||
Claude Desktop, Cursor, Cline et tout client compatible MCP. Configuration,
|
||||
outils, resources/prompts, sécurité et dépannage s'y trouvent désormais.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prérequis
|
||||
|
||||
1. Une instance ObsiGate accessible (locale ou distante).
|
||||
2. Un **jeton JWT** valide (`Authorization: Bearer <token>`), obtenu via
|
||||
`POST /api/auth/login` (ou une clé API). Le jeton porte les permissions par
|
||||
vault de l'utilisateur — l'autorisation MCP réutilise `get_current_user`.
|
||||
3. Si l'authentification est désactivée (`OBSIGATE_AUTH_ENABLED=false`), le
|
||||
serveur MCP accepte un utilisateur anonyme disposant de tous les vaults.
|
||||
|
||||
> Le transport `stdio` n'est **pas** encore supporté ; utilisez le transport
|
||||
> HTTP (un pont local type `mcp-remote` si votre client ne gère pas nativement
|
||||
> le Streamable HTTP distant).
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoint & protocole
|
||||
|
||||
| Élément | Valeur |
|
||||
|---|---|
|
||||
| URL | `https://<obsigate>/mcp` |
|
||||
| Transport | Streamable HTTP (`POST` JSON-RPC 2.0, `Accept: application/json, text/event-stream`) |
|
||||
| Auth | `Authorization: Bearer <JWT>` |
|
||||
| Protocole MCP | `2025-03-26` (négocié à l'`initialize`) |
|
||||
| Réponses | JSON (`json_response=True`) |
|
||||
|
||||
Handshake minimal :
|
||||
|
||||
```bash
|
||||
curl -sS https://obsigate.example/mcp \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-03-26","capabilities":{},
|
||||
"clientInfo":{"name":"curl","version":"1.0"}}}'
|
||||
```
|
||||
|
||||
La réponse contient l'en-tête `Mcp-Session-Id` à réutiliser pour les appels
|
||||
suivants (`tools/list`, `tools/call`, `resources/read`, …).
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration des clients
|
||||
|
||||
### Claude Desktop (via pont `mcp-remote`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsigate": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y", "mcp-remote",
|
||||
"https://obsigate.example/mcp",
|
||||
"--header", "Authorization: Bearer ${OBSIGATE_TOKEN}"
|
||||
],
|
||||
"env": { "OBSIGATE_TOKEN": "eyJ..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
`.cursor/mcp.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsigate": {
|
||||
"url": "https://obsigate.example/mcp",
|
||||
"headers": { "Authorization": "Bearer eyJ..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Primitives exposées
|
||||
|
||||
### 4.1 Tools
|
||||
|
||||
Les outils de **lecture/recherche** sont exposés directement. Les outils
|
||||
**d'écriture/destructifs** sont exposés via une paire **two-step** :
|
||||
`propose_<tool>` (aperçu + jeton de confirmation, aucune modification) puis
|
||||
`apply_<tool>` (consomme le jeton et exécute).
|
||||
|
||||
| Catégorie | Outils |
|
||||
|---|---|
|
||||
| Vaults / navigation | `list_vaults`, `list_directory`, `list_all_files` |
|
||||
| Lecture | `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` |
|
||||
| Recherche | `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` |
|
||||
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
|
||||
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
|
||||
|
||||
Flux d'une mutation :
|
||||
|
||||
```text
|
||||
1. tools/call { name: "propose_edit_file",
|
||||
arguments: { vault, path, content } }
|
||||
→ { tool, arguments, diff, confirmation_token, expires_in }
|
||||
|
||||
2. (l'utilisateur / l'agent valide)
|
||||
|
||||
3. tools/call { name: "apply_edit_file",
|
||||
arguments: { confirmation_token } }
|
||||
→ { ok: true, data: { ... } }
|
||||
```
|
||||
|
||||
Le jeton est **signé (JWT), à usage unique et à durée de vie limitée**
|
||||
(`OBSIGATE_MCP_CONFIRMATION_TTL`, défaut 300 s). Un rejeu renvoie
|
||||
`token_reused`.
|
||||
|
||||
### 4.2 Resources
|
||||
|
||||
| URI | Contenu |
|
||||
|---|---|
|
||||
| `vault://<name>` | Vault accessible (métadonnées, nombre de fichiers) |
|
||||
| `vault://<name>/<path>` | Contenu d'un fichier (lecture seule, **secrets redactés**) |
|
||||
|
||||
### 4.3 Prompts
|
||||
|
||||
`summarize-directory`, `generate-note`, `find-related`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sécurité
|
||||
|
||||
- **Permissions par vault** : `check_vault_access` est appliqué à chaque outil
|
||||
et chaque resource ; un utilisateur ne voit que ses vaults.
|
||||
- **Anti path-traversal** : `resolve_safe_path` rejette tout chemin hors du vault.
|
||||
- **Confirmation two-step** pour toute mutation (jeton signé, usage unique).
|
||||
- **Toggle par vault** `aiDestructiveTools` (défaut : activé) : le désactiver
|
||||
bloque rename/move/replace/delete tout en laissant create/edit/append.
|
||||
- **Backup automatique** avant chaque opération destructive.
|
||||
- **Rate limiting** : par jeton et par outil
|
||||
(`OBSIGATE_TOOL_RATE_LIMIT`, `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL`,
|
||||
`OBSIGATE_TOOL_RATE_WINDOW`). Une limite dépassée renvoie le code
|
||||
`rate_limited`.
|
||||
- **Redaction des secrets** : les résultats d'outils (lectures, diffs,
|
||||
extraits de recherche) sont nettoyés avant tout retour au client.
|
||||
- **Audit** : chaque appel est journalisé (`data/audit.log`, action
|
||||
`ai_tool_call`) avec arguments sensibles résumés.
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `OBSIGATE_MCP_CONFIRMATION_TTL` | `300` | Durée de vie (s) des jetons de confirmation |
|
||||
| `OBSIGATE_TOOL_RATE_LIMIT` | `60` | Appels d'outils max par identité et par fenêtre |
|
||||
| `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL` | = global | Appels max par outil et par fenêtre |
|
||||
| `OBSIGATE_TOOL_RATE_WINDOW` | `60` | Longueur de la fenêtre (s) |
|
||||
| `BOOKSLM_MAX_TOOL_CALLS` | `25` | Quota d'appels d'outils par run d'agent |
|
||||
| `BOOKSLM_MAX_TOOL_READ_BYTES` | `200000` | Taille max renvoyée par `read_file` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
| Symptôme | Cause probable / remède |
|
||||
|---|---|
|
||||
| `401 Authentification requise` | En-tête `Authorization: Bearer` absent ou jeton expiré |
|
||||
| `vault_access_denied` | Le jeton n'a pas accès à ce vault (`vaults` / `_token_vaults`) |
|
||||
| `destructive_tools_disabled` | `aiDestructiveTools=false` pour ce vault |
|
||||
| `confirmation_required` | Appeler d'abord `propose_<tool>` puis `apply_<tool>` |
|
||||
| `token_reused` / `invalid_confirmation` | Jeton déjà consommé ou expiré → refaire un `propose_` |
|
||||
| `rate_limited` | Quota dépassé ; respecter `retry_after` |
|
||||
| Le client ne se connecte pas | Vérifier le transport Streamable HTTP / le pont `mcp-remote` |
|
||||
Sommaire des guides : [docs/GUIDES/README.md](./GUIDES/README.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.16.3 | **Dernière mise à jour :** 2026-09-22
|
||||
> **Version :** 2.45.2 | **Dernière mise à jour :** 2026-10-01
|
||||
> **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)**
|
||||
@@ -37,16 +37,161 @@
|
||||
- **Reste à faire :**
|
||||
- [x] **Signature de l'updater Tauri** (gratuit) : paire de clés générée, `pubkey` renseignée, `createUpdaterArtifacts` activé, secrets CI câblés
|
||||
- [x] **Manifeste `latest.json`** généré par `scripts/updater_manifest.py` (intégré à `publish_release.py`), endpoint updater pointé sur `main`
|
||||
- [ ] **Signature de code Windows** : non retenue (pas de certificat) — alternatives : livrer non signé, SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
|
||||
- [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
|
||||
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2)
|
||||
|
||||
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
|
||||
|
||||
- **Effort :** 8-13 jours (P0 ✅ 2-3 j · P1 : 4-6 j · P2 : 2-4 j) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-09-28** — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis v2.33.0 → v2.39.0 : A6, A7, A13, A14, A15, A16, A17 (+ A11 déjà au CI) — **backlog #153 terminé**
|
||||
- **Analyse, risques et critères d'acceptation :** [features/xlsx-viewer.md](./features/xlsx-viewer.md)
|
||||
- **Description :** #152 (visionneuse XLSX, 2.27.0) lit et édite correctement la **grille de
|
||||
valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure,
|
||||
ni styles en écriture, ni formule recalculée), **écriture destructive** d'une partie du classeur,
|
||||
tableurs **invisibles à la recherche** et **inutilisables par l'IA** au-delà de la création. Ce
|
||||
lot suit ces ajouts ; les cases ci-dessous sont le **suivi de référence**, la fiche feature porte
|
||||
le détail.
|
||||
- **Constat (points de départ) :** `MAX_ROWS = 500` / `MAX_COLS = 40` sans indicateur (troncature
|
||||
silencieuse) · `wb.save()` non atomique et sans verrou (concurrence) · saisie `=…` stockée comme
|
||||
formule par openpyxl (injection DDE) · `content=""` à l'indexation (recherche TF-IDF et sémantique
|
||||
aveugles) · aucun outil IA de lecture/édition d'un classeur existant · aucun test frontend ni
|
||||
E2E sur le viewer.
|
||||
- **Périmètre réel des pertes au round-trip (mesuré sur openpyxl 3.1.5, 2026-09-27) :** graphiques,
|
||||
images, dessins **et** tableaux croisés sont préservés ; sont perdus les **valeurs calculées en
|
||||
cache**, slicers/chronologies, contrôles de formulaire, connexions/requêtes, custom XML,
|
||||
signature numérique, commentaires enrichis et macros.
|
||||
- **Sous-tâches :**
|
||||
- **P0 — garde-fous d'écriture (🔴, 2-3 j) — 🟢 livré**
|
||||
- [x] **A1** Alerte de fidélité avant écriture : `inspect_workbook()` → `xlsx_lossy_features` + bandeau FR/EN + **409** `xlsx_lossy_content` sans `force` (confirmation explicite puis reprise) — BUG-085
|
||||
- [x] **A2** Écriture atomique (`wb.save(.tmp)` + `os.replace()`, backup inchangé) — BUG-086
|
||||
- [x] **A3** Verrou par fichier autour du read-modify-write (timeout 15 s + **409** `conflict`) — BUG-087
|
||||
- [x] **A4** Neutralisation de l'injection de formule (`=`/`@` stockés en texte, opt-in `allow_formula` + bouton `f(x)`) — BUG-088
|
||||
- **P1 — recherche, IA, UX (🟡, 4-6 j) — 🟢 livré**
|
||||
- [x] **A5** Indexation du contenu des feuilles (noms de feuilles + 20 premières lignes, plafond 5 k caractères) — les mots tapés dans une cellule rendent le fichier trouvable ; au passage **BUG-089** (reindex manuel ne reconstruisait pas l'index inversé)
|
||||
- [x] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets` (v2.33.0)
|
||||
- [x] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches) (v2.34.0)
|
||||
- [x] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) — BUG-090
|
||||
- [x] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
|
||||
- [x] **A10** Types & formats de saisie (nombre/texte, booléens `TRUE`/`FAUX`, dates FR `JJ/MM/AAAA` jour-first)
|
||||
- [x] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI (JSDOM dans le job lint depuis v2.31.0 ; spec E2E livrée avec A9bis)
|
||||
- [x] **A12** Valeur calculée affichée sous la formule (2ᵉ lecture `data_only=True` seulement si l'archive contient un `<v>`, info-bulle FR/EN)
|
||||
- **P2 — étendu (🟢, 2-4 j) — 🟢 livré**
|
||||
- [x] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection (v2.35.0)
|
||||
- [x] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer) (v2.36.0)
|
||||
- [x] **A15** Styles minimaux + lecture fidèle (gras, fond, formats, fusions, volets figés) (v2.37.0)
|
||||
- [x] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`/`.ods` lecture seule via xlrd/odfpy, `.csv` éditable) (v2.38.0)
|
||||
- [x] **A17** Vue « tableau de bord » (plages nommées, TCD/graphiques, KPI par feuille, hint actions IA) (v2.39.0)
|
||||
- **Convention de suivi :** chaque sous-tâche démarre par son ID stable (`#153-A<n>` dans cette
|
||||
Roadmap) ; celles qui sont des **défauts** sont aussi ouvertes comme `BUG-NNN` dans
|
||||
[ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) (A1→BUG-085, A2→BUG-086, A3→BUG-087, A4→BUG-088 ;
|
||||
A8 le sera à son tour).
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Refonte UI/UX tableur (P2)
|
||||
|
||||
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-09-29 (Lots 1 → 5, A1-A5)** — ruban de commandes groupé, onglets
|
||||
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non recalculées,
|
||||
tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`, bandeau de conflit
|
||||
409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** (tableau de bord + entrée
|
||||
Assistant IA, redimensionnable) ; **undo/redo**, chargement via `IntersectionObserver`,
|
||||
ARIA `role="grid"` ; extraction des unités sans état dans `frontend/js/xlsx/*`.
|
||||
- **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md)
|
||||
- **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale :
|
||||
commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés
|
||||
(tableau de bord, troncature, lecture seule, formules non recalculées, conflits) mal intégrés.
|
||||
La refonte s'appuie sur les standards Excel/Google Sheets/Airtable **sans renier** la contrainte
|
||||
`vanilla JS`, zéro framework, zéro build npm.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Coquille : barre de commandes groupée, onglets feuilles permanents + « + »,
|
||||
badges d'état, tokens de grille et affordances visuelles (Lot 1)
|
||||
- [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
|
||||
- [x] **A3** Inspecteur droit repliable : Tableau de bord + entrée Assistant IA (Lot 3)
|
||||
- [x] **A4** Undo/redo, chargement via `IntersectionObserver`, sémantique ARIA (Lot 4)
|
||||
- [x] **A5** Découpage `frontend/js/xlsx/*`, lien dashboard → grille, inspecteur redimensionnable (Lot 5)
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Ergonomie tableur
|
||||
|
||||
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
|
||||
|
||||
- **Effort :** 2-4 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré** — 2026-09-29 (menu contextuel clic droit + appui long, sélection type Excel)
|
||||
- **Analyse, conception et critères :** [features/xlsx-context-menu.md](./features/xlsx-context-menu.md)
|
||||
- **Description :** retours utilisateur après #154 — ajouter un **menu contextuel** (clic droit +
|
||||
appui long tactile) sur les cellules et en-têtes, et aligner la **sélection/curseur** sur le
|
||||
comportement d'Excel (plage par glisser, `Maj`, sélection de ligne/colonne par en-tête).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Menu contextuel thémé (clic droit + appui long) : insérer/supprimer ligne & colonne,
|
||||
trier, effacer le contenu
|
||||
- [x] **A2** Sélection type Excel : plage (glisser / `Maj+clic` / `Maj+flèches`), en-têtes
|
||||
ligne/colonne, curseur croix, zone Nom affichant la plage
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Tableur Excel (complétude)
|
||||
|
||||
### 156. Éditeur Excel — complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
- **Effort :** 15-22 jours (P0 2-3 j ✅ · P1 4-6 j ✅ · P2 5-7 j ✅ · P3 4-6 j ✅) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-09-30** — **P0** (4 défauts **BUG-096 → BUG-099** corrigés),
|
||||
**P1** (**A5-A7** : presse-papiers de plage, clavier complet, zone Nom éditable), **P2**
|
||||
(**A8** mise en forme en écriture, **A9** décision « pas de moteur de formule, annoncée dans
|
||||
l'UI », **A10** undo/redo unifié conservé au re-rendu) et **P3** (**A11** export sélection/
|
||||
Markdown/HTML/impression + recherche multi-feuilles, **A12** concurrence optimiste `If-Match`,
|
||||
**A13** cache des métadonnées, **A14** outils IA `.xlsm`/`.csv` + recherche/analyse/structure)
|
||||
- **Analyse, défauts, risques et critères d'acceptation :** [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md)
|
||||
- **Description :** #153 a rendu l'éditeur **correct** sur la grille de valeurs, #154/#155 l'ont
|
||||
rendu **convivial** (ruban, inspecteur, undo/redo des cellules, sélection et menu contextuel
|
||||
type Excel). Il reste l'écart avec un vrai éditeur tableur : coller une **plage**, écrire la
|
||||
**mise en forme**, **calculer**, **sortir** le résultat, et détecter un écrivain concurrent.
|
||||
La fiche porte l'audit ; les cases ci-dessous sont le **suivi de référence**.
|
||||
- **Défauts corrigés le 2026-09-29 (cf. [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md)) :**
|
||||
- ✅ BUG-096 — enregistrer un `.csv` depuis la visionneuse levait un `TypeError`
|
||||
(`sheet: sheets[…].name` alors qu'un CSV n'a pas de `xlsx_sheets`) : aucun `PUT …/csv/save`
|
||||
n'était émis (reproduit en JSDOM, corrigé + test) ;
|
||||
- ✅ BUG-097 — feuille `.xlsm` tronquée : « Charger la suite » échouait en **415** —
|
||||
`GET …/xlsx/sheet` accepte désormais `.xlsx` **et** `.xlsm` ;
|
||||
- ✅ BUG-098 — délimiteur CSV détecté (`;`/`,`/tabulation) et réutilisé à l'écriture :
|
||||
un CSV français s'affiche en colonnes et le reste après édition ;
|
||||
- ✅ BUG-099 — sonde de perte **par feuille** + signal `cached_values_unverified` quand le
|
||||
budget est épuisé : plus de perte silencieuse possible des valeurs calculées.
|
||||
- **Sous-tâches :**
|
||||
- **P0 — défauts (🔴, 2-3 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A1** Sauvegarde `.csv` depuis la visionneuse — BUG-096 (test JSDOM : `PUT …/csv/save` émis)
|
||||
- [x] **A2** Chargement paresseux des `.xlsm` — BUG-097 (`GET …/xlsx/sheet` accepte `.xlsx`/`.xlsm`)
|
||||
- [x] **A3** Délimiteur CSV détecté et réutilisé à l'écriture — BUG-098 (`sniff_csv_delimiter`)
|
||||
- [x] **A4** Sonde de perte par feuille + signal `cached_values_unverified` — BUG-099
|
||||
- **P1 — presse-papiers & clavier (🟡, 4-6 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A5** Presse-papiers de plage — copier/couper/coller un bloc TSV (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`, menu contextuel), presse-papiers interne + système, remplissage multi-cellules (tests JSDOM : `A1:B2` → `D5:E6`)
|
||||
- [x] **A6** Clavier complet — `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` (saut de ligne en cellule)
|
||||
- [x] **A7** Zone Nom éditable (« Atteindre » : `B12`, `A1:B3`, `Feuille!A1`) + aide à la saisie des fonctions
|
||||
- **P2 — mise en forme, calcul, undo (🟡, 5-7 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A8** Mise en forme en écriture — bouton **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur) via `PUT …/xlsx/style` (verrou, backup, garde de perte, `If-Match`)
|
||||
- [x] **A9** Calcul — **décision documentée** : pas de moteur de formule, l'enregistrement annonce « enregistrée comme texte » (la garde anti-DDE reste)
|
||||
- [x] **A10** Undo/redo unifié (cellules, effacement, tri/filtre, structure) et conservé au re-rendu (`_xlsxHistory` par fichier)
|
||||
- **P3 — sortie, robustesse, performances (🟢, 4-6 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A11** Sortie & recherche : export de la sélection / Markdown / HTML / impression, recherche sur toutes les feuilles (compteur `n/m · k feuilles`)
|
||||
- [x] **A12** Concurrence optimiste inter-processus (`ETag`/`If-Match`, **409** réparable, bouton « Réessayer » qui relit)
|
||||
- [x] **A13** Performances : cache des métadonnées par `(chemin, mtime, taille)` (LRU 8), invalidé à chaque écriture (axe des colonnes > `MAX_COLS` : hors périmètre)
|
||||
- [x] **A14** Outils IA étendus — `.xlsm`/`.csv` acceptés, `search_workbook`, `analyze_range`, `edit_xlsx_structure`
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
|
||||
- **Effort :** 6-8 jours | **Impact :** 🟢
|
||||
- **Décision 2026-09-26 : reporté (P4)** — axe prioritaire = dette & sécurité (#85/#87) ; #73 hors chemin critique. Si réactivé : partir d'un MVP export/hash/LWW adossé à #59 (PWA offline) + #62 (collab Yjs/CRDT) plutôt qu'un protocole parallèle.
|
||||
- **Description :** Synchronisation des vaults entre plusieurs instances d'ObsiGate via un protocole de synchronisation décentralisé ou compatible Obsidian Sync. Alternative self-hosted à Obsidian Sync.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Protocole : évaluation CRDT vs OT vs diff/patch pour fichiers markdown
|
||||
@@ -60,60 +205,22 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 2 (P2)
|
||||
|
||||
### 83. Barre d'outils d'édition mobile — style Obsidian Android
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** frontend (mobile)
|
||||
- **Statut :** ✅ livré — ruban horizontal défilable ancré au-dessus du clavier, commandes étendues et personnalisation persistée. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #83).
|
||||
- **Description :** remplacer la barre de mise en forme Markdown actuelle par un **ruban horizontal
|
||||
défilable** ancré juste au-dessus du clavier virtuel, reprenant l'ergonomie de l'app Android
|
||||
Obsidian : fond anthracite aux coins arrondis, insertion/enrobage de la syntaxe au curseur ou sur
|
||||
la sélection, et personnalisation des commandes via une icône clé à molette.
|
||||
- **Sous-tâches :**
|
||||
- [x] Ruban horizontal défilable (glissement tactile gauche/droite) ancré au-dessus du clavier
|
||||
- [x] Actions rapides : annuler, refaire, `[[ ]]` (lien interne), modèle/fichiers, tag `#`, pièce jointe
|
||||
- [x] Formatage : H1–H6, gras, italique, barré (`~~`), surligné (`==`), code en ligne/bloc, citation (`>`)
|
||||
- [x] Liens externes, listes à puces/numérotées, case à cocher (`- [ ]`), indenter / désindenter
|
||||
- [x] Personnalisation (clé à molette) : ajouter / supprimer / réordonner les commandes
|
||||
- [x] i18n FR/EN + tests frontend (helpers purs) + E2E mobile
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
|
||||
|
||||
### 84. Consolidation & sécurité — revue statique 2026-09-13 (phase 1)
|
||||
|
||||
- **Effort :** 6-9 jours | **Impact :** 🔴 | **Zone :** backend + frontend | **Référence :** [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) BUG-021 → BUG-034
|
||||
- **Statut :** 🟢 livré (phase 1) — sanitizer XSS, rate-limit/lockout MFA, isolation vaults, ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, verrous `users.json`, audits IP, rate-limit par compte, symlinks, recherche via inverted index, token en cookie HttpOnly. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #84).
|
||||
- **Description :** traiter toutes les vulnérabilités critiques et importantes issues de la revue statique : XSS markdown (`escape=False`) et page publique de partage, brute-force MFA, isolation des vaults (`resolve_safe_path`), ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, races `users.json`, audits IP, rate-limit partagé, indexation symlinks.
|
||||
- **Sous-tâches :**
|
||||
- [x] Assainir le rendu markdown (sanitizer serveur en whitelist) et la page de partage (échappement `title`/frontmatter) — *DOMPurify client non ajouté (défense en profondeur serveur suffisante)*
|
||||
- [x] Rate-limit + lockout sur les endpoints MFA (`totp/verify`, `recovery`, `webauthn/verify`)
|
||||
- [x] Corriger `resolve_safe_path` (comparaison de chemin stricte par segment) + test de régression
|
||||
- [x] Rotation du refresh token, révocation de l'access token au logout, persistance des JTI révoqués
|
||||
- [x] Valider la politique de mot de passe à la création ; bloquer le SSRF des webhooks et externaliser les secrets
|
||||
- [x] Verrous sur les mutations `users.json` ; consigner l'adresse IP réelle dans les audits
|
||||
- [x] Ignorer les symlinks de l'index ; caps CPU/timeout regex (ReDoS)
|
||||
- [~] Durcir la CSP — *partiel* : directives `object-src`/`base-uri`/`form-action`/`frame-ancestors` ajoutées et token retiré de `sessionStorage` ; migration **nonce** restante (nécessite la conversion des gestionnaires d'événements inline)
|
||||
|
||||
### 85. Refonte architecturale — découpage du monolithe & persistance d'état (phase 2)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
||||
- **Description :** extraire le monolithe `backend/main.py` (~4 260 lignes) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai
|
||||
- [ ] Centraliser le contrat d'outils IA sur `tools/registry.py` (permissions, quotas, redaction)
|
||||
- [ ] 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)
|
||||
|
||||
### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4)
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
|
||||
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3.
|
||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
||||
- **Statut :** 🔵 en cours depuis 2026-09-26 — par tranches. **T1 livrée (v2.28.1) :** bandit bloquant (`nosec` justifiés B324/B404/B603/B607/B406, B105 exclu comme `pyproject`), `npm audit` bloquant (0 vulnérabilité), 5 suites frontend intégrées au CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`). pip-audit reste consultatif (montées starlette/weasyprint à qualifier).
|
||||
- **T6 livrée (v2.28.15) :** dépendances qualifiées — mistune 3.3.3, python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 (`cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant, 0 vulnérabilité** (seule exception documentée : PYSEC-2026-1325 ecdsa, sans correctif upstream, JWT HS256 uniquement).
|
||||
- **T7 livrée (v2.28.15) :** **semgrep bloquant** sur ruleset 100 % local `semgrep-rules/` (8 règles, 0 finding, contrôle négatif OK) ; trivy écarté (binaire + DB réseau, couche Python couverte).
|
||||
- **T8 livrée (v2.28.15, fin BUG-034) :** cookies `Secure` auto (`true|false|auto`, `X-Forwarded-Proto` sous `TRUST_PROXY`, warning affiné, `TRUST_PROXY=true` en prod) ; `CORSMiddleware` same-origin explicite ; `style-src 'unsafe-inline'` conservé assumé (189 `style=` + 343 `el.style`, T5c ayant verrouillé `script-src`).
|
||||
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Jobs CI sécurité (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
|
||||
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques
|
||||
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown) — **T4 livrée :** `tests/e2e/xss.spec.js` (BUG-021/022, 2/2 vert) + `scripts/e2e-server.ps1` (cycle de vie serveur E2E avec progression `start|stop|status|logs`) + validation locale projet `chromium-desktop` : **108/108 verts** (obsigate 44, split 37, viewers 24, xss/header 3), mobiles ciblés 10/10
|
||||
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`) — **T2 livrée (v2.28.2) :** `tests/test_hardening_concurrency.py` (users.json concurrent + budget temps regex) ; 5 suites au CI (T1)
|
||||
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3)** (helper + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte) ; **T5c livrée (v2.28.13)** (`script-src` sans `unsafe-inline`) ; **T8 livrée (v2.28.15)** (fin BUG-034 : Secure auto + CORS explicite ; `style-src` résiduel assumé ; rotation DeepSeek BUG-006 toujours côté utilisateur)
|
||||
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD — **T6/T9 livrées (v2.28.15)** (`pip-audit` 0, `npm audit` 0, locales FR/EN 2213 clés parité testée `test_i18n_parity.py`, gardes `test_version.py` + `test_ci_workflow.py`)
|
||||
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
|
||||
|
||||
---
|
||||
@@ -126,6 +233,10 @@
|
||||
|
||||
| # | Domaine / fonctionnalité | Version | Détails |
|
||||
|---|---|---|---|
|
||||
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
|
||||
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
|
||||
| 156 | Tableur — Complétude éditeur : presse-papiers/clavier (A5-A7), mise en forme (A8), décision calcul (A9), undo unifié (A10), export + recherche multi-feuilles (A11), concurrence optimiste (A12), cache méta (A13), outils IA `.xlsm`/`.csv` (A14) + BUG-096 → BUG-099 | 2.45.0 | [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md) |
|
||||
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
|
||||
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
|
||||
@@ -182,6 +293,17 @@
|
||||
| 106 | Assistant IA — Actions instantanées contextuelles, catalogue « Toutes les actions » & frontmatter complet | 2.14.0 | [features/ai-quick-actions.md](./features/ai-quick-actions.md) |
|
||||
| 107 | Configuration — Gestion des clés API & MCP : création/révocation de jetons longue durée (1 j, 1 mois, 6 mois, 1 an, sans fin), une seule clé pour l'API REST et le serveur MCP, « dernière utilisation », store `data/api_tokens.json` sans secret persisté | 2.15.0 | [features/api-mcp-tokens-107.md](./features/api-mcp-tokens-107.md) |
|
||||
| 86 | Optimisation globale des performances (phase 3) — scan différentiel, excalidraw différé, garde-fou `replace` (inverted index / PDF lazy / caps regex déjà livrés via BUG-033/040/025) | 2.16.0 | [features/perf-phase3-86.md](./features/perf-phase3-86.md) |
|
||||
| 108 | Support complet des images — arborescence, visionneuse (zoom/pan/navigation/miniatures), indexation nom+métadonnées, `media_types.py`, filtre `ext:`, SVG sandbox | 2.17.0 | [features/image-support.md](./features/image-support.md) |
|
||||
| 109 | Support audio & vidéo — lecteurs HTML5 intégrés, streaming HTTP Range (`/api/media`), fallback codec/taille | 2.18.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
|
||||
| 110 | Lecteur média persistant « Now Playing » — élément partagé téléporté (inline ⇄ dock), Media Session, mini-vidéo PiP, mobile, reprise | 2.19.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
|
||||
| 111 | Visionneuse d'images — navigation fluide : image ajustée au cadre, navigation en place (cache annuaire + préchargement), pellicule persistante, flèches latérales au survol | 2.20.0 | [features/image-navigation-111.md](./features/image-navigation-111.md) |
|
||||
| 112 | En-tête allégé & compte en sidebar — version dans le menu Options, retrait utilisateur/déconnexion du header, section compte en bas de la sidebar, pellicule d'images défilable (molette + flèches) | 2.21.0 | [features/header-user-sidebar-112.md](./features/header-user-sidebar-112.md) |
|
||||
| 113 | Configuration — ordre naturel des sections (Profil 1er, À propos dernier, TOC = page) & avatar utilisateur (import PNG/JPG/WEBP, persistance serveur, cercle sidebar) | 2.22.0 | [features/settings-order-avatar-113.md](./features/settings-order-avatar-113.md) |
|
||||
| 114 | Configuration — refonte mobile-responsive (modale plein écran 100dvh, sommaire en drawer coulissant, cibles tactiles ≥ 44 px, inputs 16 px anti-zoom, sauvegarde sticky, MFA 1 colonne, purge i18n/HTML) | 2.23.0 | [features/settings-mobile-114.md](./features/settings-mobile-114.md) |
|
||||
| BUG-078 | Fichiers de code — coloration syntaxique restaurée (feuilles highlight.js basculées sur le mode de thème et non la clé) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -189,16 +311,28 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ 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, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
|
||||
| **Total restant** | **6 items + finitions** | **~23-36 jours** |
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–86, #88–93, #94–100, #102–115, #117, #92 | ~141 jours réalisés |
|
||||
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
|
||||
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
|
||||
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
|
||||
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
|
||||
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
|
||||
| ✅ Terminé | #155 Ergonomie tableur — menu contextuel & sélection type Excel | 0 jour restant |
|
||||
| ✅ Terminé | #156 Éditeur Excel — complétude — **P0-P3 ✅ livrés le 2026-09-30** (BUG-096 → BUG-099, A5-A14) | 0 jour restant |
|
||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
|
||||
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
|
||||
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
|
||||
- **Ajout 2026-09-29 :** #155 livré — menu contextuel (clic droit / appui long) et sélection type Excel sur la grille ([features/xlsx-context-menu.md](./features/xlsx-context-menu.md)) ; au passage **BUG-094** corrigé (feuille vide/nouvelle désormais éditable, quadrillage vierge 20×8).
|
||||
- **Clôture 2026-09-30 :** #156 **livré (P0 → P3)** — **A8** mise en forme en écriture (bouton **Mise en forme** : gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — via `PUT …/xlsx/style`), **A9** décision « pas de moteur de formule, annoncée dans l'UI », **A10** undo/redo unifié conservé au re-rendu, **A11** export de la sélection / Markdown / HTML / impression + recherche sur toutes les feuilles, **A12** concurrence optimiste (`If-Match`, **409** réparable), **A13** cache des métadonnées par `mtime`, **A14** outils IA `.xlsm`/`.csv` + `search_workbook`/`analyze_range`/`edit_xlsx_structure` — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Ajout 2026-09-29 :** #156 **P1 livré** — **A5** presse-papiers de plage (copier/couper/coller un bloc, presse-papiers interne + système, entrées du menu contextuel, remplissage multi-cellules), **A6** clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) et **A7** zone Nom éditable + aide à la saisie ; restent P2 (mise en forme, calcul, undo unifié) et P3 (sortie, concurrence optimiste, performances, outils IA).
|
||||
- **Ajout 2026-09-29 :** #156 ouvert — audit de complétude de l'éditeur Excel : **4 défauts recensés** (BUG-096 enregistrement `.csv`, BUG-097 lazy-load `.xlsm`, BUG-098 délimiteur CSV, BUG-099 sonde de perte), **corrigés le jour même (P0 ✅)** avec tests de non-régression ; restent P1-P3 (presse-papiers, clavier, mise en forme, calcul, export, concurrence optimiste) puis presse-papiers de plage, clavier complet, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Clôture #85 (v2.27.13) :** monolithe découpé (T1→T9), stores verrouillés + rate-limit SQLite (T10), fiche `docs/features/archi-refonte-85.md`.
|
||||
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
|
||||
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
|
||||
- Les items marqués 🟢 (nice-to-have) sont de bons candidats pour des contributions externes.
|
||||
|
||||
@@ -404,6 +404,21 @@ Deux compléments au bouton « Ajouter » de l'assistant IA.
|
||||
|
||||
---
|
||||
|
||||
## #152 — Viewer XLSX : affichage, édition, téléchargement ✅ TERMINÉ
|
||||
|
||||
Les fichiers `.xlsx` s'ouvrent dans un dédié : un tableau HTML par feuille (onglets en cas de
|
||||
multi-feuilles, en-têtes A1, cellules `contenteditable`), bouton **Enregistrer** actif dès la
|
||||
première modification et téléchargement du fichier d'origine.
|
||||
|
||||
| Aspect | Détail |
|
||||
|---|---|
|
||||
| Lecture | `backend/xlsx_reader.py` — openpyxl `read_only`, formules affichées comme texte, plafond 500×40 cellules par feuille |
|
||||
| Écriture | `PUT /api/file/{vault}/xlsx/save` → `services/mutations.edit_xlsx_cells` (backup avant écriture, refs A1 validées, `str`→`int`/`float`, 500 cellules max par requête) |
|
||||
| Frontend | `renderXlsxViewer` dans `frontend/js/viewer.js` (onglets, cellules sales, Entrée/Échap, collage monoligne) |
|
||||
| Limite connue | Le round-trip openpyxl conserve valeurs/formules/styles mais perd graphiques, images et tableaux croisés |
|
||||
|
||||
---
|
||||
|
||||
## Grosses fonctionnalités — fiches dédiées
|
||||
|
||||
| # | Feature | Version | Fiche |
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend **pour le chat classique uniquement** (BUG-053 : en mode agent, le prompt impose les outils natifs et interdit les blocs `obsigate-action`)
|
||||
- [x] **B4.** SSE réellement streaming — `ai_chat.stream_completion` (`_openai_stream` + `_gemini_stream`) alimente `/api/ai/bookslm/chat` token par token ; le middleware GZip laisse passer les endpoints SSE BooksLM.
|
||||
- [x] **B5.** Confirmations UI : toggle « mode agent » (front → `/agent`), événements `tool`/`confirmation`, carte Apply + aperçu diff (LCS) pour les mutations, reprise `confirm`/`confirm_messages` côté backend. *S'active dès que la phase D enregistre des outils `write`.*
|
||||
- **Complément (BUG-074 → BUG-077)** : la pause de confirmation **regroupe toutes les mutations** d'un même tour LLM (`pending.actions`, chacune avec son libellé `step` et son diff) et la carte n'offre plus qu'un seul bouton « **Tout approuver (N)** » ; la reprise envoie `confirm_all` et le backend arme `ToolContext.confirmed` pour le reste du run (plus d'approbation action par action). Le bloc d'étapes affiche un **titre** (1re action) et ne compte que les **actions** (hors réflexions) ; la reprise diffuse dans le **même message** (« N étapes » cumulées). L'arborescence et le document affiché sont **rafraîchis** dès une action mutatrice (refresh débouncé + `obsigate:file-written`, documents xlsx/docx/csv/pdf inclus). Le bouton d'envoi devient « **Stop** » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »).
|
||||
- [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md))
|
||||
- [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`)
|
||||
|
||||
@@ -67,7 +68,7 @@
|
||||
`call_tool` (couvre les diffs, extraits de recherche et lectures non pré-redactées).
|
||||
- [x] **F3.** Documentation OpenAPI + guide MCP — `backend/openapi_docs.py` : tag `MCP`,
|
||||
règle `/mcp`, injection du path `/mcp` (Streamable HTTP, JSON-RPC) dans le schéma ;
|
||||
nouveau [`docs/MCP_GUIDE.md`](../MCP_GUIDE.md) (endpoint, auth, config Claude Desktop /
|
||||
nouveau [`docs/GUIDES/MCP.md`](../GUIDES/MCP.md) (endpoint, auth, config Claude Desktop /
|
||||
Cursor, tools/resources/prompts, sécurité, variables, dépannage).
|
||||
- [x] **F4.** Tests E2E de bout en bout — `tests/test_ai_e2e.py` : agent in-app
|
||||
read→confirmation→write, quota d'outils, rate limiting, redaction, et flux MCP complet
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# #85 — Refonte architecturale : découpage du monolithe & persistance d'état (phase 2)
|
||||
|
||||
> **Statut :** livré (T1→T10) — `backend/main.py` 4 827 → ~750 lignes, 14 routers,
|
||||
> persistance partielle (stores verrouillés + rate-limit SQLite optionnel).
|
||||
> Méthode : tranches à impact minimal, comportement inchangé, un domaine par
|
||||
> commit, suite complète verte à chaque commit (1320 passed / 6 skipped).
|
||||
|
||||
## 1. Découpage du monolithe (T1→T9, comportement inchangé)
|
||||
|
||||
Chaque tranche déplace un domaine vers `backend/routers/` (handlers verbatim,
|
||||
mêmes chemins/modèles/auth/tags OpenAPI), les modèles vers `backend/schemas.py`,
|
||||
et ne committe que sur suite verte + `test_version` vert.
|
||||
|
||||
| Tranche | Domaine | Nouveau module | Version |
|
||||
|---|---|---|---|
|
||||
| T1 | health (`/api/health*`) | `routers/health.py` (+ `HealthResponse` → schemas) | 2.27.2 |
|
||||
| T2 | webhooks CRUD | `routers/webhooks.py` | 2.27.3 |
|
||||
| T3 | sharing (`/api/share*`, `/s/*`) | `routers/sharing.py` | 2.27.4 |
|
||||
| T4 | backups (9 routes) | `routers/backups.py` (+ `Diff/Restore*` → schemas, `backend/sse.py`) | 2.27.5 |
|
||||
| T5 | search (11 routes) | `routers/search.py` (+ modèles → schemas, `backend/search_executor.py`) | 2.27.6 |
|
||||
| T6a | lecture fichiers | `routers/files_read.py` (+ modèles, `routers/helpers.py`) | 2.27.7 |
|
||||
| T6b | mutations fichiers/dossiers | `routers/files_write.py` (+ 15 modèles → schemas) | 2.27.8 |
|
||||
| T6c | media/pdf/export/guide | `routers/files_media.py` (Range helper → `helpers.py`) | 2.27.9 |
|
||||
| T7 | config (12 routes) | `routers/config.py` (`_FALLBACK_MODELS` déplacé) | 2.27.10 |
|
||||
| T8 | vaults + history + conflicts (13 routes) | `routers/vaults.py`, `history.py`, `conflicts.py` (+ `backend/watcher_state.py`) | 2.27.11 |
|
||||
| T9 | realtime + render | `routers/realtime.py` (SSE + collab WS), `backend/render.py` | 2.27.12 |
|
||||
|
||||
`main.py` ne contient plus que l'assemblage : lifespan, middlewares, montage
|
||||
des routers, racine `/api`, statique/SPA, 4 cales de compatibilité testées
|
||||
(`_resolve_safe_path`, `_backup_file`, `_check_vault_writable`, `_get_backup_dir`).
|
||||
|
||||
Correctifs au passage : décorateur orphelin `/s/{token}` (double-enregistrement
|
||||
de `/api/conflicts`), tag OpenAPI `media` inexistant (assignation par chemin
|
||||
conservée), tests statiques frontend réalignés (`image-viewer`, `media-viewer`),
|
||||
tests repointés vers les modules canoniques (`test_ai_models`, `test_api_main`).
|
||||
|
||||
## 2. Persistance d'état (T10)
|
||||
|
||||
| État | Avant | Après |
|
||||
|---|---|---|
|
||||
| JTI révoqués (`revoked_tokens.json`) | persisté, **sans verrou** | `RLock` (load/save/revoke/check) |
|
||||
| `shares.json` | persisté, **sans verrou** | `RLock` (4 mutateurs) |
|
||||
| `webhooks.json` + secrets | persistés, **sans verrou** | `RLock` (create/update/delete/secrets) |
|
||||
| `api_keys.json` (tool-secrets) | persisté, **sans verrou** | `RLock` (set/delete) |
|
||||
| Rate-limit auth | mémoire, mono-process | **inchangé par défaut** + option `OBSIGATE_RATELIMIT_DB` (SQLite WAL : mêmes fenêtres/budgets, partagé multi-workers, survit au redémarrage) |
|
||||
| Index de recherche | mémoire, rebuild au démarrage | **conservé** (voir §3) |
|
||||
| `users.json`, `api_tokens.json`, `vault_settings.json` | déjà verrouillés (BUG-029, #107) | inchangé |
|
||||
|
||||
Tests : `tests/test_store_locks.py` (4 — concurrence threads, pertes prouvées
|
||||
sans verrou : 25/200 partages), `tests/test_ratelimit_store.py` (7 —
|
||||
sémantique SQLite identique, persistance, concurrence 200/200).
|
||||
|
||||
Déjà existants et vérifiés (pas de code) : verrous `threading` + `asyncio`
|
||||
de l'indexeur (`_index_lock`, `_async_index_lock`), contrat central des
|
||||
outils IA — `backend/tools/registry.py` couvre déjà permissions
|
||||
(`requires_vault`, `require_destructive_allowed`), quotas
|
||||
(`check_and_record` par outil) et redaction (`redact_payload`) pour les
|
||||
35 outils enregistrés via `@tool(`.
|
||||
|
||||
## 3. Décisions assumées (non fait, et pourquoi)
|
||||
|
||||
- **Index non persisté sur disque.** Le rebuild différentiel (#86 : réutilise
|
||||
les entrées inchangées `size` + `mtime`) rend le démarrage rapide ; un
|
||||
snapshot introduirait des risques de staleness/drift de format sans gain
|
||||
mesuré. Réévaluer si le démarrage devient lent (vaults 50k+ fichiers).
|
||||
- **Redis exclu.** SQLite WAL couvre le multi-workers mono-hôte sans nouvelle
|
||||
infra ; Redis reste l'option multi-nœuds documentée (cf. `ratelimit.py`).
|
||||
- **`.gitignore` (`_*.py` ignore les `__init__.py`).** Contourné par
|
||||
`git add -f` comme les packages existants ; assainir la règle à part.
|
||||
- Noms en `_` conservés (`backend/render.py`, stores) : déplacement verbatim,
|
||||
zéro churn d'appels.
|
||||
|
||||
## 4. Reste connu (hors #85)
|
||||
|
||||
- CSP `unsafe-inline` (migration nonce, BUG-034 partiel) et `Secure` cookies → #87.
|
||||
- `main.py` (~750 lignes) : lifespan, middlewares, statique/SPA — cible
|
||||
d'extraction ultérieure si besoin, non bloquant.
|
||||
@@ -0,0 +1,69 @@
|
||||
# #112 — En-tête allégé & compte en sidebar
|
||||
|
||||
> **Version livrée :** 2.21.0 · **Statut :** 🟢 · **Impact :** 🟡
|
||||
> **Zone :** frontend (`index.html`, `frontend/js/auth.js`, `frontend/js/viewer.js`,
|
||||
> `frontend/style.css`, i18n FR/EN) + CI (`.gitea/workflows/ci.yml`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Le header concentrait plusieurs éléments redondants ou peu lisibles :
|
||||
|
||||
- la **version** était affichée en permanence à côté du bouton Options ;
|
||||
- un **bouton de déconnexion** et le **nom de l'utilisateur** occupaient la zone
|
||||
droite du header, sans regroupement logique ;
|
||||
- la pellicule de miniatures de la visionneuse d'images ne pouvait être
|
||||
parcourue qu'au glisser (pas de molette horizontale ni de flèches dédiées).
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Header allégé
|
||||
|
||||
- **Version déplacée** du header vers le menu **Options** : nouvelle ligne
|
||||
non-interactive « Version » (`#version-badge` conservé pour `populateVersions`).
|
||||
- **Suppression** du bloc `#user-menu` (nom + bouton « sign out ») et de la
|
||||
ligne « Déconnexion » du menu Options. Le header droit ne garde que l'indicateur
|
||||
hors-ligne, le contexte vault et le bouton Options.
|
||||
|
||||
### B. Section compte en bas de la sidebar
|
||||
|
||||
- Nouveau bloc `#sidebar-user` épinglé au bas de la sidebar (`flex-shrink: 0`,
|
||||
bordure supérieure), à la manière d'un site web professionnel :
|
||||
- initiales dans un **avatar** circulaire (dégradé d'accent) ;
|
||||
- **nom** (`#sidebar-user-name`, classe `.user-display-name` conservée pour la
|
||||
réutilisation par la collaboration) et **rôle** localisé
|
||||
(Administrateur / Utilisateur) ;
|
||||
- **bouton déconnexion** (`#sidebar-user-logout`) ;
|
||||
- clic sur l'identité → **Profil** (ouvre la section `#cfg-profile`).
|
||||
- `AuthManager.renderUserSection()` peuple le bloc ; il reste masqué quand
|
||||
l'authentification est désactivée (`#sidebar-user[hidden]`). `renderUserMenu()`
|
||||
est conservé comme alias rétro-compatible.
|
||||
- La règle mobile qui masquait `.user-display-name` est limitée au header
|
||||
(`.header-right .user-display-name`) pour que le nom reste visible dans la
|
||||
sidebar mobile.
|
||||
|
||||
### C. Pellicule d'images défilable (#112, complément de #111)
|
||||
|
||||
- Nouveau conteneur `.image-nav` autour de la pellicule, avec deux **flèches
|
||||
translucides** fixes (`.image-strip-arrow-prev/next`) révélées au survol.
|
||||
- **Molette** au-dessus de la pellicule → défilement horizontal
|
||||
(`strip.scrollLeft += deltaY`), conversion des deltas verticaux.
|
||||
- Les flèches font défiler d'une « page » (`strip.scrollBy`, défilement doux).
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/frontend/unit.test.mjs` (+1) : header nettoyé (plus de `#user-menu`,
|
||||
`#logout-btn`, ni version dans `.header-right`), version dans le menu Options,
|
||||
présence de `#sidebar-user`, styles `.sidebar-user`/`.menu-list-version`, clés
|
||||
i18n FR/EN.
|
||||
- `tests/frontend/image-viewer.test.mjs` (+1) : wrapper `.image-nav`, flèches de
|
||||
pellicule, molette et `scrollBy`.
|
||||
- `tests/e2e/header-sidebar.spec.js` (nouveau) : test header/version exécuté
|
||||
partout ; test compte en sidebar conditionné à l'auth (skip sinon).
|
||||
- CI : `image-viewer.test.mjs` ajouté au job `lint` (il n'y était pas depuis #108).
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- La section compte n'apparaît que si l'authentification est activée.
|
||||
- `doLogoutFallback` (script inline supprimé avec la ligne du menu) reste appelé
|
||||
par la section Profil uniquement en repli ; `window.handleLogout` est toujours
|
||||
défini par `auth.js`, donc le repli n'est jamais atteint.
|
||||
@@ -0,0 +1,80 @@
|
||||
# #111 — Visionneuse d'images : navigation fluide
|
||||
|
||||
> **Version livrée :** 2.20.0 · **Statut :** 🟢 · **Impact :** 🟡
|
||||
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/style.css`, i18n FR/EN).
|
||||
> **Améliore :** [#108](./image-support.md) (visionneuse livrée en 2.17.0).
|
||||
|
||||
## Contexte
|
||||
|
||||
La visionneuse d'images de #108 fonctionnait mais restait perfectible sur l'usage
|
||||
quotidien :
|
||||
|
||||
- certaines images s'affichaient **plus grandes que le cadre** de présentation ;
|
||||
- changer d'image (←/→, flèches, vignette) appelait `openFile` → `renderFile` →
|
||||
`renderImageViewer`, soit **un rechargement complet** de la vue **et** un
|
||||
nouvel appel `/api/browse` à *chaque* image — sensible dès qu'un dossier en
|
||||
contient beaucoup ;
|
||||
- la **pellicule de miniatures disparaissait** le temps du re-rendu ;
|
||||
- aucune zone de clic latérale ne permettait de changer d'image.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Ajustement au cadre
|
||||
|
||||
- La zone de contenu devient un conteneur **flex** dédié à la visionneuse
|
||||
(`.content-area:has(> .image-viewer-container) { padding: 0; overflow: hidden }`) :
|
||||
plus de défilement de page, la visionneuse occupe tout l'espace disponible.
|
||||
- `.image-stage` gagne `min-height: 0`, un `padding` de 16 px (cadre) et
|
||||
`box-sizing: border-box` ; `.image-main` conserve `max-width/height: 100%`
|
||||
avec `width/height: auto`. L'image est donc **toujours redimensionnée dans
|
||||
l'espace disponible**, y compris quand le panneau « Métadonnées » s'ouvre
|
||||
(la scène se réduit, l'image suit).
|
||||
|
||||
### B. Navigation « en place » (performance)
|
||||
|
||||
- `renderImageViewer` ne se contente plus de rendre une image : il gère un état
|
||||
mutable (`currentPath`, `currentTitle`, `imgUrl`, `currentMeta`) et expose
|
||||
`showSibling(index)` qui **remplace le `src` du `<img>`** sans reconstruire le
|
||||
DOM. Les flèches, le clavier ←/→ et les miniatures passent tous par là.
|
||||
- Conséquences : plus de `openFile`, plus de re-rendu, **plus de refetch du
|
||||
fichier ni de `/api/browse`** à chaque image.
|
||||
- **Cache annuaire** `_imageDirCache` (`Map`, TTL 15 s) : la liste des images
|
||||
d'un dossier n'est récupérée qu'une fois par courte fenêtre.
|
||||
- **Préchargement** des images voisines (`new Image()`), avec un `Set` pour
|
||||
éviter les doublons.
|
||||
|
||||
### C. Pellicule persistante
|
||||
|
||||
- La pellicule de miniatures n'est plus recréée à chaque navigation ; la
|
||||
vignette active est simplement re-marquée (`.active`) et **amenée dans la vue
|
||||
par défilement horizontal du film uniquement** (jamais la page).
|
||||
- Miniatures en `loading="lazy"` + `decoding="async"`.
|
||||
|
||||
### D. Flèches latérales translucides
|
||||
|
||||
- Deux boutons superposés `.image-nav-arrow` (prev/next) longent les bords du
|
||||
cadre, avec une icône `chevron` et une ombre portée pour rester lisibles sur
|
||||
toute image.
|
||||
- Opacité quasi nulle au repos, révélée au **survol du cadre** (`0.4`) puis du
|
||||
bouton (`1`, avec dégradé sombre). Toujours visibles (opacité moyenne) sur les
|
||||
appareils tactiles (`@media (hover: none)`).
|
||||
- Le `pointerdown`/`dblclick` des flèches n'est pas propagé à la scène : le
|
||||
pan (glisser) et le double-clic de réinitialisation du zoom restent intacts.
|
||||
- Un **compteur** `n / total` est ajouté à la barre d'outils.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/frontend/image-viewer.test.mjs` : helpers purs inchangés + vérifications
|
||||
statiques de la navigation en place (`showSibling`, absence de
|
||||
`_imageViewerNavPending`, cache annuaire), des flèches et du CSS
|
||||
(`:has(> .image-viewer-container)`, `max-width/height`, `opacity`).
|
||||
- `tests/e2e/image-viewer.spec.js` (+1) : image contenue dans le cadre, flèche
|
||||
superposée révélée au survol, titre mis à jour **sans recréer le conteneur**
|
||||
(marqueur `data-inplace`), pellicule toujours visible et compteur affiché.
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- Le cache annuaire a un TTL court : une image ajoutée puis ouverte dans les
|
||||
quelques secondes peut ne pas apparaître tout de suite dans la pellicule.
|
||||
- Les flèches latérales n'apparaissent pas en mode lightbox plein écran
|
||||
(navigation clavier ←/→ et pellicule masquée conservées).
|
||||
@@ -0,0 +1,96 @@
|
||||
# #108 — Support complet des images (arborescence, visionneuse, indexation)
|
||||
|
||||
> **Version livrée :** 2.17.0 · **Statut :** ✅ · **Impact :** 🟡
|
||||
> **Zone :** backend (`indexer`, `main`, `media_types`, `media_thumbs`) + frontend
|
||||
> (`viewer.js`, `utils.js`, `style.css`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Seul l'affichage *inline* dans un document markdown (`![[image.png]]`) fonctionnait.
|
||||
L'image isolée était **invisible dans l'arborescence** (filtrée par
|
||||
`SUPPORTED_EXTENSIONS`) et son **affichage standalone était cassé** : le `<img>`
|
||||
généré par `api_file_view()` pointait vers `/api/file/{vault}/raw`, un endpoint qui
|
||||
renvoie du **JSON** (`FileRawResponse`) et non des octets d'image.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Arborescence & indexation
|
||||
|
||||
- **`backend/media_types.py`** (nouveau) : source unique des extensions
|
||||
`IMAGE_EXTENSIONS`, `AUDIO_EXTENSIONS`, `VIDEO_EXTENSIONS` (+ helpers
|
||||
`is_image`/`is_audio`/`is_video`/`is_media`/`media_mime_type`). Socle réutilisé
|
||||
par #109. `attachment_indexer.py` et `api_file_view()` ne dupliquent plus la
|
||||
liste.
|
||||
- Les extensions image sont intégrées à `SUPPORTED_EXTENSIONS`
|
||||
(`indexer.py`) **avec une branche binaire** : `_scan_vault` et
|
||||
`_index_single_file_sync` indexent **nom / taille / mtime** et ne lisent
|
||||
**jamais** les octets (`content: ""`, `content_preview: ""`). Le TF-IDF reste
|
||||
donc propre et aucune `UnicodeDecodeError` ne pollue les logs.
|
||||
- Le reindex **watchdog** suit automatiquement (même filtre d'extensions).
|
||||
- Le filtre `ext:png` / `ext:jpg` de la recherche avancée est opérationnel dès
|
||||
lors que les images entrent dans l'index.
|
||||
- `/api/dashboard` expose `image_count` (par vault) et `total_images` (global),
|
||||
séparés du `file_count` général.
|
||||
|
||||
### B. Affichage standalone (correctif)
|
||||
|
||||
- `api_file_view()` génère désormais
|
||||
`src="/api/image/{vault}?path=…"` (chemin URL-encodé) au lieu de `/raw`.
|
||||
- `viewer.js` utilise le même endpoint (plus de bouton « Plein écran » cassé).
|
||||
- **Sécurité SVG** : `/api/image` (et le repli de `/api/media/.../thumb`) ajoute
|
||||
`Content-Security-Policy: sandbox` pour les `.svg`, ce qui empêche
|
||||
l'exécution du JavaScript embarqué quand le fichier est ouvert directement
|
||||
dans un onglet (XSS same-origin). Dans une balise `<img>`, l'en-tête est sans
|
||||
effet. Le middleware de sécurité ne remplace plus une politique stricte posée
|
||||
par une route.
|
||||
|
||||
### C. Miniatures
|
||||
|
||||
- `GET /api/media/{vault}/thumb?path=…&size=…` : miniature **WebP** générée
|
||||
avec `pillow>=10.0`, mise en cache sous
|
||||
`<OBSIGATE_DATA_DIR>/.obsigate-cache/thumbs/{sha1}.webp`. La clé de cache
|
||||
embarque **mtime + taille**, donc toute édition invalide naturellement la
|
||||
vignette.
|
||||
- Génération dans un thread (`run_in_executor`) avec **timeout 2 s** ; repli sur
|
||||
l'original en cas d'échec. SVG : l'original est servi tel quel (Pillow ne
|
||||
décode pas le SVG) ; GIF/WebP animés : première frame.
|
||||
|
||||
### D. Visionneuse
|
||||
|
||||
`renderImageViewer()` (`frontend/js/viewer.js`) remplace l'ancien rendu minimal :
|
||||
|
||||
- image centrée `object-fit: contain` ; **zoom molette 0,1×–8×**, **pan au
|
||||
glisser** (Pointer Events), **double-clic = réinitialisation**, raccourcis
|
||||
`+` / `-` / `0` ;
|
||||
- boutons +/−/reset et **badge de zoom** ;
|
||||
- **navigation ←/→** entre les images du même dossier (via `/api/browse`) et
|
||||
**pellicule de miniatures** (`/api/media/.../thumb`, `loading="lazy"`) ;
|
||||
- barre d'outils : « Ouvrir l'original » (nouvel onglet `/api/image`),
|
||||
téléchargement, **panneau métadonnées** repliable (dimensions via
|
||||
`naturalWidth/Height`, taille, type MIME, chemin, date), **lightbox** plein
|
||||
écran (fond `rgba(0,0,0,.9)`, `Échap` pour quitter) ;
|
||||
- `EXT_ICONS` : extensions image → icône Lucide `image` ;
|
||||
- compatible Split View (#75) : rendu dans `getContentArea()` du panneau actif.
|
||||
|
||||
### E. Tests
|
||||
|
||||
- `tests/test_image_api.py` : octets + MIME sur `/api/image`, en-tête `sandbox`
|
||||
des SVG, URL `/api/image` dans le HTML de `api_file_view`, encodage des
|
||||
chemins accentués, miniatures WebP + repli SVG + refus non-image.
|
||||
- `tests/test_image_indexing.py` : image présente dans `list_directory` et
|
||||
`path_index`, indexée avec `content == ""`, pertinence watchdog, compteurs
|
||||
dashboard, filtre `ext:png`.
|
||||
- `tests/frontend/image-viewer.test.mjs` : helpers purs (`clampImageZoom`,
|
||||
`isImagePath`, `buildImageUrl`) + vérifications statiques (zoom/pan/nav,
|
||||
absence de `/raw` dans la visionneuse, CSS, icônes).
|
||||
- `tests/e2e/image-viewer.spec.js` : ouverture d'une image depuis
|
||||
l'arborescence, réponse `/api/image` en `image/png`, zoom molette, navigation
|
||||
par la pellicule (fixtures `test_vault/sample-image.png` +
|
||||
`sample-vector.svg`).
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- **HEIC/HEIF** (iPhone) : non décodables par les navigateurs → hors scope ;
|
||||
`pillow-heif` envisagé en v2.
|
||||
- Le SVG passe par l'original (pas de rendu bitmap côté serveur) : les
|
||||
miniatures de dossiers SVG ne sont pas générées.
|
||||
@@ -0,0 +1,187 @@
|
||||
# #109 — Support audio & vidéo (lecteurs HTML5 intégrés)
|
||||
|
||||
> **Version livrée :** 2.18.0 · **Statut :** ✅ · **Impact :** 🟡
|
||||
> **Zone :** backend (`main`, `indexer`, `media_types`, `bookslm`) + frontend
|
||||
> (`viewer.js`, `utils.js`, `style.css`, `sw.js`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Le socle média de #108 (`backend/media_types.py`) exposait déjà
|
||||
`AUDIO_EXTENSIONS` / `VIDEO_EXTENSIONS`, mais ces fichiers n'étaient ni indexés
|
||||
ni affichables : ils tombaient dans le chemin binaire « Ce fichier est binaire
|
||||
et ne peut pas être affiché » + bouton download.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Backend
|
||||
|
||||
- **Indexation** : `AUDIO_EXTENSIONS` et `VIDEO_EXTENSIONS` sont intégrées à
|
||||
`SUPPORTED_EXTENSIONS` (`indexer.py`). La branche `is_media(ext)` existante
|
||||
indexe **nom / taille / mtime** sans jamais lire les octets (`content: ""`),
|
||||
donc le TF-IDF et les logs restent propres. Le watcher et le filtre `ext:`
|
||||
suivent automatiquement.
|
||||
- **Streaming** : le helper Range de `pdf/stream` a été extrait en
|
||||
`_stream_file_with_range(file_path, request, media_type)` (206 +
|
||||
`Content-Range` + `Accept-Ranges`, `416` sur plage invalide, `FileResponse`
|
||||
simple sinon, lectures offloadées via `asyncio.to_thread`). `pdf/stream`
|
||||
l'utilise désormais aussi (comportement inchangé, tests de régression).
|
||||
- **Nouvel endpoint `GET /api/media/{vault}?path=…`** : sert audio/vidéo avec le
|
||||
MIME `media_types.media_mime_type` (surcharges `.m4a→audio/mp4`,
|
||||
`.opus/.oga→audio/ogg`, `.mov→video/quicktime`, `.m4v→video/mp4`).
|
||||
- **Gardes-fous** : `_resolve_safe_path`, `check_vault_access`, et
|
||||
`OBSIGATE_MEDIA_MAX_INLINE_MB` (défaut **500 Mo**) — au-delà, l'endpoint
|
||||
renvoie `413` et la vue fichier bascule sur l'UI de téléchargement.
|
||||
- **`api_file_view()`** renvoie, avant tout `read_text()`, `is_audio` /
|
||||
`is_video` / `stream_url` / `media_mime` / `size_bytes`. Au-delà de la limite :
|
||||
`unsupported: true` + `media_too_large: true`.
|
||||
|
||||
### B/C. Frontend — lecteurs
|
||||
|
||||
- `viewer.js` dispatche `data.is_audio` → `renderAudioViewer()` et
|
||||
`data.is_video` → `renderVideoViewer()`.
|
||||
- **Audio** : `<audio controls preload="metadata">` pleine largeur, artwork
|
||||
placeholder (icône Lucide `audio-lines`), durée lue via `loadedmetadata`,
|
||||
toolbar (titre, voûte + taille, badge durée, ouvrir l'original, télécharger).
|
||||
- **Vidéo** : `<video controls playsinline preload="metadata">` centrée sur une
|
||||
scène noire letterboxée (`max-height: calc(100vh - 180px)`), même toolbar
|
||||
avec durée + résolution.
|
||||
- `renderMediaFallback()` : sur l'événement `error` de l'élément média (codec
|
||||
hors web-natif : `.mkv`, `.avi`, HEVC…) ou si le fichier est trop volumineux,
|
||||
remplace le lecteur par l'UI binaire + message
|
||||
`viewer.media_unsupported` / `viewer.media_too_large` + **Télécharger** /
|
||||
**Ouvrir dans un nouvel onglet**.
|
||||
- **Pause** au changement de vue : `_mediaViewerCleanup` met en pause et détache
|
||||
la source quand `renderFile()` re-rend la zone (changement d'onglet, navigation)
|
||||
— pas de lecture persistante en v1 (cohérence #75).
|
||||
- `EXT_ICONS` (`utils.js`) : audio → `audio-lines`, vidéo → `video`.
|
||||
- i18n FR/EN (`viewer.media_unsupported`, `viewer.media_too_large`).
|
||||
|
||||
### D. Recherche & intégrations
|
||||
|
||||
- Filtres `ext:mp3`, `ext:mp4`, etc. opérationnels (les médias entrent dans
|
||||
l'index).
|
||||
- Récents / dashboards : previews vides (aucun texte extrait) — comportement
|
||||
naturel de la branche binaire.
|
||||
- **BooksLM** : `_file_entry()` ignore désormais tout média (`is_media`) — les
|
||||
octets ne sont jamais envoyés au modèle ; les images restent gérées à part via
|
||||
`load_vault_image_data_url` (vision).
|
||||
- **Hors scope v1** (porte notée) : transcription audio via Whisper.
|
||||
|
||||
### E. Mobile & PWA
|
||||
|
||||
- Le service worker ne met **jamais** en cache le flux média
|
||||
(`/api/media/{vault}`), tout en conservant le cache des miniatures
|
||||
(`/api/media/{vault}/thumb`) et la stratégie Network First pour le reste. Les
|
||||
requêtes `Range` étaient déjà exclues.
|
||||
|
||||
### F. Tests
|
||||
|
||||
- `tests/test_media_stream.py` : 206 + `Content-Range` + 1024 octets, `416` hors
|
||||
borne, `200` + `Accept-Ranges` sans Range, suffix range, `413` au-delà de la
|
||||
limite, `403` path traversal, `403` vault sans accès, `400` non-média, MIME
|
||||
`.mov`, régression `pdf/stream`.
|
||||
- `tests/test_media_indexing.py` : `.mp3`/`.mp4`/`.flac` dans l'arborescence et
|
||||
l'index, `content == ""`, watcher pertinent, filtre `ext:mp3`.
|
||||
- `tests/frontend/media-viewer.test.mjs` : helpers purs (`formatMediaDuration`,
|
||||
`buildMediaUrl`) + vérifications statiques (dispatch, `<audio>`/`<video>`,
|
||||
fallback, CSS, icônes, i18n, service worker, endpoints backend).
|
||||
- `tests/e2e/media-viewer.spec.js` : lecture `<audio>` et `<video>` via
|
||||
`/api/media` + réponse `206` sur requête `Range`. Fixtures :
|
||||
`test_vault/sample-audio.mp3` (sine 1 s) et `test_vault/sample-video.webm`
|
||||
(VP8 64×64).
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- Formats hors web-natifs (`.mkv`, `.avi`, HEVC, AC-4) : non lisibles sans
|
||||
transcodage (ffmpeg hors scope) → repli téléchargement / lecteur de l'OS.
|
||||
- HLS, sous-titres `<track src=".vtt">` et vignettes vidéo : hors scope v1.
|
||||
- Gros médias (> 500 Mo par défaut) : pas de lecture intégrée (protège le worker
|
||||
uvicorn unique) ; ajustable via `OBSIGATE_MEDIA_MAX_INLINE_MB`.
|
||||
|
||||
---
|
||||
|
||||
## #110 — Lecteur média persistant « Now Playing »
|
||||
|
||||
> **Version livrée :** 2.19.0 · **Statut :** ✅ · **Impact :** 🟡
|
||||
> **Zone :** frontend (`now-playing.js`, `viewer.js`, `ui.js`, `pane-manager.js`,
|
||||
> `app.js`, `style.css`, `index.html`, locales).
|
||||
|
||||
### Principe : un seul média, téléporté
|
||||
|
||||
`frontend/js/now-playing.js` est un contrôleur singleton qui possède **l'unique
|
||||
élément `<audio>`/`<video>`** de l'application. Il est déplacé par `appendChild`
|
||||
(sans recréation, donc sans couper la lecture) entre :
|
||||
|
||||
- la **vue inline** — l'onglet/panneau du média, via la surface
|
||||
`NowPlaying.attachInline(area, data)` (appelée par `renderAudioViewer` /
|
||||
`renderVideoViewer` de `viewer.js`) ; et
|
||||
- le **dock global** — un enfant direct de `<body>` (`#now-playing-host`), monté
|
||||
hors de `.content-wrapper` pour survivre à `renderFile()`, aux onglets, aux
|
||||
panneaux et à la reconstruction de la grille split.
|
||||
|
||||
`renderFile()` appelle `NowPlaying.handleRender(area, data)` : si la zone qui va
|
||||
être réécrite contient l'élément média, celui-ci est renvoyé au dock. Les autres
|
||||
points qui vident le contenu (dashboard `_showDashboard`, `showWelcome`,
|
||||
`PaneManager._buildGrid` / `_collapseToSingle`) appellent le même hook via le
|
||||
global `window.NowPlaying`.
|
||||
|
||||
### Surfaces et ergonomie
|
||||
|
||||
- **Dock audio (desktop)** : pilule flottante verre dépoli centrée en bas
|
||||
(`.np-dock--audio`) — artwork, titre, voûte, durée, play/pause,
|
||||
précédent/suivant, barre de progression (seek), volume, **revenir au média**,
|
||||
agrandir, fermer.
|
||||
- **Panneau étendu** : carte centrale (bottom-sheet sur mobile) avec artwork,
|
||||
scrub large, volume, vitesse 0,5–2×, précédent/suivant et actions
|
||||
ouvrir/télécharger/fermer.
|
||||
- **Mini-vidéo flottante** (`.np-dock--video`) : déplaçable **librement** depuis
|
||||
n'importe quel point de la fenêtre (position absolue mémorisée, centre autorisé
|
||||
— aucune aimantation aux bords) et redimensionnable, géométrie persistée ; sur
|
||||
mobile elle se fixe au-dessus de la barre d'outils.
|
||||
- **Mobile** : mini-player au-dessus de la barre 64 px
|
||||
(`bottom: calc(64px + env(safe-area-inset-bottom))`), `viewport-fit=cover`
|
||||
ajouté, et `body.np-active` ajoute le décalage du contenu. La barre audio passe
|
||||
en grille (progression sur sa propre ligne) et masque les actions secondaires
|
||||
pour éviter tout chevauchement.
|
||||
|
||||
### Comportements
|
||||
|
||||
- Naviguer (onglet, panneau, dashboard, split) **ne coupe pas** la lecture ; le
|
||||
dock apparaît.
|
||||
- **Revenir au média** : `NowPlaying.focus()` rouvre/focalise l'onglet
|
||||
`vault::path` (`window.getActiveTabManager().open`).
|
||||
- **Fermer** : `NowPlaying.stop()` met en pause, libère l'élément et masque le
|
||||
dock.
|
||||
- **Fermer l'onglet** du média en cours : la lecture continue et un toast
|
||||
`player.continues` le signale.
|
||||
- **Media Session** : métadonnées (`MediaMetadata`) + actions
|
||||
play/pause/stop/seek/nexttrack/previoustrack → écran verrouillé, casque
|
||||
Bluetooth, touches média, **Windows SMTC** (WebView2).
|
||||
- **Picture-in-Picture** natif pour la vidéo (`requestPictureInPicture`), bouton
|
||||
masqué si non supporté.
|
||||
- **Reprise après rechargement** : état (fichier, position, pause, préférences
|
||||
volume/vitesse) persisté en `localStorage` (`obsigate-now-playing`,
|
||||
`obsigate-player-prefs`, `obsigate-player-pos`).
|
||||
|
||||
### Fichiers modifiés / ajoutés
|
||||
|
||||
- Nouveau : `frontend/js/now-playing.js` (contrôleur, dock, session, PiP,
|
||||
persistance), `tests/e2e/media-viewer.spec.js` (dock/retour/fermeture/mini-vidéo).
|
||||
- Modifiés : `viewer.js` (délégation inline + `handleRender`), `ui.js`
|
||||
(dashboard + toast de fermeture d'onglet), `pane-manager.js` (grille/collapse),
|
||||
`app.js` (`initNowPlaying`), `style.css` (dock/étendu/vidéo/mobile, et
|
||||
correction des variables `--surface1`/`--text-dim` non définies),
|
||||
`index.html` (`viewport-fit=cover`), locales FR/EN (`player.*`).
|
||||
|
||||
### Correctifs annexes
|
||||
|
||||
- Les variables CSS `--surface1` et `--text-dim`, utilisées mais **jamais
|
||||
définies** depuis #108/#109, sont remplacées par `--surface` et
|
||||
`--text-secondary` (+ `--text-dim` dans toute la feuille).
|
||||
|
||||
### Hors scope (porte notée)
|
||||
|
||||
- Fenêtre vidéo détachée **native** Tauri (`WebviewWindowBuilder` +
|
||||
`always_on_top`) : non implémentée, à faire dans une itération dédiée (Rust,
|
||||
capabilities, route `/player`).
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# #114 — Configuration — refonte mobile-responsive de la section Settings
|
||||
|
||||
> **Statut :** 🟢 · **Impact :** 🟡 · **Zone :** frontend (mobile, ≤ 768 px)
|
||||
> **Fichiers :** `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`,
|
||||
> `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`,
|
||||
> `tests/e2e/config-mobile.spec.js`.
|
||||
|
||||
## Contexte
|
||||
|
||||
La page **Configurations** (`#config-modal`) était utilisable en mobile seulement
|
||||
partiellement (correctifs BUG-071) : le sommaire s'ouvrait en bloc haut, la modale
|
||||
n'était pas plein écran, les cibles tactiles étaient sous 44 px, le clavier virtuel
|
||||
iOS zoomait les champs, la rangée « Sauvegarder » disparaissait au scroll et plusieurs
|
||||
dettes HTML/i18n étaient restées en place.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Sommaire en drawer coulissant
|
||||
|
||||
- `#config-nav` devient un **panneau coulissant gauche** (`position: fixed`,
|
||||
`width: min(320px, 88vw)`, `z-index: 40`) sous un fond assombri
|
||||
(`#config-modal.config-toc-open::before`, `z-index: 35`).
|
||||
- Ouverture/fermeture pilotée par la classe **`.config-toc-open`** sur `#config-modal`
|
||||
(JS `_setConfigNav`) + un `display` inline conservé pour le contrat de reset.
|
||||
- Bouton **`#config-toc-close`** (classe `.help-toc-close`, `aria-label`
|
||||
`config.toc_close`) dans l'en-tête du drawer ; visible uniquement dans le drawer
|
||||
de la config (masqué sur desktop où la nav est toujours visible).
|
||||
- **Backdrop** : un tap hors du drawer ferme d'abord le drawer, pas la modale
|
||||
(`e.target === modal` → `config-toc-open` présent → `_setConfigNav(false)`).
|
||||
- **Échap** : ferme le drawer d'abord, puis la modale.
|
||||
- Fermeture de la modale (`closeConfigModal`) nettoie toujours la classe
|
||||
`config-toc-open` et le `display` inline (aucun fond ne subsiste).
|
||||
- Animation `config-toc-slide-in` (translateX) à l'ouverture.
|
||||
|
||||
### B. Modale plein écran
|
||||
|
||||
- `#config-modal` : `padding: 0`, `.editor-container` en `100vw × 100dvh`
|
||||
(`100dvh` = hauteur du viewport dynamique, tient compte de la barre du navigateur
|
||||
mobile), `border-radius: 0`, `border: none`.
|
||||
- Le contenu (`#config-scroll`) garde son scroll propre ; le sous-bloc
|
||||
`.config-content` reçoit un `padding-bottom: 80 px` pour ne jamais passer sous la
|
||||
rangée sticky.
|
||||
|
||||
### C. Cibles tactiles ≥ 44 px & anti-zoom iOS
|
||||
|
||||
- **Champs** : `.config-input`, `.config-select`, `.help-nav-search`,
|
||||
`.profile-field .config-input/.config-select` → `min-height: 44px` +
|
||||
`font-size: 16px` (**anti-zoom iOS** : un `font-size < 16px` déclenche le zoom
|
||||
automatique au focus). La règle est **scoppée `#config-modal`** pour ne pas écraser
|
||||
`.mfa-code-input` (qui a sa propre typographie).
|
||||
- **Boutons** : `.config-btn-save`, `.config-btn-secondary`, `.config-btn-primary`,
|
||||
`.config-btn-danger`, `.config-btn-add`, `.config-btn-sm`, `.mfa-link-btn`,
|
||||
`.theme-action-btn`, `.profile-avatar-actions .config-btn-secondary`,
|
||||
`.editor-btn` (fermer), `#config-hamburger`, `#config-toc-close`,
|
||||
`.help-search-clear` → `min-height/min-width: 44px`.
|
||||
- **Liens du sommaire** : `.help-nav-link` → `min-height: 44px` (ligne tactile
|
||||
confortable).
|
||||
- `.help-hamburger` passe de 36 px à **44 px** en mobile (règle partagée avec le
|
||||
modal d'aide).
|
||||
|
||||
### D. Rangée « Sauvegarder » sticky
|
||||
|
||||
- `.config-actions-row` (dans `#cfg-backend-settings`) → `position: sticky;
|
||||
bottom: 0`, empilée verticalement (`flex-direction: column`), boutons pleine
|
||||
largeur 44 px, fond opaque + bordure, `padding-bottom` avec
|
||||
`env(safe-area-inset-bottom)` (barre home iOS).
|
||||
- La rangée reste visible pendant le scroll de la section backend ; le
|
||||
`padding-bottom: 80px` de `.config-content` garantit qu'elle ne masque jamais les
|
||||
derniers contrôles.
|
||||
|
||||
### E. Formulaires 1 colonne & grilles
|
||||
|
||||
- `.config-row` → 1 colonne (`grid-template-columns: 1fr`), `.config-input--num`
|
||||
pleine largeur, `text-align: left`.
|
||||
- `.ai-default-grid` / `.ai-provider-fields` → 1 colonne.
|
||||
- Add-rows (`.config-add-row`, `.config-add-pattern`) → wrap + largeurs inline
|
||||
(`180/140/100px`) neutralisées (`width: auto !important`).
|
||||
- Items webhook/token/share → wrap ; URLs/méta sur leur propre ligne
|
||||
(`overflow-wrap: anywhere`) ; boutons de suppression 44 px.
|
||||
- `.hidden-files-add-row` → wrap, input pleine largeur.
|
||||
- `.config-diag-row` → wrap.
|
||||
- `.profile-avatar-row` → wrap ; `.profile-form` → `max-width: 100%`.
|
||||
- `.webauthn-key-item` → wrap ; `.webauthn-key-label` → pleine largeur.
|
||||
- `.theme-grid` reste en `auto-fill minmax(160px, 1fr)` (déjà responsive).
|
||||
|
||||
### F. MFA & sécurité
|
||||
|
||||
- `.mfa-recovery-list` → **1 colonne** en mobile (2 colonnes illisibles à 360 px).
|
||||
- `.mfa-verify-section`, `.mfa-recovery-actions`, `.mfa-disable-actions` → wrap ;
|
||||
champs/boutons enfants en pleine largeur.
|
||||
- `.mfa-code-input` → `width: 100%`, `max-width: 320px`, `letter-spacing: 6px`
|
||||
(au lieu de 12 px qui débordait), `min-height: 52px`.
|
||||
- `.mfa-secret-code` → `word-break: break-all` (secret TOTP long).
|
||||
- `.mfa-code-input-group` → wrap.
|
||||
- Règle morte **`.mfa-recovery-input`** supprimée (auth.js utilise
|
||||
`mfa-code-input recovery-input`).
|
||||
|
||||
### G. Dettes HTML corrigées
|
||||
|
||||
- `.config-actions-row` (Sauvegarder / Réindexer / Réinitialiser) déplacée
|
||||
**dans** `#cfg-backend-settings` (elle était hors de toute section → le sticky
|
||||
n'avait pas de conteneur de scroll fiable) ; `</section>` orphelin supprimé.
|
||||
- Id dupliqué **`cfg-partages-publics`** retiré du `<h2>` (l'id reste sur la
|
||||
`<section>`, cf. #113).
|
||||
- Conteneur mort **`#plugins-settings-container`** supprimé (le rendu réel est
|
||||
`#cfg-plugins` via `plugins.js`).
|
||||
- Sections plugins/about ré-indentées.
|
||||
- **`#mt-explorer`** : libellé brut `settings.search` remplacé par
|
||||
`<span data-i18n="settings.explorer">` (i18n correct, FR « Explorateur » / EN
|
||||
« Files »).
|
||||
- Doublons CSS **`.config-btn-add`** (3 définitions) réduits à la définition de
|
||||
référence.
|
||||
|
||||
### H. i18n FR/EN
|
||||
|
||||
- **Purge des clés mortes** (aucune référence HTML/JS/tests/backend) :
|
||||
`settings.backend`, `settings.backend_hint`, `settings.restart_badge`,
|
||||
`settings.save`, `settings.plugins`.
|
||||
- **Ajouts** : `settings.explorer` (FR « Explorateur » / EN « Files »),
|
||||
`config.toc_close` (FR « Fermer le sommaire » / EN « Close contents »).
|
||||
- **Correctif** : `settings.tabs` en FR était le mot anglais « Tabs » →
|
||||
**« Onglets »** (EN reste « Tabs »).
|
||||
- Clés vivantes conservées : `settings.reindex`, `settings.no_restart_badge`,
|
||||
`settings.backend_section`, `settings.security`, `settings.search`,
|
||||
`settings.tabs`, `settings.search_placeholder`.
|
||||
|
||||
## Tests
|
||||
|
||||
### Statics — `tests/frontend/config-mobile.test.mjs` (27, au CI)
|
||||
|
||||
- BUG-071a–e (hamburger, toggle JS, grilles/wrap, ancres mortes,
|
||||
`data-i18n-attr` multi-paires) — conservés et adaptés au drawer.
|
||||
- **#114a** : `#config-toc-close` présent + i18n ; `_setConfigNav` bascule
|
||||
`.config-toc-open` ; backdrop `::before` (z-index 35) ; `.help-toc-close`
|
||||
masqué sur desktop / visible dans le drawer ; Échap et backdrop ferment le
|
||||
drawer d'abord ; `closeConfigModal` nettoie la classe.
|
||||
- **#114b** : modale `100dvh` + `padding: 0` ; inputs/selects `16px` +
|
||||
`44px` ; boutons `44px` ; rangée sticky (`position: sticky` +
|
||||
`flex-direction: column` + safe-area) ; MFA 1 colonne + wrap + code
|
||||
full-width ; `.config-actions-row` bien dans `#cfg-backend-settings`.
|
||||
- **#114i18n** : clés mortes purgées ; `settings.explorer` FR/EN ;
|
||||
`settings.tabs` FR = « Onglets » ; `#mt-explorer` porte
|
||||
`data-i18n="settings.explorer"` ; `#plugins-settings-container` absent.
|
||||
|
||||
### E2E — `tests/e2e/config-mobile.spec.js` (5, projet `chromium-mobile`)
|
||||
|
||||
1. Hamburger → drawer `position: fixed` + classe `config-toc-open` sur la modale.
|
||||
2. Bouton `#config-toc-close` et tap backdrop ferment le drawer **sans** fermer la
|
||||
modale.
|
||||
3. Sélection d'une section → scroll doux + lien actif + repli du drawer.
|
||||
4. Modale plein écran (largeur/hauteur ≈ viewport) + `#config-hamburger`,
|
||||
`#config-close` et `#cfg-save-backend` ≥ 44 px.
|
||||
5. Aucun débordement horizontal à 393 px (sections IA / tokens / webhooks /
|
||||
partages).
|
||||
|
||||
## Détails d'implémentation notables
|
||||
|
||||
- **Spécificité CSS** : les règles `#config-modal #config-nav` (2 ids) priment sur
|
||||
les règles génériques `.help-nav` / `body .help-nav` qui masquent la nav en
|
||||
mobile — pas besoin de `!important`.
|
||||
- **Backdrop = pseudo-élément** : les clics sur `::before` sont attribués à
|
||||
l'élément or (`#config-modal`), donc le handler `e.target === modal` existant
|
||||
fonctionne sans node supplémentaire.
|
||||
- **Contrat de reset conservé** : `configNavOnOpen.style.display = ''` à
|
||||
l'ouverture (test statique BUG-071b) — le CSS reprend la main (drawer masqué par
|
||||
défaut sur mobile).
|
||||
- **`100dvh` avec repli `100vh`** : les navigateurs sans support `dvh` gardent le
|
||||
comportement précédent.
|
||||
- La règle `.help-hamburger { display: inline-flex }` du bloc mobile du modal
|
||||
d'aide est **partagée** (44 px) ; le drawer de la config double avec
|
||||
`#config-modal .help-hamburger` pour rester robuste à un réordonnancement des
|
||||
règles.
|
||||
@@ -0,0 +1,94 @@
|
||||
# #113 — Ordre naturel des sections Configurations & avatar utilisateur
|
||||
|
||||
> **Version livrée :** 2.22.0 · **Statut :** 🟢 · **Impact :** 🟡
|
||||
> **Zone :** frontend (`index.html`, `frontend/js/config.js`, `frontend/js/auth.js`,
|
||||
> `frontend/style.css`, i18n FR/EN) + backend (`backend/auth/router.py`,
|
||||
> `backend/auth/user_store.py`) + CI (`.gitea/workflows/ci.yml`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Deux irritants sur la page **Configurations** :
|
||||
|
||||
- l'ordre des sections était historique et peu naturel (Recherche en tête, Profil
|
||||
noyé en position 11, À propos au milieu) ;
|
||||
- la section **Profil** ne permettait pas de personnaliser l'image affichée dans le
|
||||
cercle du compte en bas de la sidebar (initiales uniquement).
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Ordre naturel des sections (TOC = page)
|
||||
|
||||
Nouvel ordre, appliqué **à la liste `<ul class="help-nav-list">` (TOC) et aux
|
||||
`<section>` de la page**, dans le même ordre :
|
||||
|
||||
1. **Profil** (`#cfg-profile`) — en premier
|
||||
2. Sécurité du compte (`#cfg-security`)
|
||||
3. Thèmes (`#cfg-themes`)
|
||||
4. Paramètres de recherche (`#cfg-search`)
|
||||
5. Historique récent (`#cfg-recent`)
|
||||
6. Filtrage de tags (`#cfg-tags`)
|
||||
7. Fichiers cachés (`#cfg-hidden-files`)
|
||||
8. Synchronisation (`#cfg-sync`)
|
||||
9. Paramètres backend (`#cfg-backend-settings`)
|
||||
10. Diagnostics (`#cfg-diags`)
|
||||
11. Clés API IA (`#cfg-ai`)
|
||||
12. Sources connectées (`#cfg-sources`)
|
||||
13. Clés API & MCP (`#cfg-tokens`)
|
||||
14. Notifications push (`#cfg-push`)
|
||||
15. Webhooks (`#cfg-webhooks`)
|
||||
16. Partages publics (`#cfg-partages-publics`)
|
||||
17. Plugins (`#cfg-plugins`)
|
||||
18. **À propos** (`#cfg-about`) — en dernier
|
||||
|
||||
Regroupement retenu : **Compte & apparence → Navigation & contenu → Système →
|
||||
Intégrations & notifications → À propos**.
|
||||
|
||||
Les ancres `cfg-tags` et `cfg-partages-publics` étaient posées sur un `<h2>` à
|
||||
l'intérieur d'une `<section>` sans id : elles sont désormais portées par la
|
||||
`<section>` elle-même, pour que le scroll vise le haut de la section (comme toutes
|
||||
les autres).
|
||||
|
||||
### B. Avatar utilisateur ( Profil )
|
||||
|
||||
- **Import d'image** : bouton « Choisir une image » + overlay caméra au survol de
|
||||
l'aperçu circulaire (88 px) → `<input type="file" accept="image/png,image/jpeg,image/webp">`.
|
||||
- **Traitement client** (`config.js`) : garde type (PNG/JPEG/WEBP) et taille brute
|
||||
(8 Mo), **recadrage carré central** et redimensionnement à **256 px** via canvas,
|
||||
export JPEG qualitée 0,85 (fond blanc pour les PNG transparents).
|
||||
- **Persistance serveur** : `PATCH /api/auth/me` avec `{"avatar": "<data-url>"}` ;
|
||||
`""` supprime. Validation stricte dans `backend/auth/router.py`
|
||||
(`_validate_avatar`) : data-URL PNG/JPEG/WebP uniquement (**SVG refusé** — surface
|
||||
XSS), plafond 400 000 caractères, base64 valide et **octets magiques** contrôlés.
|
||||
Champ `avatar` ajouté à `data/users.json` (`create_user`), renvoyé par
|
||||
`GET/PATCH /api/auth/me` et par le payload `user` de login.
|
||||
- **Affichage sidebar** (`auth.js`) : `renderUserSection()` insère un
|
||||
`<img class="sidebar-user-avatar-img">` dans `#sidebar-user-avatar` quand
|
||||
`user.avatar` est défini, sinon les initiales (repli inchangé). Helpers
|
||||
`AuthManager.updateCachedUser()` et `AuthManager.isAuthEnabled()`.
|
||||
- **Suppression** : bouton « Supprimer la photo » (stylisté danger), revenu aux
|
||||
initiales.
|
||||
- **Auth désactivée** : le bloc avatar est masqué (pas de compte, sidebar masquée).
|
||||
- Feedback : toasts `config.avatar_updated` / `config.avatar_removed`, erreurs en
|
||||
ligne (`config.avatar_invalid_type`, `config.avatar_too_large`,
|
||||
`config.avatar_upload_failed`).
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_auth_api.py` — classe `TestAvatar` (+8) : GET/PATCH exposent
|
||||
l'avatar, data-URL PNG acceptée, `""` efface, SVG refusé, payload non-image refusé,
|
||||
trop grand refusé, base64 invalide refusé, avatar présent dans le payload de login.
|
||||
- `tests/frontend/settings-order-avatar.test.mjs` (nouveau, ajouté au job `lint`) —
|
||||
9 tests : ordre TOC (Profil 1er, À propos dernier), ordre de page strictement
|
||||
identique à la TOC, aucune ancre morte / section orpheline, présence de l'UI avatar
|
||||
dans `#cfg-profile`, flux `config.js` (types, taille, resize, PATCH, rafraîchissement
|
||||
sidebar), rendu `auth.js`, règles CSS, clés i18n FR/EN, validation backend.
|
||||
- Vérifications locales : pytest 1302 passed, ruff/mypy 0 erreur, tests frontend
|
||||
statiques + JSDOM verts.
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- L'avatar est stocké en data-URL dans `data/users.json` (adéquat pour un usage
|
||||
personnel ; un stockage fichier dédié restera possible si les comptes se multiplient).
|
||||
- La conversion GIF/animé n'est pas prise en charge (types PNG/JPEG/WEBP uniquement).
|
||||
- Le nom d'affichage du profil reste local (`localStorage`) et n'est pas poussé au
|
||||
serveur (comportement antérieur conservé).
|
||||
@@ -0,0 +1,122 @@
|
||||
# #115 / BUG-078 / #117 — Barre d'outils épinglée, coloration syntaxique des fichiers de code & avatars prédéfinis
|
||||
|
||||
> **Statut :** 🟢 livré (en attente vérification utilisateur)
|
||||
> **Impact :** 🟡 (#115, BUG-078) · 🟢 (#117)
|
||||
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/js/themes.js`,
|
||||
> `frontend/js/ui.js`, `frontend/js/config.js`, `frontend/index.html`,
|
||||
> `frontend/style.css`, `frontend/popout.html`, `frontend/locales/fr.json`,
|
||||
> `frontend/locales/en.json`, `frontend/icons/avatar/*`)
|
||||
|
||||
Trois demandes traitées dans la même livraison, toutes côté frontend.
|
||||
|
||||
## #115 — Barre d'outils de lecture toujours visible
|
||||
|
||||
### Problème
|
||||
|
||||
La barre d'actions d'un document (pop-out, bookmark, Editer, Source, Copier, PDF, Export,
|
||||
Partager…) était rendue **dans** `.file-header`, un conteneur court placé en haut de la
|
||||
zone de lecture. Or un élément `position: sticky` reste borné par son parent : dès que
|
||||
`.file-header` sortait de l'écran au défilement d'un document long, la barre disparaissait.
|
||||
|
||||
### Correctif
|
||||
|
||||
- `frontend/js/viewer.js` et `frontend/popout.html` sortent la barre d'actions de
|
||||
`.file-header` et la placent dans un nouveau conteneur `.file-toolbar`, **enfant direct
|
||||
de `.content-area`** (le conteneur de défilement). La barre peut donc se coller au haut
|
||||
de la zone de lecture pour toute la hauteur du document.
|
||||
- `frontend/style.css` :
|
||||
|
||||
```css
|
||||
.file-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
margin: 0 0 16px;
|
||||
padding: 8px 0;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
```
|
||||
|
||||
Le fond est opaque pour qu'aucun texte ne transparaisse dessous.
|
||||
- `body.reading-mode .file-toolbar { display: none }` : la barre reste masquée en mode
|
||||
lecture, comme les autres actions.
|
||||
|
||||
## BUG-078 — Coloration syntaxique des fichiers de code
|
||||
|
||||
### Problème
|
||||
|
||||
Les fichiers `.py`, `.sh`, `.ps1`, `.yml`, `.json`… (et les blocs de code Markdown)
|
||||
s'affichaient en **texte brut**, sans couleurs. Le code était pourtant correctement
|
||||
généré côté backend (`<pre><code class="language-python">…`) et `safeHighlight()` appelait
|
||||
bien `hljs.highlightElement()`.
|
||||
|
||||
La cause était ailleurs : les deux feuilles de style de highlight.js (`#hljs-theme-dark`
|
||||
et `#hljs-theme-light`, chargées depuis le CDN dans `index.html`) étaient basculées à
|
||||
partir de la **clé de thème** persistée (`obsigate-theme` = `defaut-obsigate`, …) :
|
||||
|
||||
```js
|
||||
darkSheet.disabled = theme !== "dark"; // "defaut-obsigate" !== "dark" → true
|
||||
lightSheet.disabled = theme !== "light"; // "defaut-obsigate" !== "light" → true
|
||||
```
|
||||
|
||||
Les deux feuilles finissaient donc désactivées, privant tous les tokens de leurs couleurs.
|
||||
Le résultat dépendait de l'ordre de deux initialisations concurrentes — `UI.initTheme()`
|
||||
(clé de thème) au démarrage et `themes.initThemes()` (mode) via `Sync.init()` — d'où un
|
||||
comportement **non déterministe** (parfois coloré, le plus souvent non).
|
||||
|
||||
### Correctif
|
||||
|
||||
- `frontend/js/themes.js` — `applyTheme(themeKey, mode)` bascule désormais les feuilles
|
||||
highlight.js selon le **mode** :
|
||||
|
||||
```js
|
||||
var isDark = mode === 'dark';
|
||||
darkSheet.disabled = !isDark;
|
||||
lightSheet.disabled = isDark;
|
||||
```
|
||||
|
||||
(`sepia` et `high-contrast` réutilisent la palette claire.)
|
||||
- `frontend/js/ui.js` — `initTheme()` lit le **mode** persisté (`obsigate-theme-mode`) et
|
||||
`applyTheme()` résout un mode avant de fixer `data-theme` et de basculer les feuilles,
|
||||
au lieu de comparer la clé de thème à `"dark"`/`"light"`.
|
||||
|
||||
Le basculement est ainsi **déterministe** dès le premier rendu et à chaque changement de
|
||||
mode.
|
||||
|
||||
## #117 — Avatars prédéfinis dans le profil
|
||||
|
||||
### Ce qui a été livré
|
||||
|
||||
- **Galerie de 12 avatars** dans la section `Profil` (`#cfg-profile`), servis depuis
|
||||
`frontend/icons/avatar/` (`/static/icons/avatar/<fichier>.jpg`) : Chat, chien, elephan,
|
||||
hibou, koala, lapin, lion, ours, penda, pingouin, raton, tigre.
|
||||
- Un clic charge l'image, la fait passer par le **même pipeline que l'import** (recadrage
|
||||
carré central, redimensionnement 256 px, export JPEG 0,85) et l'enregistre via
|
||||
`PATCH /api/auth/me` — aucune modification backend n'a été nécessaire, la validation
|
||||
data-URL PNG/JPEG/WebP existante (BUG-113) s'applique telle quelle.
|
||||
- L'avatar actif est **surligné** ; le choix est mémorisé dans `localStorage`
|
||||
(`obsigate-avatar-preset`) et purgé dès qu'on importe une photo personnalisée ou qu'on
|
||||
supprime l'avatar.
|
||||
- L'import d'une photo et la suppression restent disponibles.
|
||||
- i18n : nouvelle clé `config.avatar_presets_label` (FR/EN).
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/frontend/unit.test.mjs` — `syntax highlight theme` (BUG-078) : `themes.applyTheme`
|
||||
bascule les feuilles selon le mode et `ui.js` ne compare plus la clé au mode.
|
||||
- `tests/frontend/toolbar-order.test.mjs` — #115 : `.file-toolbar` dans `viewer.js` et
|
||||
`popout.html`, règle CSS `position: sticky; top: 0`, masquage en mode lecture.
|
||||
- `tests/frontend/settings-order-avatar.test.mjs` — #117 : 12 avatars présents dans
|
||||
`#cfg-profile`, fichiers d'images existants, pipeline `config.js`, règles CSS, clé i18n
|
||||
FR/EN.
|
||||
- Vérification Playwright (instance locale, auth désactivée) : coloration déterministe sur
|
||||
5 chargements successifs d'un `.py` ; `.file-toolbar` dont le `top` ne bouge plus après
|
||||
défilement (épinglage effectif).
|
||||
|
||||
## Limitations
|
||||
|
||||
- L'avatar reste stocké en data-URL 256 px dans `data/users.json` (comportement #113
|
||||
conservé).
|
||||
- Le mode `high-contrast`/`sepia` utilise le thème highlight.js **clair** (pas de palette
|
||||
dédiée).
|
||||
@@ -0,0 +1,72 @@
|
||||
# #155 — Ergonomie tableur : menu contextuel, sélection type Excel
|
||||
|
||||
> **Item de roadmap :** [#155 — Ergonomie tableur (menu contextuel + sélection)](../ROADMAP.md)
|
||||
> **Origine :** retours utilisateur après #154 (refonte UI/UX tableur)
|
||||
> **Statut :** ✅ **livré** — 2026-09-29
|
||||
> **Effort estimé :** 2-4 jours (réalisé en 1 lot)
|
||||
|
||||
---
|
||||
|
||||
## 1. Demande
|
||||
|
||||
1. **Menu contextuel** au clic droit (et **appui long** sur mobile/tactile) dans la grille, sur une
|
||||
cellule, un numéro de ligne ou un en-tête de colonne.
|
||||
2. **Comportement du curseur / de la sélection comme Excel** : le curseur en forme de croix
|
||||
au-dessus des cellules, sélection d'une **plage** (glisser, `Maj+clic`, `Maj+flèches`), clic sur
|
||||
un en-tête de ligne/colonne pour sélectionner toute la ligne/colonne.
|
||||
3. (BUG-094, traité séparément) une **feuille vide/nouvelle** doit rester éditable et permettre
|
||||
d'ajouter lignes/colonnes.
|
||||
|
||||
## 2. Conception
|
||||
|
||||
### Menu contextuel
|
||||
- Positionné au **pointeur** (souris) ou au point de contact (tactile), borné au viewport.
|
||||
- Thème de l'application (classes `.xlsx-context-menu`, réutilise `.btn-action`).
|
||||
- Contenu selon la cible et le mode :
|
||||
- **Insérer** : ligne au-dessus / en dessous, colonne à gauche / à droite (sur la plage
|
||||
sélectionnée, via `PUT …/xlsx/structure`, `count` = taille de la plage).
|
||||
- **Supprimer** : ligne(s) / colonne(s) — confirmation modale (destructif).
|
||||
- **Trier** : A→Z / Z→A sur la colonne de gauche de la sélection.
|
||||
- **Effacer le contenu** : vide les cellules sélectionnées (modification locale, non
|
||||
sauvegardée tant que l'utilisateur n'enregistre pas).
|
||||
- Masqué pour les formats en lecture seule (`.xls`/`.ods`) ; sans les actions de structure pour un
|
||||
`.csv`.
|
||||
- **Appui long** (tactile) : minuteur ~500 ms annulé au déplacement / relâchement.
|
||||
|
||||
### Sélection type Excel
|
||||
- **Clic** : cellule active + sélection simple ; la barre de formule (zone Nom) affiche la plage
|
||||
(`A1` ou `A1:B3`).
|
||||
- **Glisser** : sélection rectangulaire (`mousedown` → `mouseover` sur une autre cellule →
|
||||
`mouseup`) ; le texte de la cellule n'est plus sélectionné pendant le glisser.
|
||||
- **`Maj+clic`** et **`Maj+flèches`** : étendent la sélection depuis l'ancre.
|
||||
- **En-têtes** : clic sur un numéro de ligne / lettre de colonne sélectionne la ligne/colonne
|
||||
entière. Le tri n'est plus déclenché par simple clic sur l'en-tête (il passe au menu contextuel),
|
||||
conformément au comportement Excel.
|
||||
- **Curseur** : `cursor: cell` (croix) sur les cellules, `pointer` sur les en-têtes.
|
||||
|
||||
## 3. Fichiers
|
||||
|
||||
| Couche | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Menu | `frontend/js/xlsx/context-menu.js` (nouveau) | Menu contextuel générique thémé (build + position + dismiss) |
|
||||
| Sélection + câblage | `frontend/js/viewer.js` | État de sélection, drag, `Maj`, en-têtes, ouverture du menu, actions |
|
||||
| Styles | `frontend/style.css` | `.xlsx-selected`, `.xlsx-context-menu`, curseurs |
|
||||
| i18n | `frontend/locales/{fr,en}.json` | Libellés du menu |
|
||||
| Tests | `tests/frontend/xlsx-viewer.test.mjs` | Sélection, menu contextuel, tri via menu |
|
||||
|
||||
## 4. Critères d'acceptation
|
||||
|
||||
- Clic droit sur une cellule ouvre un menu thémé aux coordonnées du pointeur ; `Échap` ou clic
|
||||
ailleurs le ferme.
|
||||
- Insérer/supprimer ligne/colonne via le menu envoie la bonne action `PUT …/xlsx/structure`
|
||||
(avec `count` pour une plage) puis re-rend.
|
||||
- Glisser de A1 vers B2 surligne A1:B2 et la zone Nom affiche `A1:B2`.
|
||||
- `Maj+clic` étend la sélection ; cliquer un en-tête sélectionne la ligne/colonne entière.
|
||||
- Aucune régression des tests #154 (édition, save, undo/redo, dashboard).
|
||||
|
||||
## 5. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Ouverture #155 (menu contextuel + sélection type Excel) suite aux retours utilisateur |
|
||||
| 2026-09-29 | **Livré** : menu contextuel thémé (`frontend/js/xlsx/context-menu.js`) ouvert au clic droit et à l'appui long, actions résultat de `PUT …/xlsx/structure` (insérer/supprimer ligne & colonne avec `count`), tri et effacement ; sélection type Excel (glisser, `Maj+clic`, `Maj+flèches`, en-têtes ligne/colonne, curseur croix, zone Nom affichant la plage) |
|
||||
@@ -0,0 +1,322 @@
|
||||
# #156 — Éditeur Excel : complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
> **Item de roadmap :** [#156 — Éditeur Excel — complétude fonctionnelle](../ROADMAP.md)
|
||||
> **Origine :** audit de complétude demandé le 2026-09-29, après #153 (backlog tableur),
|
||||
> #154 (refonte UI/UX) et #155 (menu contextuel & sélection)
|
||||
> **Statut :** ✅ **livré le 2026-09-30** — P0 ✅ (BUG-096 → BUG-099), P1 ✅ (A5, A6, A7),
|
||||
> P2 ✅ (A8, A9, A10) et P3 ✅ (A11, A12, A13, A14) ; ouverture 2026-09-29 (audit statique,
|
||||
> A1 reproduit en JSDOM)
|
||||
> **Effort estimé :** 15-22 jours (P0 2-3 j · P1 4-6 j · P2 5-7 j · P3 4-6 j) — **réalisé dans
|
||||
> l'enveloppe annoncée**
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, les défauts, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
#153 a rendu l'éditeur XLSX **correct sur le périmètre « grille de valeurs »** (lecture/écriture
|
||||
gardées, styles en lecture, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation, outils IA,
|
||||
tableau de bord). #154/#155 l'ont rendu **convivial** (ruban, inspecteur, undo/redo des cellules,
|
||||
sélection et menu contextuel type Excel).
|
||||
|
||||
Reste l'écart avec ce qu'un utilisateur attend d'un **éditeur tableur** : coller une plage,
|
||||
penser la mise en forme, calculer, sortir le résultat, et se protéger d'un autre écrivain.
|
||||
Cet item recense **4 défauts** (A1 reproduit, A4 théorique) et les **manques fonctionnels**
|
||||
correspondants.
|
||||
|
||||
## 2. Périmètre (couches touchées)
|
||||
|
||||
| Couche | Fichier | Rôle dans cet item |
|
||||
|---|---|---|
|
||||
| Lecture XLSX | `backend/xlsx_reader.py` | Fenêtres de lignes, délimiteur CSV, sonde de perte, métadonnées |
|
||||
| Écriture XLSX | `backend/services/mutations.py` | Cellules, structure, **style (A8)**, coercion, `revision` (A12) |
|
||||
| Endpoints | `backend/routers/files_read.py`, `files_write.py` | `xlsx/sheet`, `xlsx/save`, `xlsx/structure`, **`xlsx/style` (A8)**, `csv/save` |
|
||||
| Outils IA | `backend/tools/spreadsheets.py` | Lecture/édition par l'assistant (`.xlsx`/`.xlsm`/`.csv`, recherche, analyse, structure — A14) |
|
||||
| Visionneuse | `frontend/js/viewer.js` + `frontend/js/xlsx/*` | Presse-papiers, clavier, barre de formule, undo/redo, export |
|
||||
| Styles | `frontend/style.css` | Affordances presse-papiers/mise en forme (variables CSS) |
|
||||
| i18n | `frontend/locales/{fr,en}.json` | Tout libellé nouveau, FR **et** EN |
|
||||
| Tests | `tests/test_xlsx_*.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js` | Non-régression par sous-tâche |
|
||||
|
||||
## 3. Audit
|
||||
|
||||
### 3.1 Défauts recensés (P0) — ✅ corrigés le 2026-09-29
|
||||
|
||||
#### ✅ A1 → BUG-096 — Enregistrer un `.csv` depuis la visionneuse échoue
|
||||
|
||||
`frontend/js/viewer.js` construit l'unique job de sauvegarde ainsi :
|
||||
|
||||
```js
|
||||
const jobs = panelEls.map((panel) => {
|
||||
const cells = {};
|
||||
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
|
||||
return { sheet: sheets[Number(panel.dataset.sheet)].name, cells }; // ← ligne 1757
|
||||
}).filter((job) => Object.keys(job.cells).length);
|
||||
```
|
||||
|
||||
En mode CSV, la réponse de lecture (`backend/routers/files_read.py:548-556`) **ne contient pas
|
||||
`xlsx_sheets`** : `sheets` est donc vide et `sheets[...].name` lève un `TypeError`. Vérifié le
|
||||
2026-09-29 avec le harnais JSDOM (`tests/frontend/xlsx-viewer.test.mjs`) : après édition d'une
|
||||
cellule CSV et clic sur **Enregistrer**, `TypeError: Cannot read properties of undefined
|
||||
(reading 'name')` et **aucun** `PUT /api/file/{vault}/csv/save` n'est émis. Le service backend
|
||||
`save_csv_cells()` et l'endpoint sont pourtant corrects : le défaut est purement côté visionneuse.
|
||||
|
||||
*Cause racine :* le mode CSV réutilise un panneau unique (`data-sheet="0"`) sans feuille
|
||||
correspondante dans `sheets` ; le chemin d'enregistrement n'a pas été adapté comme l'ont été les
|
||||
onglets, la structure et le tableau de bord (qui, eux, testent `isCsv`).
|
||||
|
||||
**Correctif (2026-09-29) :** le nom de feuille est résolu avec repli
|
||||
(`sheets[idx]?.name || sheets[0]?.name || data.title || data.path`). Test JSDOM : un CSV monté
|
||||
**sans** `xlsx_sheets` émet un unique `PUT …/csv/save`.
|
||||
|
||||
#### ✅ A2 → BUG-097 — `.xlsm` éditable : « Charger la suite » renvoie 415 au-delà de 500 lignes
|
||||
|
||||
Un `.xlsm` est servi **éditable** (`backend/routers/files_read.py:491-512`, pas de
|
||||
`xlsx_readonly`) : la visionneuse câble donc le chargement paresseux. Mais
|
||||
`GET /api/file/{vault}/xlsx/sheet` refuse **tout ce qui n'est pas `.xlsx`** (`files_read.py:272`,
|
||||
**415**). Une feuille `.xlsm` de plus de 500 lignes affiche donc le pied « Charger la suite » dont
|
||||
chaque clic échoue (toast d'erreur), alors que la limitation n'est pas annoncée comme telle pour
|
||||
ce format.
|
||||
|
||||
**Correctif (2026-09-29) :** `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (les autres
|
||||
formats restent en 415). Tests : fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`.
|
||||
|
||||
#### ✅ A3 → BUG-098 — Délimiteur CSV figé : un `.csv` français s'ouvre en une seule colonne
|
||||
|
||||
`render_csv_table(raw)` utilise le délimiteur par défaut `,` (`backend/xlsx_reader.py:784`),
|
||||
appelé sans argument (`backend/routers/files_read.py:551`) — tandis que l'**export** CSV de la
|
||||
visionneuse écrit du `;` et un BOM UTF-8. Un CSV produit par Excel/Sheets en locale française
|
||||
(`;`) s'affiche donc en une colonne. Le même `,` est utilisé par `save_csv_cells()` pour re-parser
|
||||
et réécrire le fichier : l'édition peut re-sérialiser dans un format différent de la source.
|
||||
|
||||
**Correctif (2026-09-29) :** `sniff_csv_delimiter()` (Sniffer, repli par décompte sur la
|
||||
première ligne, puis `,`) est partagé par le rendu et la réécriture — `save_csv_cells()`
|
||||
réutilise le délimiteur détecté. Tests : lecture, écriture et champs entre guillemets contenant
|
||||
le séparateur.
|
||||
|
||||
#### ✅ A4 → BUG-099 — Sonde de perte plafonnée à 8 Mo : perte silencieuse possible
|
||||
|
||||
`inspect_workbook()` détecte les valeurs calculées en cache via un budget global
|
||||
`_MAX_PROBE_BYTES = 8_000_000` (`backend/xlsx_reader.py:69`) partagé entre toutes les feuilles
|
||||
(`_has_cached_formulas`). Sur un classeur dont le XML des feuilles dépasse ce budget **avant** la
|
||||
première formule cachée, `xlsx_lossy_features` ne contient pas `cached_values` : le `409`
|
||||
`xlsx_lossy_content` n'est pas déclenché et l'enregistrement détruit ces valeurs **sans
|
||||
avertissement** — exactement le risque n°1 de #153. À ce stade, risque **théorique** (non
|
||||
reproduit).
|
||||
|
||||
**Correctif (2026-09-29) :** budget **par feuille** (4 Mo) + plafond global (32 Mo) ;
|
||||
`_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute
|
||||
`cached_values_unverified` (libellé i18n FR/EN) : la sauvegarde demande une confirmation au lieu
|
||||
de passer en silence. Trucs de test : budgets monkeypatchés. Contre-preuve : budget épuisé →
|
||||
signalé au lieu de `[]` (comportement antérieur).
|
||||
|
||||
### 3.2 Fonctions manquantes (écart vs Excel / Google Sheets)
|
||||
|
||||
**Presse-papiers et sélection** — ✅ traité en P1 (A5)
|
||||
|
||||
- ✅ Coller une **plage** : le handler forcait tout sur une ligne
|
||||
(`viewer.js` : `replace(/\r?\n/g, " ")`) — coller un bloc TSV/CSV depuis Excel écrasait une seule
|
||||
cellule. Le collage découpe désormais le bloc (`parseMatrix`) et remplit la plage à partir de la
|
||||
cellule active (`fillFrom`), 1×1 restant un remplacement de cellule.
|
||||
- ✅ Copier / Couper / Coller de plage côté application : `Ctrl+C`/`Ctrl+X`/`Ctrl+V` (événements
|
||||
`copy`/`cut`/`paste`) **et** entrées du menu contextuel ; une sélection de texte dans la cellule
|
||||
éditée garde le comportement natif.
|
||||
- Toujours absents : poignée de recopie (fill), multi-sélection `Ctrl+clic`, glisser-déposer de
|
||||
lignes/colonnes, copie d'une image/du HTML.
|
||||
|
||||
**Clavier / navigation** — ✅ traité en P1 (A6, A7)
|
||||
|
||||
- ✅ `Ctrl+S`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Ctrl+A`
|
||||
sont câblés dans un handler unique de la grille ; une cellule reste en mode « navigation »
|
||||
jusqu'à la saisie (ou `F2`), sinon `Suppr`/`Home`/`End` voleraient une touche du curseur.
|
||||
- ✅ `Maj+Entrée` insère un saut de ligne **dans** la cellule (texte uniquement).
|
||||
- ✅ La **zone Nom** est un champ éditable (« Atteindre ») : `B12`, `A1:B3`, `Feuille!A1`.
|
||||
- ✅ Aide à la saisie : la barre de formule propose les noms de fonctions courants (`<datalist>`,
|
||||
liste localisée FR/EN). L'autocomplétion contextuelle reste à faire si un moteur de formules
|
||||
arrive (A9).
|
||||
|
||||
**Mise en forme en écriture** — ✅ traitée (A8, 2026-09-30)
|
||||
|
||||
- ✅ Un bouton **Mise en forme** (`#xlsx-format-btn`, classeurs éditables non-CSV) agit sur la
|
||||
**sélection** via `PUT …/xlsx/style` : gras / italique / souligné / effacer, alignement
|
||||
gauche-centré-droite, couleur de police et de fond (`<input type="color">`, donc aucune
|
||||
palette codée en dur), formats de nombre (général, nombre, pourcentage, devise, date, texte),
|
||||
fusion/défusion, volets figés/libérés, largeur de colonne et hauteur de ligne. La fusion exige
|
||||
une vraie plage (`xlsx.format_merge_needs_range`).
|
||||
- Écriture sur le modèle de `xlsx/save` / `xlsx/structure` : verrou par fichier, backup, écriture
|
||||
atomique (`.tmp` + `os.replace`), garde de perte (refus sans `force`), `If-Match` (A12), garde
|
||||
anti-formule de BUG-088, plafond `MAX_STYLE_CELLS = 10 000`, invalidation du cache méta (A13).
|
||||
- Toujours **hors périmètre** : commentaires, liens hypertexte, validation de données, mise en
|
||||
forme conditionnelle, protection de cellules (lecture seule), bordures et palette Excel.
|
||||
|
||||
**Calcul** — ✅ décision documentée (A9, 2026-09-30)
|
||||
|
||||
- Aucun moteur de formule n'est livré : ObsiGate lit et écrit les formules telles qu'Excel les a
|
||||
enregistrées, sans recalcul (pas de références inter-feuilles, pas d'agrégats). Le choix est
|
||||
**annoncé à l'utilisateur** : un enregistrement contenant une valeur commençant par `=` ou `@`
|
||||
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé ; la garde
|
||||
anti-DDE de BUG-088 reste en place.
|
||||
|
||||
**Sortie et recherche** — ✅ traité (A11, 2026-09-30)
|
||||
|
||||
- ✅ Export de la **sélection** (CSV, nom `Feuille-A1B2.csv`) quand une plage de plusieurs cellules
|
||||
est active, sinon la feuille entière ; l'écart doc/code de #153-A13 est tranché dans ce sens.
|
||||
- ✅ Menu **Exporter** : Markdown (tableau), HTML (document autonome) et **impression** (iframe
|
||||
hors écran, cadre précédent détruit). Les quatre formats suivent le **contenu affiché**.
|
||||
- ✅ La recherche (`Ctrl+F`) parcourt **toutes les feuilles** : compteur `n/m · k feuilles` et
|
||||
activation automatique de l'onglet contenant la correspondance.
|
||||
|
||||
**Robustesse / multi-postes** — ✅ traitée (A10, A12, 2026-09-30)
|
||||
|
||||
- ✅ Concurrence optimiste : `revision` (`mtime_ns` + taille) renvoyée à la lecture et acceptée en
|
||||
`If-Match` sur `xlsx/save`, `xlsx/structure`, `xlsx/style` et `csv/save` ; un écrivain externe
|
||||
détecté donne un **409** `conflict` (`details.reason = "stale_revision"`) au lieu d'un
|
||||
écrasement, et le bandeau « Réessayer » **relit** le fichier avant de rejouer.
|
||||
- ✅ Piles undo/redo **unifiées et conservées au re-rendu** (`_xlsxHistory` par fichier) : édition,
|
||||
effacement, tri/filtre et structure ; action destructive (suppression) non annulable faute
|
||||
d'inverse, et lecture du filtre **coalescée** (une entrée par session de frappe).
|
||||
- Le tri/filtre restent **d'affichage** : après un tri, un enregistrement ne persiste pas l'ordre
|
||||
(seules les cellules sales sont écrites) — le libellé `xlsx.sort_applied` le dit.
|
||||
|
||||
**Outils IA** — ✅ traités (A14, 2026-09-30)
|
||||
|
||||
- ✅ `_spreadsheet_path()` accepte `.xlsx`, `.xlsm` (macros préservées) et `.csv` (délimiteur
|
||||
conservé) ; `update_xlsx_cells` délègue à `save_csv_cells` pour un CSV.
|
||||
- ✅ Nouveaux outils : `search_workbook` (recherche multi-feuilles, comptée par feuille),
|
||||
`analyze_range` (nombre, somme, moyenne, min, max d'une plage A1), `edit_xlsx_structure`
|
||||
(feuilles, lignes, colonnes — risque WRITE, **confirmation conservée**).
|
||||
- Bornes explicites : `MAX_SCAN_ROWS/MAX_SCAN_COLS`, `MAX_SEARCH_RESULTS = 100`,
|
||||
`MAX_RANGE_VALUES = 200`, `MAX_RANGE_CELLS = 10 000`.
|
||||
|
||||
### 3.3 Limites techniques relevées
|
||||
|
||||
- `MAX_COLS = 40` sans pagination de colonnes : le chargement paresseux ne concerne que les
|
||||
lignes (`read_sheet_window`), l'axe des colonnes reste tronqué.
|
||||
- Coût de lecture : `render_sheets()` charge le classeur pour les formules, une seconde fois si
|
||||
des valeurs cachées existent, puis `read_workbook_meta()` (troisième chargement, scan
|
||||
`500 × 40` par feuille). ✅ **A13** : `read_workbook_meta()` est mémoïsé par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8) et invalidé à chaque écriture — le reload complet à chaque
|
||||
fenêtre demandée a disparu ; `render_sheets()` reste hors cache (il doit relire les valeurs).
|
||||
- `allow_formula` (bouton `f(x)`) reste actif pour toute la session une fois activé : rien ne le
|
||||
remet à zéro après un enregistrement.
|
||||
- `role="grid"` posé (#154-A4) mais sans `aria-rowcount`/`aria-colcount`/`aria-selected`/
|
||||
`aria-activedescendant` ni annonce des changements de cellule active.
|
||||
|
||||
## 4. Backlog #156 — sous-tâches
|
||||
|
||||
Légende : 🔴 P0 (défaut) · 🟡 P1 (productivité immédiate) · 🟢 P2 (fidélité/finitions) ·
|
||||
effort en jours-homme (développement + tests).
|
||||
|
||||
### P0 — défauts (2-3 j)
|
||||
|
||||
- [x] **A1 — Sauvegarde `.csv` depuis la visionneuse (BUG-096).** Nom de feuille résolu avec
|
||||
repli (feuille → `data.title`/`data.path`), le chemin d'enregistrement ne dépend plus de la
|
||||
structure du classeur. *Vérifié :* test JSDOM « saving an edited csv PUTs /csv/save
|
||||
(payload without xlsx_sheets) » (contre-preuve : échouait avec le `TypeError`).
|
||||
- [x] **A2 — Chargement paresseux des `.xlsm` (BUG-097).** `GET …/xlsx/sheet` accepte `.xlsx`
|
||||
**et** `.xlsm` ; les autres formats restent refusés (415). *Vérifié :*
|
||||
`test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats`.
|
||||
- [x] **A3 — Détection du délimiteur CSV (BUG-098).** `sniff_csv_delimiter()` partagé par la
|
||||
lecture et la réécriture, qui conserve le délimiteur détecté. *Vérifié :* `TestCsvDelimiter`
|
||||
(3 séparateurs + repli) et 3 tests client (lecture, écriture, guillemets).
|
||||
- [x] **A4 — Sonde de perte par feuille (BUG-099).** Budget par feuille + plafond global ;
|
||||
budget épuisé → clé `cached_values_unverified` (i18n FR/EN) au lieu d'un silence. *Vérifié :*
|
||||
`TestXlsxCachedValueProbe` (3), dont la contre-preuve du silence antérieur.
|
||||
|
||||
### P1 — presse-papiers & clavier (4-6 j)- [x] **A5 — Presse-papiers de plage (livré le 2026-09-29).** Copier / couper / coller un bloc
|
||||
(presse-papiers interne TSV, miroir système best-effort) via `Ctrl+C`/`Ctrl+X`/`Ctrl+V` et les
|
||||
entrées du menu contextuel ; remplissage multi-cellules depuis la cellule active, bloc laissé
|
||||
sélectionné, collage **texte** uniquement (R4) et annulable cellule par cellule ; un bloc plus
|
||||
grand que la grille rendue est tronqué et signalé (`xlsx.paste_out_of_grid`). Couper efface la
|
||||
source au collage, sauf en collage sur place.
|
||||
*Vérifié :* JSDOM — `A1:B2` copié puis collé en `D5` donne `D5:E6`, bloc TSV Excel + retour
|
||||
ligne final, 1×1 qui remplace la cellule, coupe → collage, menu contextuel, débordement
|
||||
signalé, `E2E` (Playwright : copie/collage par le menu contextuel sur la fixture 520 lignes).
|
||||
- [x] **A6 — Clavier complet (livré le 2026-09-29).** `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`,
|
||||
`Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` ; undo/redo déplacés
|
||||
dans le même handler priorisé et `Suppr` passe par `clearRange()` (donc **annulable**).
|
||||
*Vérifié :* JSDOM — un test par raccourci, dont la contre-épreuve « `Suppr` pendant la saisie
|
||||
supprime un caractère, pas la sélection » ; `E2E` (Ctrl+A, Ctrl+Home/End, Suppr).
|
||||
- [x] **A7 — Zone Nom éditable & aide à la saisie (livré le 2026-09-29).** La zone Nom est un
|
||||
`<input>` (`#xlsx-active-cell`) : `B12`, `A1:B3`, `$A$1`, `Feuille!A1` (changement d'onglet) ;
|
||||
référence inconnue → message `xlsx.name_box_invalid` et adresse précédente restaurée. Le champ
|
||||
de formule porte la liste des fonctions (`<datalist id="xlsx-function-list">`, `xlsx.fn_suggestions`).
|
||||
*Vérifié :* JSDOM (atteindre, plage normalisée, refus d'une référence inconnue, bascule
|
||||
d'onglet, liste de fonctions) ; `E2E` (zone Nom → `B12`).
|
||||
|
||||
### P2 — mise en forme, calcul, undo (5-7 j)
|
||||
|
||||
- [x] **A8 — Mise en forme en écriture (livré le 2026-09-30).** Bouton **Mise en forme** :
|
||||
gras/italique/souligné/effacer, alignements, couleurs de police et de fond (sélecteur natif),
|
||||
formats de nombre (général/nombre/pourcentage/devise/date/texte), fusion/défusion, volets
|
||||
figés/libérés, largeur de colonne et hauteur de ligne, sur la sélection courante. Nouvelle route
|
||||
`PUT …/xlsx/style` (`mutate_xlsx_style`) sur le modèle de `PUT …/xlsx/structure` : verrou,
|
||||
`.tmp`/`os.replace`, backup, garde de perte (`force`), `If-Match`, garde anti-formule BUG-088,
|
||||
plafond `MAX_STYLE_CELLS`. i18n FR/EN. *Vérifié :* `tests/test_xlsx_styles.py` (17 : rechargement,
|
||||
fusions/tailles/volets, effacement, entrées invalides, CSV refusé, `if_match`, backup) et JSDOM
|
||||
(application d'un format, refus d'un CSV, garde de perte).
|
||||
- [x] **A9 — Calcul : décision documentée (livré le 2026-09-30).** Pas de moteur de formule ; le
|
||||
gap est **annoncé dans l'UI** (toast « enregistrée comme texte » à l'enregistrement si une valeur
|
||||
commence par `=` ou `@` sans le bouton `f(x)`), la garde anti-DDE reste. *Vérifié :* JSDOM
|
||||
(l'avertissement apparaît, disparaît une fois `f(x)` activé).
|
||||
- [x] **A10 — Undo/redo unifié (livré le 2026-09-30).** Entrées typées `cell|order|hidden|structure`,
|
||||
piles **par fichier** (`_xlsxHistory`, 8 fichiers) conservées au re-rendu, filtre coalescé,
|
||||
rejeu de structure silencieux, action destructive non annulable. *Vérifié :* JSDOM (édition →
|
||||
effacement → tri annulés successivement, insertion de ligne rejouée, filtre annulé en un `Ctrl+Z`).
|
||||
|
||||
### P3 — sortie, robustesse, performances (4-6 j)
|
||||
|
||||
- [x] **A11 — Sortie & recherche (livré le 2026-09-30).** Bouton **Exporter** : CSV de la
|
||||
**sélection** (sinon de la feuille, nom `Feuille-A1B2.csv`), Markdown, HTML et impression
|
||||
(iframe hors écran) ; écart #153-A13 tranché. `runFind` parcourt **toutes** les feuilles avec
|
||||
compteur `n/m · k feuilles` et activation de l'onglet correspondant. *Vérifié :* JSDOM
|
||||
(sélection multi-cellules, feuille entière, 4 formats, compteur multi-feuilles) et E2E.
|
||||
- [x] **A12 — Concurrence optimiste (livré le 2026-09-30).** `revision` (`mtime_ns` + taille)
|
||||
renvoyée à la lecture (`xlsx_revision`) et acceptée en `If-Match`/`if_match` sur `xlsx/save`,
|
||||
`xlsx/structure`, `xlsx/style` et `csv/save` ; unconflict produit un **409** `conflict`
|
||||
(`details.reason = "stale_revision"`) et le bandeau « Réessayer » relit avant de rejouer.
|
||||
*Vérifié :* `TestOptimisticConcurrency` (6) + JSDOM (version rafraîchie par la réponse, relecture
|
||||
avant le retry).
|
||||
- [x] **A13 — Performances & plafonds (livré le 2026-09-30).** Cache de `read_workbook_meta()` par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8), invalidé à chaque écriture : charger une fenêtre ne
|
||||
rescanne plus le classeur. *Hors périmètre :* la pagination de l'axe des colonnes au-delà de
|
||||
`MAX_COLS = 40` (documenté dans les limites du guide). *Vérifié :* `TestMetaCache` (4).
|
||||
- [x] **A14 — Outils IA étendus (livré le 2026-09-30).** `SPREADSHEET_EXTENSIONS` =
|
||||
`.xlsx`/`.xlsm`/`.csv` (`_spreadsheet_path`, `_read_grid`), `update_xlsx_cells`/`append_xlsx_rows`
|
||||
adaptés, nouveaux outils `search_workbook` (READ), `analyze_range` (READ) et `edit_xlsx_structure`
|
||||
(WRITE, **confirmation conservée**, refus d'un CSV). *Vérifié :*
|
||||
`tests/test_spreadsheet_tools.py` (34, dont `TestXlsmSupport`, `TestCsvSupport`,
|
||||
`TestSearchWorkbook`, `TestAnalyzeRange`, `TestEditXlsxStructure`).
|
||||
|
||||
## 5. Risques et sécurité
|
||||
|
||||
| # | Risque | Où | Traitement |
|
||||
|---|---|---|---|
|
||||
| R1 | Perte silencieuse de valeurs calculées (sonde plafonnée) | `xlsx_reader.inspect_workbook` | ✅ A4 — budget par feuille + clé `cached_values_unverified` (BUG-099) |
|
||||
| R2 | Écrasement par un écrivain externe | `mutations`, routes write | ✅ A12 — `If-Match` + **409** `conflict`, retry qui relit |
|
||||
| R3 | Réécriture CSV dans un délimiteur différent de la source | `xlsx_reader`, `mutations.save_csv_cells` | ✅ A3 — `sniff_csv_delimiter` partagé (BUG-098) |
|
||||
| R4 | Collage d'un bloc : ne jamais injecter de HTML/`contenteditable` brut | `viewer.js` | ✅ A5 — insertion **texte** uniquement, échappée |
|
||||
| R5 | Mise en forme en écriture : garder la garde anti-formule (BUG-088) et la garde de perte (BUG-085) | `mutations` | ✅ A8 — `mutate_xlsx_style` réutilise verrou, backup, swap atomique, garde de perte et `If-Match` |
|
||||
|
||||
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
||||
|
||||
- Chaque sous-tâche démarre par son **ID stable** (`#156-A<n>`) ; un **défaut** est ouvert comme
|
||||
`BUG-NNN` dans `docs/ISSUES_TODOLIST.md` (A1→BUG-096, A2→BUG-097, A3→BUG-098, A4→BUG-099).
|
||||
- Tout correctif arrive avec son **test de non-régression** (contre-preuve quand c'est possible).
|
||||
- Frontend : `vanilla JS`, **zéro build**, `safeCreateIcons()`, **variables CSS**, **i18n FR+EN**
|
||||
(`test_i18n_parity.py` vert).
|
||||
- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans
|
||||
`backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`.
|
||||
- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée + index), cette fiche,
|
||||
guide utilisateur i18n + README si impact utilisateur.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Audit de complétude de l'éditeur Excel → ouverture de #156 et de **BUG-096 → BUG-099** ; défaut A1 reproduit en JSDOM (`TypeError` sur l'enregistrement d'un CSV, aucun `PUT …/csv/save` émis) |
|
||||
| 2026-09-29 | **P1 livré** — A5 (presse-papiers de plage), A6 (clavier complet) et A7 (zone Nom éditable + aide à la saisie) : presse-papiers interne TSV (miroir système best-effort), collage texte/annulable, `Suppr`/`F2`/`Ctrl+flèches`/`PgUp-PgDn`/`Ctrl+A`/`Maj+Entrée`, zone Nom `<input>` et liste de fonctions i18n FR/EN. Tests : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux) + E2E Playwright |
|
||||
| 2026-09-30 | **P2 livré** — A8 (mise en forme en écriture : bouton Mise en forme, `PUT …/xlsx/style`, verrou/backup/garde de perte/`If-Match`), A9 (décision « pas de moteur de formule », annoncée dans l'UI), A10 (undo/redo unifié, piles conservées au re-rendu). Tests : `test_xlsx_styles.py` 17 + JSDOM `xlsx-viewer.test.mjs` |
|
||||
| 2026-09-30 | **P3 livré** — A11 (export sélection / Markdown / HTML / impression, recherche multi-feuilles), A12 (`If-Match` + 409 réparable), A13 (cache des métadonnées par `mtime`), A14 (outils IA `.xlsm`/`.csv`, recherche, analyse de plage, structure). Tests : `test_spreadsheet_tools.py` 34, `test_xlsx_viewer.py` (`TestOptimisticConcurrency`, `TestMetaCache`), JSDOM |
|
||||
| 2026-09-30 | **Backlog #156 clôturé** — P0 → P3 livrés (A1 → A14, dont 4 défauts **BUG-096 → BUG-099**). Suite complète : `pytest tests/` 1525 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 107/107, E2E `xlsx` 10/10 |
|
||||
| 2026-09-29 | **P0 livré** — A1-A4 corrigés et testés (BUG-096 → BUG-099) : sauvegarde `.csv` depuis la visionneuse, fenêtres `.xlsm`, délimiteur CSV détecté/réutilisé, sonde de perte par feuille + signal `cached_values_unverified`. Suite complète : 1490 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 61/61 |
|
||||
@@ -0,0 +1,149 @@
|
||||
# #154 — Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
|
||||
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
|
||||
> **Statut :** ✅ **terminé** — Lots 1 → 5 livrés le 2026-09-29 (A1-A5)
|
||||
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5 ✅)
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Rendre la vue tableur d'ObsiGate **intuitive, moderne et hautement utilisable** en s'inspirant
|
||||
des standards du marché (Excel, Google Sheets, Airtable), **sans renier les contraintes du
|
||||
dépôt** : thème sombre, `vanilla JS`, **zéro framework, zéro build npm**
|
||||
([`AGENTS.md`](../../AGENTS.md)). La refonte est **organique** : on améliore la coquille
|
||||
existante (`frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css`), on ne réécrit pas
|
||||
la grille ni le backend.
|
||||
|
||||
## 2. Audit UX — les 3 problèmes majeurs
|
||||
|
||||
| # | Problème | Constat | Résolution |
|
||||
|---|---|---|---|
|
||||
| **P1** | **Aucune hiérarchie ni regroupement des commandes** | Rangée plate de boutons de poids identique (`viewer.js` toolbar historique) ; « Tableau de bord » *prependé* au runtime ; barre de formule réduite à un `input`. | **Barre de commandes groupée** (Formules · Insertion · Vue · Fichier), bouton **Enregistrer primaire**, état *dirty*. |
|
||||
| **P2** | **Grille sans affordances : en-têtes = cellules** | Contraste faible entre `th` et `td`, pas de zébrage, pas de survol lisible, cellule active peu marquée. | **Tokens de grille** + en-têtes plus clairs/interactifs, zébrage, survol, cellule active en bordure accent. |
|
||||
| **P3** | **États avancés traités comme du contenu** | Dashboard *inline* qui pousse la grille, troncature/lecture seule/formules non recalculées sans emplacement dédié, `confirm()`/`prompt()` natifs. | **Couche UI dédiée** : bandeaux d'état + **inspecteur droit** (Lot 3) + dialogues thémés (Lot 2). |
|
||||
|
||||
## 3. Architecture cible de l'écran
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ BARRE APP (globale, existante) │
|
||||
├─────────────┬────────────────────────────────────────────────────────────┤
|
||||
│ │ A. RUBAN — groupes Formules · Insertion · Vue · Fichier │
|
||||
│ EXPLORATEUR│ B. BARRE DE FORMULE — [ A1 ] fx [ … ] │
|
||||
│ DE FICHIERS│ C. BANDEAUX D'ÉTAT — lecture seule · formules non recalculées│
|
||||
│ (sidebar) ├──────────────────────────────────────────────┬─────────────┤
|
||||
│ │ D. GRILLE (en-têtes clairs, zébrage, survol) │ E. INSPECTEUR│
|
||||
│ │ │ (dashboard + │
|
||||
│ │ │ IA, repliable)│
|
||||
│ ├───────────────────────────────────────────────┤ │
|
||||
│ │ F. ONGLETS FEUILLES + « + » · 500/522 │ │
|
||||
└─────────────┴───────────────────────────────────────────────┴─────────────┘
|
||||
```
|
||||
|
||||
- **A. Ruban** : groupes d'actions avec séparateurs ; actions de style désactivées (styles lus,
|
||||
pas écrits). Bouton **Enregistrer** en accent, désactivé si rien de *dirty*.
|
||||
- **B. Barre de formule** : zone nom + champ + badge de session `f(x)`.
|
||||
- **C. Bandeaux d'état** : empilables, non bloquants ; portent lecture seule et
|
||||
« formules non recalculées ».
|
||||
- **D. Grille** : rendue côté serveur (`backend/xlsx_reader.py`), habillée et câblée par le front.
|
||||
- **E. Inspecteur** : **à venir (Lot 3)** — Tableau de bord + Assistant IA dans un panneau droit
|
||||
repliable (réutilise `PaneManager` pour le détachement), au lieu du dashboard *inline* actuel.
|
||||
- **F. Onglets feuilles** : permanents (même à une seule feuille) + bouton « + ».
|
||||
|
||||
## 4. Plan par lots (incréments livrables)
|
||||
|
||||
### Lot 1 — Coquille : ruban groupé, onglets permanents, badges d'état ✅ *(2026-09-29)*
|
||||
|
||||
- **A1.1** Barre de commandes groupée (`.xlsx-cmdbar`, `.xlsx-cmd-group`, `.xlsx-cmd-sep`,
|
||||
`.xlsx-save-primary`), IDs existants conservés (compatibilité tests JSDOM/E2E).
|
||||
- **A1.2** Onglets de feuilles **toujours rendus** (non-CSV) + bouton **`+`** `.xlsx-tab-add`
|
||||
→ `sheet_add` (même pipeline `putStructure`).
|
||||
- **A1.3** Badges d'état : `.xlsx-status-pill` **lecture seule** (`.xls`/`.ods`) et
|
||||
**formules non recalculées** (non-CSV).
|
||||
- **A1.4** Tokens de grille (`--grid-bg`, `--grid-header-bg`, `--grid-header-text`,
|
||||
`--grid-border`, `--grid-zebra`) déclinés dark/light + affordances (en-têtes clairs,
|
||||
zébrage, survol, cellule active solide, cellule *dirty* prioritaire au survol).
|
||||
|
||||
### Lot 2 — Dialogues thémés & feedback ✅ *(2026-09-29)*
|
||||
|
||||
- **A2.1** Helpers génériques **`showConfirm()` / `showPrompt()`** (`frontend/js/ui.js`), promise-based,
|
||||
réutilisant les classes `.obsigate-modal-*` (fini `window.confirm()` / `window.prompt()`).
|
||||
- **A2.2** La visionneuse XLSX utilise ces dialogues pour les actions de structure (ajouter /
|
||||
renommer / dupliquer / supprimer feuille, insérer / supprimer ligne et colonne) et pour la
|
||||
confirmation de perte (409 `xlsx_lossy_content`).
|
||||
- **A2.3** **Conflit de sauvegarde (409 `conflict`)** : bandeau **non bloquant** `.xlsx-banner-conflict`
|
||||
avec bouton **Réessayer** — les modifications sont conservées.
|
||||
- **A2.4** Indicateur *dirty* sur le bouton **Enregistrer** et sur l'onglet de la feuille concernée.
|
||||
|
||||
### Lot 3 — Inspecteur droit ✅ *(2026-09-29)*
|
||||
|
||||
- **A3.1** Le **Tableau de bord** quitte le flux de la grille pour un **panneau droit repliable**
|
||||
(`.xlsx-inspector`) : la grille reste visible à côté (`.xlsx-body` = `.xlsx-main` + inspecteur).
|
||||
- **A3.2** En-tête d'inspecteur : titre, bouton **Assistant IA** (ouvre le panneau latéral global
|
||||
existant) et bouton de fermeture.
|
||||
- **A3.3** Responsive : sous 900 px, l'inspecteur passe sous la grille.
|
||||
|
||||
### Lot 4 — Interactions : undo/redo, défilement, accessibilité ✅ *(2026-09-29)*
|
||||
|
||||
- **A4.1** **Undo/redo** local (pile de commandes) pour les éditions de cellules : boutons
|
||||
**Annuler / Rétablir** dans le ruban + raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`.
|
||||
- **A4.2** Chargement des fenêtres via **`IntersectionObserver`** (repli sur l'écouteur de
|
||||
défilement pour les environnements sans IO).
|
||||
- **A4.3** Sémantique **ARIA** : `role="grid"` / `row` / `gridcell` / `columnheader` / `rowheader`.
|
||||
|
||||
### Lot 5 — Découpage modulaire & finitions ✅ *(2026-09-29)*
|
||||
|
||||
- **A5.1** Extraction des parties pures/sans état du monolithe `renderXlsxViewer` dans
|
||||
`frontend/js/xlsx/` : **`refs.js`** (`parseRef`, `columnName`, `findTd`, `sheetOfRef`,
|
||||
`firstCellOfRange`), **`command-bar.js`** (`buildCommandBar` : onglets + ruban + pastilles),
|
||||
**`dashboard.js`** (`renderDashboardLoading`, `renderDashboardHtml`). Le noyau **avec état**
|
||||
(orchestration DOM, édition, écouteurs) reste dans `viewer.js` : l'extraction est volontairement
|
||||
limitée aux unités sans état, à comportement constant et sous couvert des tests.
|
||||
- **A5.2** Lien **dashboard → grille** : cliquer une plage nommée sélectionne et révèle sa
|
||||
première cellule (change d'onglet si la plage est sur une autre feuille).
|
||||
- **A5.3** Inspecteur **redimensionnable** (poignée gauche, largeur 260–640 px, restaurée par
|
||||
session via `localStorage`).
|
||||
- **A5.4** `SW_VERSION` incrémenté (`v28`) et nouveaux modules ajoutés au pré-cache du service
|
||||
worker.
|
||||
|
||||
> **Hors périmètre (documenté) :** le détachement de l'inspecteur en split view
|
||||
> (`PaneManager.splitRight()`) n'est pas retenu — l'inspecteur est intrinsèquement lié à la
|
||||
> visionneuse d'un document ; un split générique ouvrirait un second contexte sans le classeur.
|
||||
> À réévaluer si un usage concret apparaît.
|
||||
|
||||
## 5. Recommandations techniques (contrainte « zéro build »)
|
||||
|
||||
| Option Data Grid | Build | Licence | Verdict |
|
||||
|---|---|---|---|
|
||||
| AG Grid Community | npm + bundler | MIT | ❌ viole « zéro build », réécrit le DOM, casse les tests |
|
||||
| Handsontable | npm + bundler | **commerciale** | ❌ licence non libre |
|
||||
| TanStack Table | headless (importable esm.sh) | MIT | ⚠ possible sans build, mais *headless* → gain limité |
|
||||
| **Grille maison sur `<table>`** | aucun | — | ✅ **recommandé** (conserve DOM, CSP, i18n, tests) |
|
||||
|
||||
- **Performance** : ne pas ré-écrire tout le DOM ; réutiliser le pipeline `appendWindow` ;
|
||||
`content-visibility:auto; contain:strict` sur les lignes ; garder la pagination serveur
|
||||
(500 × 40 = 20 000 cellules/feuille) plutôt qu'une virtualisation client complexe.
|
||||
- **CSP** : `main.py` autorise déjà `esm.sh` — une lib *headless* reste possible en Lot 4 si
|
||||
un vrai besoin de modèle de colonnes apparaît.
|
||||
|
||||
## 6. Critères d'acceptation (par lot)
|
||||
|
||||
- **Lot 1** : une feuille unique affiche son onglet + « + » ; « + » ajoute une feuille via
|
||||
`PUT …/xlsx/structure` et re-rend ; `.xls`/`.ods` montrent le badge « lecture seule » (pas de
|
||||
« + », pas de structure, pas de dashboard) ; un `.xlsx` montre le badge « formules non
|
||||
recalculées », un `.csv` non ; les tests JSDOM/E2E existants restent verts + nouveaux tests.
|
||||
- Lots suivants : définis à leur ouverture.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) |
|
||||
| 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) |
|
||||
| 2026-09-29 | **Lot 3** livré : le Tableau de bord passe dans un **inspecteur droit repliable** (grille toujours visible), en-tête d'inspecteur avec entrée **Assistant IA** et fermeture, responsive < 900 px |
|
||||
| 2026-09-29 | **Lot 4** livré : **undo/redo** (boutons + `Ctrl+Z`/`Ctrl+Maj+Z`/`Ctrl+Y`), chargement par `IntersectionObserver`, **ARIA** `role="grid"` ; le découpage modulaire est reporté en A5 |
|
||||
| 2026-09-29 | **Lot 5** livré (clôture #154) : extraction des unités sans état dans `frontend/js/xlsx/*` (`refs.js`, `command-bar.js`, `dashboard.js`), lien **dashboard → grille**, inspecteur **redimensionnable**, `SW_VERSION` v28 + pré-cache. Split view écarté (documenté) |
|
||||
@@ -0,0 +1,260 @@
|
||||
# #153 — Visionneuse & édition XLSX — état des lieux et backlog
|
||||
|
||||
> **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md)
|
||||
> **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir
|
||||
> [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md))
|
||||
> **Statut :** ✅ **Backlog terminé et livré le 2026-09-28** — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis A6→A17 en v2.33.0 → v2.39.0 (A11 étant au CI depuis A5/A8)
|
||||
> **Effort estimé :** 8-13 jours au total (P0 ✅ 2-3 j · P1 4-6 j · P2 2-4 j)
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Périmètre et architecture
|
||||
|
||||
| Couche | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Lecture | `backend/xlsx_reader.py` | `render_sheets()` → un tableau HTML par feuille (openpyxl `read_only=True`, `data_only=False`) |
|
||||
| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html, rows, cols, total_*, max_*, truncated}]` |
|
||||
| Endpoint fenêtre | `backend/routers/files_read.py` | `GET /api/file/{vault}/xlsx/sheet?path=&sheet=&offset=&limit=` (#153 A9) — une fenêtre de lignes, vraies coordonnées A1 |
|
||||
| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets`, `XlsxSheetWindowResponse` |
|
||||
| Écriture | `backend/services/mutations.py:227-320` | `edit_xlsx_cells()` (backup, refs A1 validées, coercion `str`→`int`/`float`) |
|
||||
| Endpoint écriture | `backend/routers/files_write.py:116-148` | `PUT /api/file/{vault}/xlsx/save` (1 à 500 cellules / requête) |
|
||||
| Documentation API | `backend/openapi_docs.py:184-187` | exemple d'appel `xlsx/save` |
|
||||
| UI | `frontend/js/viewer.js:998-1100` | `renderXlsxViewer()` (onglets, cellules sales, Entrée/Échap, collage monoligne) |
|
||||
| CSS | `frontend/style.css:10927-10988` | `.xlsx-*` (variables CSS, colonne A `sticky`) |
|
||||
| Indexation | `backend/indexer.py:68, 563-568, 957-960` | `.xlsx` supporté, **métadonnées seules** (`content=""`) |
|
||||
| Outils IA | `backend/tools/documents.py:66-89` + `schemas.py:296-305` | `create_xlsx` (WRITE + confirmation) — **création seule** |
|
||||
| Tests | `tests/test_xlsx_viewer.py` (58) + `test_xlsx_styles.py` (9) + `test_xlsx_formats.py` (12) + `test_xlsx_dashboard.py` (8) + `test_xlsx_structure.py` (11) + `test_spreadsheet_tools.py` (17) · `tests/frontend/xlsx-viewer.test.mjs` (35) · `tests/e2e/xlsx-viewer.spec.js` (9) | Backend, JSDOM et E2E (chromium-desktop) |
|
||||
|
||||
## 2. Ce qui est supporté aujourd'hui (livré, non concerné par #153 sauf mention)
|
||||
|
||||
**Lecture** — multi-feuilles avec onglets ; en-têtes A1/A2/B1 et numéros de ligne ; valeurs
|
||||
`_fmt()` (dates `YYYY-MM-DD` / `YYYY-MM-DD HH:MM`) ; lignes et colonnes de fin élaguées
|
||||
(`_trim`) ; feuille vide affichée ; `html.escape()` sur chaque valeur.
|
||||
|
||||
**Édition** — `contentEditable` par `<td>`, classe `xlsx-dirty`, bouton Save actif seulement si
|
||||
modification ; `Entrée` → blur, `Échap` → restauration, collage forcé en monoligne ; un `PUT` par
|
||||
feuille sale ; coercion automatique des nombres (`"250"` → int `250`) ; chaîne vide → cellule
|
||||
vidée ; backup `.bak` avant écriture ; garde-fou vault read-only (403) ; `resolve_safe_path()`
|
||||
(anti path-traversal) ; `check_vault_access()` + `require_auth` ; journalisation d'audit
|
||||
(`log_file_save`).
|
||||
|
||||
**Divers** — téléchargement de l'original ; refresh de l'arborescence via le watcher après
|
||||
écriture ; rafraîchissement de la visionneuse après une action IA (`create_xlsx` →
|
||||
`obsigate:file-written`, BUG-076).
|
||||
|
||||
## 3. Limites connues (par couche)
|
||||
|
||||
> **Note (2026-09-28)** : les limites ci-dessous décrivent l'état du jour de l'audit
|
||||
> (2026-09-27). La quasi-totalité a été levée depuis par le backlog §5 (styles, navigation
|
||||
> clavier, tri/filtre/recherche, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation,
|
||||
> outils IA) — se reporter aux cases cochées et à l'historique §7 ; ne pas relire cette
|
||||
> section comme l'état actuel.
|
||||
|
||||
### 3.1 Fidélité du round-trip — risque n°1
|
||||
|
||||
`load_workbook()` → `wb.save()` : ce qui est **réellement** perdu a été mesuré sur
|
||||
openpyxl 3.1.5 (2026-09-27), et non repris de la documentation :
|
||||
|
||||
| Élément | Round-trip openpyxl 3.1.5 |
|
||||
|---|---|
|
||||
| Graphiques, images, dessins | ✅ **préservés** (mesuré : `xl/charts/`, `xl/drawings/`, `xl/media/` intacts) |
|
||||
| Tableaux croisés (pivot) + caches | ✅ **préservés** (`reader/excel.py` relit les `TableDefinition`, `workbook/_writer.py` les réécrit) |
|
||||
| Styles, formats, fusions, validation de données, mise en forme conditionnelle, commentaires | ✅ préservés |
|
||||
| **Valeurs calculées en cache** (`<f>…</f><v>…</v>`) | ❌ **perdues** → tout lecteur `data_only=True` (pandas, script tiers, convertisseur) renvoie `None` tant qu'Excel n'a pas recalculé |
|
||||
| Slicers / chronologies, contrôles de formulaire (`ctrlProps`/`activeX`), connexions & requêtes, custom XML, signature numérique, commentaires enrichis, macros | ❌ **perdus** (parties absentes de l'archive après écriture) |
|
||||
|
||||
La liste fait foi dans le code : [`LOSSY_PARTS`](../backend/xlsx_reader.py) + la sonde
|
||||
`<f>…</f><v>[^<]` pour les valeurs en cache (openpyxl écrivant lui-même un `<v></v>` vide).
|
||||
|
||||
**Ce qui reste ouvert** (non mesuré, prudence) : types de graphiques exotiques (treemap,
|
||||
sunburst, funnel…), `sparklines`, `xl/queryTables` en lecture Excel. Un classeur qui en contient
|
||||
peut sortir dégradé, voire échouer au chargement — d'où le refus par défaut (A1).
|
||||
|
||||
### 3.2 Lecture
|
||||
|
||||
- Aucun style, format de nombre, devise, pourcentage, largeur de colonne, ligne figée, cellule
|
||||
fusionnée, commentaire, lien hypertexte, validation de données, mise en forme conditionnelle.
|
||||
- Plafonds durs `MAX_ROWS = 500`, `MAX_COLS = 40` par feuille, **sans indicateur dans l'UI** : au-delà,
|
||||
contenu silencieusement tronqué et **non éditable**.
|
||||
- Pas de pagination ni de chargement à la demande : toutes les feuilles sont rendues d'un bloc
|
||||
dans le JSON (20 feuilles × 20 000 cellules = payload énorme, UI gelée).
|
||||
- Formules affichées **en texte** (`=B1*2`), jamais recalculées ; après édition, les cellules
|
||||
dépendantes ne se mettent pas à jour à l'écran.
|
||||
|
||||
### 3.3 UI (`viewer.js`)
|
||||
|
||||
Navigation clavier (Tab/flèches) absente ; pas de barre de formule, pas de nom de cellule actif,
|
||||
pas d'undo/redo global, pas de recherche dans la feuille, pas de tri/filtre, pas d'export CSV,
|
||||
pas d'ajout/renommage/suppression de feuille, pas d'insertion/suppression de ligne ou colonne,
|
||||
pas de sélection de plage, pas de copie d'une plage, pas de retour ligne dans une cellule
|
||||
(`Maj+Entrée`) ; seul le retour de l'API est signalé (plafond 500 cellules) ; seule la
|
||||
**colonne A** est `sticky` (le `thead` ne l'est pas → les en-têtes de colonnes disparaissent au
|
||||
défilement vertical). **Couverture de test** : `tests/frontend/xlsx-viewer.test.mjs` (10) et
|
||||
`tests/e2e/xlsx-viewer.spec.js` (3) depuis #153 P0 — la navigation clavier et la barre de formule
|
||||
restent à faire (A7).
|
||||
|
||||
### 3.4 Recherche, IA et knowledge base
|
||||
|
||||
- **Indexation** : `content=""` → un `.xlsx` est totalement **invisible** à la recherche TF-IDF, à
|
||||
la recherche sémantique, au remplacement global, aux tags et aux statistiques de contenu.
|
||||
- **Outils IA** : seul `create_xlsx` existe (crée un fichier neuf, une seule feuille,
|
||||
`overwrite=True` par défaut) ; `read_file` fait un `read_text()` sur l'archive ZIP → **bruit
|
||||
binaire** envoyé au LLM ; pas de `update_xlsx_cells` pourtant le service existe déjà, pas
|
||||
d'ajout de lignes, pas de `xlsx → markdown` pour le contexte.
|
||||
|
||||
## 4. Risques de sécurité / robustesse
|
||||
|
||||
| # | Risque | Où | Traitement | État |
|
||||
|---|---|---|---|---|
|
||||
| R1 | Perte silencieuse (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) | `mutations.edit_xlsx_cells` | **A1** — bandeau + **409** `xlsx_lossy_content` sans `force` | 🟢 livré (BUG-085) |
|
||||
| R2 | Écriture non atomique (`wb.save()` en place) → classeur corrompu si crash | `mutations.edit_xlsx_cells` | **A2** — `.tmp` + `os.replace` | 🟢 livré (BUG-086) |
|
||||
| R3 | Concurrence : deux éditions (onglets, watcher + IA) → dernier écrivain gagne | `mutations.edit_xlsx_cells` | **A3** — verrou par chemin, **409** `conflict` | 🟢 livré (BUG-087) |
|
||||
| R4 | **Injection de formule** : une saisie `=cmd\|…`, `=HYPERLINK(…)` est stockée comme formule par openpyxl → DDE à l'ouverture dans Excel | `mutations._write_cell` | **A4** — forçage texte (`data_type="s"`), opt-in `allow_formula` | 🟢 livré (BUG-088) |
|
||||
| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | 🟢 bandeau + dimensions exposées (BUG-090) ; le chargement paresseux par fenêtres sert les lignes au-delà du plafond |
|
||||
|
||||
## 5. Backlog #153 — sous-tâches
|
||||
|
||||
Légende : 🔴 P0 (sécurité / perte de données) · 🟡 P1 (valeur immédiate) · 🟢 P2 (confort /
|
||||
couverture) · effort en jours-homme de développement + tests.
|
||||
|
||||
### P0 — Garde-fous d'écriture (2-3 j) — 🟢 livré le 2026-09-27
|
||||
|
||||
- [x] **A1 — Alerte de fidélité avant écriture (R1).** `inspect_workbook()` liste ce qu'un
|
||||
round-trip perd (`LOSSY_PARTS` + sonde valeurs en cache) ; la lecture renvoie
|
||||
`xlsx_lossy_features` ; la visionneuse affiche un bandeau listant les éléments ; `PUT
|
||||
…/xlsx/save` répond **409** `xlsx_lossy_content` (avec `details.features`) tant que `force` n'est
|
||||
pas passé, le client demande confirmation puis réémet avec `force: true` (une seule fois par
|
||||
session). *Vérifié :* `TestXlsxLossyGuard` (5), `xlsx-viewer.test.mjs` (10), E2E (3).
|
||||
- [x] **A2 — Écriture atomique (R2).** `wb.save(<nom>.<pid>.tmp)` puis `os.replace()` ; `.tmp`
|
||||
supprimé sur échec ; backup `.bak` inchangé. Le `.tmp` est ignoré par le watcher. *Vérifié :*
|
||||
`TestXlsxAtomicWrite` (2) — les octets d'origine sont intacts après un `save` en échec.
|
||||
- [x] **A3 — Verrou par fichier (R3).** Verrou `threading.Lock` par chemin (registre + garde,
|
||||
timeout 15 s) autour du cycle load → edit → replace ; **409** `conflict` si le délai est dépassé.
|
||||
L'endpoint est passé en `def` (sync) pour que l'attente s'exécute dans le threadpool. *Vérifié :*
|
||||
`TestXlsxWriteLock` (2). *Limite :* verrou en mémoire, par processus (suffisant pour un serveur
|
||||
ObsiGate, y compris desktop).
|
||||
- [x] **A4 — Neutralisation de l'injection de formule (R4).** `cell.data_type = "s"` après
|
||||
affectation : une saisie `=`/`@` est stockée en texte. Opt-in `allow_formula: true` côté API et
|
||||
bouton `f(x)` dans la visionneuse (état de session, jamais persisté). `+`/`-` restent des
|
||||
nombres. Au passage : le handler `ServiceError` expose `code` + `details` et `api()` les
|
||||
propage sur l'Error. *Vérifié :* `TestXlsxFormulaGuard` (4) + test du toggle côté UI.
|
||||
|
||||
### P1 — Recherche, IA, UX (4-6 j) — 🟢 livré le 2026-09-28 (A5 → A12)
|
||||
|
||||
- [x] **A5 — Indexation du contenu des feuilles.** `extract_indexable_text()` (noms de feuilles +
|
||||
20 premières lignes, `MAX_INDEX_CHARS = 5 000`, 20 feuilles max) alimente le TF-IDF et la
|
||||
recherche sémantique ; la lecture binaire reste inchangée pour l'affichage. Un classeur
|
||||
chiffré/corrompu s'indexe par son seul nom (jamais d'exception). Au passage : **BUG-089**,
|
||||
un reindex manuel ne reconstruisait pas l'index inversé. *Vérifié :* `TestXlsxSearchable` (4)
|
||||
+ `TestXlsxIndexing`, **contre-preuve** (neutraliser l'extraction → 3 tests échouent).
|
||||
- [x] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant),
|
||||
`append_xlsx_rows`, `xlsx_to_markdown` (contexte LLM, plafonné), `list_xlsx_sheets` — risque
|
||||
WRITE + confirmation pour les mutations, libellés i18n dans `backend/tools/labels.py`,
|
||||
refresh viewer via `obsigate:file-written`. *Livré (v2.33.0) :* `backend/tools/spreadsheets.py`.
|
||||
*Vérifié :* `tests/test_spreadsheet_tools.py` (17).
|
||||
- [x] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
|
||||
active affichée (nom A1), `Maj+Entrée` pour le multiligne, copier une plage, focus visible
|
||||
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`). *Livré (v2.34.0).*
|
||||
- [x] **A8 — `thead` sticky + indicateur de troncature (R5) — livré 2026-09-28 (BUG-090).**
|
||||
Ligne d'en-têtes figlée au défilement vertical (`thead th { top: 0 }` ; `top: auto` sur les
|
||||
numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose
|
||||
`total_rows`/`total_cols` (dimensions déclarées), `max_rows`/`max_cols` (plafonds) et
|
||||
`truncated` — le bandeau « feuille tronquée » annonce le **plafond atteint** et non la
|
||||
taille élaguée (une feuille creuse rend 1×1 tout en couvrant 500 lignes) ; libellés
|
||||
`xlsx.truncated_*` FR/EN. *Vérifié :* `TestXlsxTruncationNotice` (4), `xlsx-viewer.test.mjs`
|
||||
(4 nouveaux), E2E sur `test_vault/sample-xlsx-large.xlsx` (520 lignes).
|
||||
- [x] **A9 — Chargement paresseux par feuille (côté API).** Endpoint
|
||||
`GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`,
|
||||
exemple dans `backend/openapi_docs.py`) : une fenêtre de 1 à 1 000 lignes (plafond
|
||||
`MAX_WINDOW_ROWS`, `limit>1000` → 422), `has_more` pour paginer, valeurs calculées A12
|
||||
incluses. Les numéros de ligne et `data-cell` restent les coordonnées A1 réelles de la
|
||||
feuille (`_table(..., row_offset=offset)`) : une fenêtre est indistinguishable d'un rendu
|
||||
complet et une édition dans la fenêtre cible la bonne cellule. Erreurs : 404 feuille
|
||||
inconnue / fichier absent, 415 non-`.xlsx`. *Vérifié :* `TestXlsxSheetWindow` (11),
|
||||
**contre-preuve** (neutraliser l'offset → 3 tests échouent), E2E « l'endpoint de fenêtre
|
||||
sert les lignes au-delà du plafond ».
|
||||
- [x] **A9bis — Chargement à la demande côté UI.** Sous une feuille tronquée, un pied de page
|
||||
« N lignes affichées sur M · Charger la suite » apparaît : cliquer — ou approcher du bas
|
||||
du tableau (sentinelle de défilement, marge 120 px) — fetch la fenêtre suivante
|
||||
(`limit=500`) et l'insère dans la table. Les lignes ajoutées passent par le **même**
|
||||
pipeline d'édition que le rendu initial (`setupCell` factorisé : contenteditable, dirty,
|
||||
Échap, collage monoligne, info-bulle valeurs calculées) et sont donc sauvegardables
|
||||
immédiatement. Un fetch échoué restore le libellé du pied de page (retry possible) et
|
||||
toast l'erreur ; feuille complète → pied de page masqué (`class="done"`).
|
||||
*Vérifié :* `xlsx-viewer.test.mjs` 19/19 (5 nouveaux), **contre-preuve** (désactiver
|
||||
`wireLazyRows` → 5 tests échouent), E2E « le bouton charger la suite ajoute les lignes
|
||||
cachées » sur `sample-xlsx-large.xlsx` (A520 visible et éditable après clic).
|
||||
- [x] **A10 — Types et formats de saisie.** `_coerce_xlsx_value()` reconnait les booléens
|
||||
(`true`/`vrai`/`oui`/`yes` et leurs négatifs) et les dates FR `JJ/MM/AAAA` (+ `HH:MM`),
|
||||
jour-first comme Excel en locale française : `01/02/2026` = 1ᵉʳ février. Une saisie
|
||||
ressemblant à une formule n'est jamais convertie (BUG-088 préservé) ; un code postal
|
||||
numérique ou une version restent ce qu'ils sont. *Vérifié :* `TestXlsxValueCoercion` (5),
|
||||
**contre-preuve** (neutraliser la coercion → 2 tests échouent).
|
||||
- [x] **A11 — Tests frontend + E2E.** `tests/frontend/xlsx-viewer.test.mjs` (dirty, Échap,
|
||||
collage, 1 PUT par feuille, bouton désactivé) et `tests/e2e/xlsx-viewer.spec.js`
|
||||
(ouverture, onglets, édition, sauvegarde, rechargement) ; intégration au CI. *Vérifié :*
|
||||
35 tests JSDOM (le job CI `lint` lance `node xlsx-viewer.test.mjs`) et 9 E2E
|
||||
chromium-desktop ; la couverture a grandi avec chaque sous-tâche (A5/A8 → P2).
|
||||
- [x] **A12 — Valeurs calculées.** La valeur en cache s'affiche sous la formule dans un
|
||||
`<span class="xlsx-cached">`. La 2ᵉ lecture `data_only=True` n'a lieu que si l'archive
|
||||
contient réellement un `<f>…</f><v>…</v>` (sonde déjà présente pour A1) : le cas courant
|
||||
reste à un seul chargement, et toute erreur retombe sur l'affichage formules seul.
|
||||
L'info-bulle est traduite côté client (`xlsx.cached_value_title` FR/EN) — aucun texte
|
||||
d'interface n'est émis par le backend. *Vérifié :* `TestXlsxCachedValues` (3),
|
||||
**contre-preuve** (neutraliser la 2ᵉ lecture → 2 tests échouent).
|
||||
|
||||
### P2 — Étendu (2-4 j) — 🟢 livré le 2026-09-28
|
||||
|
||||
- [x] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
|
||||
*Livré (v2.35.0) :* tout en manipulation d'affichage, le classeur n'est jamais réécrit
|
||||
(info-bulle `xlsx.sort_applied`).
|
||||
- [x] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
|
||||
*Livré (v2.36.0) :* `PUT …/xlsx/structure` + menu Structure, mêmes garde-fous que
|
||||
l'édition de cellules. *Vérifié :* `tests/test_xlsx_structure.py` (11).
|
||||
- [x] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format
|
||||
devise/pourcentage/date, cellules fusionnées, volets figés) ; conserver `csv-table` comme
|
||||
socle de rendu. *Livré (v2.37.0) en lecture :* couleurs, gras/italique/souligné,
|
||||
alignements, fusions, ancre de volets figés ; un format de nombre personnalisé est signalé
|
||||
en police mono (pas de rendu devise/pourcentage). L'application de styles **depuis la
|
||||
visionneuse** (écriture) reste hors périmètre. *Vérifié :* `tests/test_xlsx_styles.py` (9).
|
||||
- [x] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
|
||||
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion. *Livré (v2.38.0) :*
|
||||
`.xlsm` éditable macros préservées, `.xls`/`.ods` lecture seule (xlrd/odfpy), `.csv`
|
||||
éditable et réécrit RFC 4180. *Vérifié :* `tests/test_xlsx_formats.py` (12).
|
||||
- [x] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue
|
||||
résumée (KPI par feuille) et proposal d'actions IA sur ces plages. *Livré (v2.39.0) :*
|
||||
panneau Tableau de bord (`GET …/xlsx/dashboard`) — plages nommées avec portée, comptage
|
||||
graphiques/TCD par analyse des parties OPC, stats par feuille, 8 KPI ; le volet IA se
|
||||
limite à un conseil contextuel (pas d'appel IA dédié sur les plages).
|
||||
*Vérifié :* `tests/test_xlsx_dashboard.py` (8).
|
||||
|
||||
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
||||
|
||||
- Chaque sous-tâche démarre par un **ID stable** : nouvelle feature = `#153-A<n>` dans la
|
||||
Roadmap ; si la sous-tâche est un **défaut** (A1, A2, A3, A4, A8), l'ouvrir aussi comme
|
||||
`BUG-NNN` dans `docs/ISSUES_TODOLIST.md` au moment du démarrage.
|
||||
- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans
|
||||
`backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`.
|
||||
- Frontend : vanilla JS sans build, `safeCreateIcons()`, **variables CSS** (jamais de couleur
|
||||
hardcodée), **i18n FR + EN** pour chaque nouveau texte (`test_i18n_parity.py` vert).
|
||||
- Tests : `pytest tests/test_xlsx_viewer.py`, `ruff`, `mypy`, `validate-imports`, suite frontend
|
||||
ciblée, E2E si l'UI change — puis CI verte.
|
||||
- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée), cette fiche (résultat),
|
||||
guide utilisateur i18n + README si impact utilisateur.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2.27.0 | #152 livré : affichage multi-feuilles, édition des cellules, téléchargement (`docs/archive/COMPLETED_v1-v2.md`) |
|
||||
| 2026-09-27 | Audit complet → création de #153 : limites, risques R1-R5, backlog A1-A17 |
|
||||
| 2026-09-27 | Périmètre de perte **remesuré** sur openpyxl 3.1.5 : graphiques / images / TCD sont préservés, seules les valeurs en cache et quelques parties exotiques sont perdues |
|
||||
| 2026-09-27 | **P0 livré** (BUG-085 → BUG-088) : `xlsx_lossy_features` + 409 `xlsx_lossy_content`, écriture atomique, verrou par fichier, formules stockées en texte par défaut |
|
||||
| 2026-09-28 | **A5 + A10 + A12 livrés** : le contenu des cellules est indexé (recherche), la saisie est typée (booléens, dates FR), la valeur calculée s'affiche sous la formule. **BUG-089** corrigé au passage (reindex manuel ≠ reconstruction de l'index inversé ; `backend/search.py` lisait l'index par valeur) |
|
||||
| 2026-09-28 | **A8 + A9 livrés** (BUG-090) : la troncature d'une feuille est annoncée (bandeau + dimensions dans la réponse de lecture), les en-têtes restent visibles au défilement, et `GET …/xlsx/sheet` sert une fenêtre de lignes avec les vraies coordonnées A1 — les lignes au-delà du plafond redeviennent accessibles aux clients API. Défilement virtuel côté UI à suivre |
|
||||
| 2026-09-28 | **A9bis livré** : « Charger la suite » + sentinelle de défilement sous une feuille tronquée ; les lignes ajoutées sont éditables et sauvegardables immédiatement (même pipeline que le rendu initial) |
|
||||
| 2026-09-28 | **A6 + A7 livrés** (v2.33.0, v2.34.0) : l'assistant IA lit et modifie les classeurs (`list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`, `append_xlsx_rows`) et la visionneuse gagne navigation clavier complète + barre de formule |
|
||||
| 2026-09-28 | **A13 + A14 livrés** (v2.35.0, v2.36.0) : tri, filtre, recherche et export CSV côté affichage ; structure du classeur éditable (feuilles, lignes, colonnes) via `PUT …/xlsx/structure` |
|
||||
| 2026-09-28 | **A15 + A16 + A17 livrés** (v2.37.0 → v2.39.0) : styles/fusions/volets figés rendus, formats `.xlsm`/`.xls`/`.ods`/`.csv` gérés, panneau Tableau de bord (plages nommées, graphiques/TCD, stats, KPI) — **backlog #153 terminé** |
|
||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 144 KiB |