Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+8
-2
@@ -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
|
||||
|
||||
+34
-6
@@ -44,8 +44,11 @@ jobs:
|
||||
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
|
||||
@@ -63,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
|
||||
@@ -80,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 ─────────────────────────────────────────────────────────
|
||||
@@ -123,14 +132,30 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install bandit pip-audit
|
||||
pip install bandit pip-audit semgrep
|
||||
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, bloquant — #87)
|
||||
# Règles 100 % locales (semgrep-rules/, 8 règles) : aucun
|
||||
# téléchargement de registre (runner au réseau fragile).
|
||||
run: semgrep --config semgrep-rules/ backend/
|
||||
|
||||
- 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.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
@@ -192,6 +217,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
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
+536
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.27.2**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.36.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,541 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.36.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.35.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.34.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.33.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.32.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.31.0] — 2026-09-28
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-090 — troncature silencieuse d'une feuille `.xlsx` au-delà de
|
||||
500 lignes × 40 colonnes.** `render_sheets()` renvoie les dimensions
|
||||
déclarées par la feuille (`total_rows`/`total_cols`), les plafonds du
|
||||
moteur (`max_rows`/`max_cols`) et un flag `truncated` : la visionneuse
|
||||
affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 »
|
||||
(i18n FR/EN) au lieu de présenter une table courte comme complète. La
|
||||
ligne d'en-têtes est désormais figée au défilement vertical (`thead`
|
||||
sticky, `top: auto` sur les numéros de ligne pour éviter leur
|
||||
empilement en haut à gauche). *#153 A8/R5.*
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#153 A9 — chargement paresseux d'une feuille par fenêtres.**
|
||||
`GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` renvoie un
|
||||
bloc de lignes (`XlsxSheetWindowResponse`, plafond 1 000 lignes par
|
||||
requête, `has_more` de pagination) avec les **vraies** coordonnées A1
|
||||
et numéros de ligne de la feuille — une fenêtre se comporte exactement
|
||||
comme le rendu complet. Erreurs typées : 404 feuille inconnue, 415
|
||||
fichier non-`.xlsx`. La lecture des valeurs calculées en cache (#153
|
||||
A12) s'applique aussi aux fenêtres.
|
||||
- **#153 A9bis — « Charger la suite » sous une feuille tronquée.** Un
|
||||
pied de page annonce la progression et fetch la fenêtre suivante au
|
||||
clic ou à l'approche du bas du tableau (sentinelle de défilement).
|
||||
Les lignes ajoutées passent par le même pipeline d'édition que le
|
||||
rendu initial : éditables et sauvegardables immédiatement. Un fetch
|
||||
échoué restore le bouton (retry possible) ; feuille complète → pied
|
||||
de page masqué.
|
||||
|
||||
---
|
||||
|
||||
## [2.30.0] — 2026-09-27
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-089 — un reindex manuel ne reconstruisait pas l'index inversé.**
|
||||
`reload_index()` / `reload_single_vault()` remplacent l'entrée de vault
|
||||
en bloc, ce qui n'émet pas les notifications incrémentales : la
|
||||
recherche TF-IDF continuait de servir un index périmé après un
|
||||
reindex. Les deux fonctions appellent désormais `init_inverted_index()`.
|
||||
Au passage, `backend/search.py` lisait l'index via
|
||||
`from backend.indexer import index` — une liaison **par valeur** du
|
||||
dict : un rechargement du module `backend.indexer` recréait le dict
|
||||
côté indexer alors que la recherche écrivait dans l'ancien, et
|
||||
l'index inversé n'indexait plus rien. Tous les accès passent par
|
||||
`_indexer.index`. *Trouvé en écrivant le test de recherche d'A5 : il
|
||||
passait isolément et échouait en suite complète selon l'ordre.*
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#153 A5 — les tableurs sont indexés par leur contenu.**
|
||||
`extract_indexable_text()` extrait les noms de feuilles et les 20
|
||||
premières lignes (plafond 5 000 caractères, 20 feuilles) pour le
|
||||
TF-IDF et la recherche sémantique. Un mot tapé dans une cellule rend
|
||||
désormais le classeur trouvable ; la lecture binaire pour l'affichage
|
||||
est inchangée et un classeur chiffré/corrompu s'indexe par son seul
|
||||
nom.
|
||||
- **#153 A10 — la saisie est typée comme dans Excel.** Une valeur
|
||||
`TRUE`/`FAUX`/`OUI`/`NON` devient un booléen, une date `JJ/MM/AAAA`
|
||||
(avec `HH:MM` optionnel) devient une vraie date — et dans l'ordre
|
||||
français : `01/02/2026` est le 1ᵉʳ février. Une saisie ressemblant à
|
||||
une formule n'est jamais convertie.
|
||||
- **#153 A12 — la valeur calculée s'affiche sous la formule.** Quand une
|
||||
cellule porte encore le résultat de son dernier calcul Excel, celui-ci
|
||||
s'affiche dans une ligne discrète sous la formule. La seconde lecture
|
||||
`data_only=True` n'a lieu que si l'archive contient réellement une
|
||||
valeur en cache, et toute erreur retombe sur l'affichage formules seul.
|
||||
Info-bulle traduite FR/EN (`xlsx.cached_value_title`).
|
||||
|
||||
---
|
||||
|
||||
## [2.29.0] — 2026-09-27
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-084 — l'index inversé conservait des documents fantômes après la
|
||||
suppression d'une vault.** `remove_vault_from_index()`
|
||||
(`backend/indexer.py`) ne notifiait pas le hook incrémental : après
|
||||
suppression d'une vault, ses documents restaient dans l'index inversé
|
||||
(`postings`, `doc_info`, `doc_vault`, `vault_docs`) et continuaient de
|
||||
correspondre aux recherches pour une vault inexistante — seul un reindex
|
||||
manuel les effaçait. Le correctif déclenche
|
||||
`_on_index_change('remove', …)` pour chaque fichier de la vault, et
|
||||
`_remove_doc_internals()` supprime désormais la clé `vault_docs` dont le set
|
||||
devient vide (c'est un `defaultdict` : une lecture la recréait).
|
||||
Test : `TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le
|
||||
correctif).
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **Index inversé — `is_stale()` renommé `is_ready()`.** La relecture de
|
||||
`plan.md` a établi que les étapes 6 et 7 (suppression du cooldown et du hack
|
||||
de coalescence) étaient **déjà livrées** : ni `_last_rebuild`, ni
|
||||
`_rebuild_cooldown`, ni `_source_generation`, ni `_on_vault_change` ne
|
||||
subsistent. `is_stale()` ne mesurait donc plus aucune staleness — il
|
||||
indiquait seulement si l'index initial était construit, sous un nom
|
||||
trompeur. Renommé `is_ready()`, cohérent avec le `is_ready()` de
|
||||
`SemanticIndex` ; l'alias `is_stale()` de `SemanticIndex`, sans appelant, est
|
||||
supprimé. `/api/diagnostics` expose désormais `is_ready` (libellé « Index
|
||||
prêt » côté `frontend/js/config.js`). Tests :
|
||||
`test_is_ready_tracks_initial_build`, `test_is_ready_survives_incremental_updates`.
|
||||
|
||||
- **`plan.md` recalibré.** Le fichier est désormais marqué « livré » et
|
||||
suivi d'une section « État réel » : le code a divergé du plan sur quatre
|
||||
points (pas de repli `_needs_rebuild`, `_ready` au lieu de `doc_count == 0`,
|
||||
`rebuild()` conservé au démarrage, `is_stale()` repurposé). Les extraits de
|
||||
code du plan sont explicitement signalés comme ne décrivant pas le code
|
||||
actuel.
|
||||
|
||||
- **Fixtures de test locales exclues du suivi Git.** `test_dir/music/`,
|
||||
`test_dir/video/`, `test_vault/images/`, `test_vault/markdown/` et
|
||||
`test_vault/budget.xlsx` (~200 Mo) sont ajoutés au `.gitignore` : aucun test
|
||||
ni job CI ne les référence — les tests unitaires génèrent leurs fixtures dans
|
||||
`tmp_path` et l'E2E n'utilise que les fixtures committées
|
||||
(`test_vault/sample-*.{mp3,png,svg,webm,pdf}`, `test_dir/*.md`). Ils
|
||||
restaient non suivis et polluaient `git status`.
|
||||
|
||||
### Sécurité
|
||||
|
||||
- **BUG-088 — plus d'injection de formule via la visionneuse Excel.** Une
|
||||
saisie `=cmd|'/c calc'!A1` (ou `@…`) était stockée comme **formule** par
|
||||
openpyxl, donc exécutée par Excel à la réouverture du fichier (DDE).
|
||||
`edit_xlsx_cells` force maintenant le type texte (`cell.data_type = "s"`)
|
||||
pour toute valeur commençant par `=` ou `@` ; l'API accepte
|
||||
`allow_formula: true` et la visionneuse expose un bouton `f(x)`
|
||||
(opt-in, état de session, jamais persisté). `+`/`-` restent des nombres.
|
||||
- **BUG-087 — écriture concurrente d'un classeur.** `load_workbook()` →
|
||||
`save()` n'était pas sérialisé : deux sauvegardes simultanées (deux
|
||||
onglets, l'agent IA et la visionneuse) faisaient gagner la dernière, en
|
||||
silence. Verrou par chemin (`backend/services/mutations.py::_xlsx_write_lock`,
|
||||
timeout 15 s) autour du cycle lecture → édition → remplacement ; attente
|
||||
dépassée → **409** `conflict`. L'endpoint `PUT …/xlsx/save` est devenu
|
||||
synchrone pour que l'attente s'exécute dans le threadpool.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-085 — la perte de données à l'enregistrement d'un `.xlsx` est
|
||||
annoncée, plus silencieuse.** `GET /api/file/{vault}` renvoie
|
||||
`xlsx_lossy_features` (éléments qu'un round-trip openpyxl perd) ; la
|
||||
visionneuse affiche un bandeau listant ces éléments et la première
|
||||
sauvegarde demande confirmation avant de renvoyer `force: true`. Sans
|
||||
`force`, l'API répond **409** `xlsx_lossy_content` avec
|
||||
`details.features`. Périmètre **remesuré** sur openpyxl 3.1.5 : graphiques,
|
||||
images, dessins et tableaux croisés sont bien 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.
|
||||
- **BUG-086 — écriture atomique des classeurs.** `wb.save()` écrivait en
|
||||
place sur le fichier du vault : un plantage laissait un `.xlsx` tronqué.
|
||||
L'écriture passe désormais par un `.tmp` puis `os.replace()` (le backup
|
||||
`.bak` est inchangé, le `.tmp` est ignoré par le watcher).
|
||||
- Le handler global `ServiceError` expose maintenant `code` et `details` dans
|
||||
la réponse JSON, et `api()` (frontend) les propage sur l'Error — nécessaire
|
||||
pour que le client distingue un 409 de confirmation d'une autre erreur.
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#153 (P0) — tests de la visionneuse Excel.**
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (10 tests JSDOM : bannière,
|
||||
confirmation + reprise `force`, refus, toggle `f(x)`, payload de
|
||||
sauvegarde) et `tests/e2e/xlsx-viewer.spec.js` (3 tests Playwright sur la
|
||||
fixture `test_vault/sample-xlsx-lossy.xlsx`) ; la suite JSDOM est branchée
|
||||
dans le CI.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **#153 — Visionneuse & édition XLSX : audit complet et backlog de
|
||||
complétude.** La visionneuse `.xlsx` livrée par #152 a été auditée couche
|
||||
par couche (lecture `backend/xlsx_reader.py`, écriture
|
||||
`backend/services.mutations.edit_xlsx_cells`, UI `renderXlsxViewer`,
|
||||
indexation, outils IA, tests). Bilan : la grille de valeurs est éditée
|
||||
correctement (sécurité, backup, audit, échappement HTML), mais l'ensemble
|
||||
supporté est étroit, une partie du classeur est perdue à l'enregistrement,
|
||||
les tableurs sont **invisibles pour la recherche** et l'IA ne sait que les
|
||||
**créer**. Ouverture de l'item **#153** dans `docs/ROADMAP.md` (17
|
||||
sous-tâches suivies **A1 → A17** ; **P0 livré**, reste P1 recherche/IA/UX
|
||||
puis P2 étendu) et création de la fiche
|
||||
[docs/features/xlsx-viewer.md](docs/features/xlsx-viewer.md) : cartographie
|
||||
du code, limites par couche, tableau des risques R1-R5 et critères
|
||||
d'acceptation par sous-tâche.
|
||||
|
||||
---
|
||||
|
||||
## [2.28.16] — 2026-09-27
|
||||
|
||||
---
|
||||
|
||||
## [2.28.15] — 2026-09-27
|
||||
|
||||
### Sécurité
|
||||
|
||||
- **#87 T6 — dépendances qualifiées, `pip-audit` bloquant (0 vulnérabilité).**
|
||||
mistune 3.0.2 → 3.3.3 (XSS/ReDoS/DoS dans le moteur de rendu),
|
||||
python-multipart 0.0.9 → 0.0.31, weasyprint 69 → 70, mcp 1.9.4 → 1.28.1,
|
||||
fastapi 0.110.3 → 0.141.1 + starlette 0.37.2 → 1.7.0, setuptools 84 ;
|
||||
`cast(str, …)` aux 3 sites d'appel mistune (typage 3.3 resserré). Suite
|
||||
complète 1359 passed, ruff/mypy 0. Seule exception : PYSEC-2026-1325
|
||||
(ecdsa, Minerva) — aucun correctif upstream ET JWT exclusivement HS256
|
||||
(`backend/auth/jwt_handler.py`), les chemins ECDSA P-256 ne s'exécutent
|
||||
jamais → `--ignore-vuln` documenté.
|
||||
|
||||
- **#87 T7 — semgrep SAST local bloquant (8 règles, 0 finding).**
|
||||
Ruleset `semgrep-rules/` (eval/exec, shell=True, os.system, pickle,
|
||||
yaml.load sans Loader, verify=False, Markup, mktemp) — 100 % local,
|
||||
aucun registre réseau (runner au réseau fragile). Trivy écarté :
|
||||
binaire + base de vulnérabilités à télécharger à chaque run, couche
|
||||
Python déjà couverte par `pip-audit` bloquant (image = slim + 4 libs).
|
||||
|
||||
- **#87 T8 — fin BUG-034 : cookies Secure auto, CORS same-origin explicite.**
|
||||
`OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut auto : Secure en https,
|
||||
sinon rien — logins http locaux préservés ; `X-Forwarded-Proto` honoré
|
||||
sous `TRUST_PROXY`, avertissement démarrage affiné, `TRUST_PROXY=true`
|
||||
dans le compose prod) ; `CORSMiddleware` same-origin explicite (sûr :
|
||||
web et desktop Tauri same-origin, API directe hors navigateur) ;
|
||||
`style-src 'unsafe-inline'` conservé et assumé (189 attributs `style=` +
|
||||
343 `el.style` — suppression = réécriture complète, risque nul côté
|
||||
exécution une fois `script-src` verrouillé en T5c).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.14] — 2026-09-27
|
||||
|
||||
---
|
||||
|
||||
## [2.28.13] — 2026-09-27
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#87 (T5c) — `script-src` sans `'unsafe-inline'`.**
|
||||
Seuls les scripts avec nonce frais (`backend/csp.py`, T5b) ou servis par
|
||||
`'self'`/CDN listés s'exécutent ; `style-src` garde `'unsafe-inline'`
|
||||
(chantier séparé). Vérifié : `test_csp_nonce.py` 5/5, 0 handler inline
|
||||
restant dans les pages HTML (propriétés `onXxx = fn` en JS non concernées
|
||||
par la CSP).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.12] — 2026-09-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-081 — `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée.**
|
||||
Le pseudo-user `anonymous` (auth désactivée, mode E2E/CI) n'a aucune entrée
|
||||
en store : `get_user(...)` → `None` puis `AttributeError` sur `user.get`.
|
||||
Garde `None` → payload « MFA désactivé » (`mfa_enabled: false`,
|
||||
`totp_enabled: false`, `webauthn_credentials: 0`). Test : `tests/test_mfa.py`
|
||||
(`TestMfaStatusAuthDisabled`, échoue en 500 sans le correctif).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.11] — 2026-09-27
|
||||
|
||||
---
|
||||
|
||||
## [2.28.10] — 2026-09-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-083 — job CI `security` rouge : le runner tronquait le `#` du `run:` pip-audit.**
|
||||
Le runner Gitea Act coupe naïvement au premier `#` (même entre
|
||||
guillemets) : `echo "... see #87)"` devenait une citation non fermée
|
||||
(`unexpected EOF while looking for matching '"'"`). Seul `run:` du
|
||||
workflow avec un `#` ; l'echo n'a plus de `#` (réf `#87` en commentaire
|
||||
YAML, jamais vu par le shell). Garde-fou : `tests/test_ci_workflow.py`
|
||||
(aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé dans
|
||||
l'étape JSDOM — BUG-082).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.9] — 2026-09-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-082 — CI `lint` rouge : suites frontend exigeant `jsdom`.**
|
||||
`tests/frontend/upload.test.mjs` puis `config-ai-keys.test.mjs` (imports
|
||||
statiques `jsdom`, introduits par `#89`) étaient exécutés dans l'étape
|
||||
frontend racine où `jsdom` n'est jamais installé (`ERR_MODULE_NOT_FOUND`,
|
||||
rouge depuis `7bee4a2`). Déplacés dans l'étape JSDOM (les deux branches,
|
||||
après install si besoin) ; garde-fou `tests/test_ci_workflow.py` :
|
||||
aucun fichier de l'étape racine ne doit importer `jsdom` statiquement.
|
||||
|
||||
---
|
||||
|
||||
## [2.28.8] — 2026-09-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-080 — harnais E2E local anti-blocage (plus de run pendu toute la nuit).**
|
||||
`run-e2e-local.ps1` : Playwright lancé via `node` direct sur la CLI locale
|
||||
(jamais de prompt interactif, `Start-Process` ne sachant pas exécuter `npx` ;
|
||||
paramètre `$Arguments`, `$Args` étant une variable automatique qui l'écraserait),
|
||||
installation Chromium sautée si déjà présent (`E2E_INSTALL_BROWSERS=1`
|
||||
pour forcer), étapes `install`/`test` bornées (`E2E_TIMEOUT_SEC`,
|
||||
défaut 1800 s / 600 s, exit 124 au dépassement — au-delà du globalTimeout
|
||||
pour un abandon propre avec rapport) ; `run-e2e-local.sh` : `npx --yes` +
|
||||
mêmes bornes ; `playwright.config.ts` : `globalTimeout` (25 min en local,
|
||||
30 min en CI, `E2E_GLOBAL_TIMEOUT_MS` pour surcharger) ; `e2e-server.ps1` :
|
||||
pidfile resynchronisé sur le vrai propriétaire du port et `stop` qui tue
|
||||
l'arbre complet (fini les serveurs orphelins qui squattent le port 2029).
|
||||
Garde-fous : `tests/test_e2e_harness.py` (8 tests).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.7] — 2026-09-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#87 (T5b) — nonces CSP prêts pour la bascule (sans changement).**
|
||||
Nonce frais par réponse dans `script-src` (`backend/csp.py`), injecté
|
||||
dans les 6 pages HTML servies (dont la nouvelle route
|
||||
`/excalidraw-editor.html`, utilisée par l'iframe du viewer au lieu de
|
||||
`/static/`) et la page de partage ; `tests/test_csp_nonce.py` (unicité,
|
||||
concordance en-tête/HTML). `unsafe-inline` conservé jusqu'en T5c.
|
||||
|
||||
---
|
||||
|
||||
## [2.28.6] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#87 (T5a) — 16 handlers inline convertis en listeners (CSP inchangée).**
|
||||
`onclick`/`onerror` de `index.html` et des vues JS (`config`, `plugins`,
|
||||
`sync`, `viewer`, `auth`) remplacés par `addEventListener` ; suites
|
||||
frontend vertes (unit, ai, config-mobile, pdf-viewer, mfa-settings,
|
||||
sidebar-filters).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.5] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.28.4] — 2026-09-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#87 (T4) — E2E XSS et serveur E2E piloté.**
|
||||
`tests/e2e/xss.spec.js` : page publique `/s/{token}` (titre/frontmatter
|
||||
hostile échappé, JSON neutralisé, aucun JS exécuté) et lecteur markdown
|
||||
(sanitizer, aucun `on*`/`javascript:` vivant) — 2/2 verts en local.
|
||||
`scripts/e2e-server.ps1` : `start|stop|status|logs` avec progression
|
||||
visible et fichier PID (fini les serveurs orphelins sur le port 2029).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.3] — 2026-09-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#87 (T3) — cookies `Secure` et CORS explicites.**
|
||||
Helper `is_secure_cookies()` centralisé (défaut `false` conservé pour ne
|
||||
pas casser les logins HTTP locaux) + avertissement au démarrage sur bind
|
||||
non-loopback sans `Secure` ; `tests/test_security_headers.py` atteste
|
||||
l'absence de CORS permissif (same-origin par défaut du navigateur) et les
|
||||
en-têtes de durcissement.
|
||||
|
||||
---
|
||||
|
||||
## [2.28.2] — 2026-09-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#87 (T2) — tests de durcissement : concurrence et regex.**
|
||||
`tests/test_hardening_concurrency.py` : créations/mises à jour/`login
|
||||
failures` concurrents sur `users.json` (zéro mise à jour perdue, JSON
|
||||
toujours récupérable) et budget temps de la politique ReDoS (motifs
|
||||
catastrophiques rejetés en < 1 s, motifs acceptés < 5 s sur 200 Ko).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.1] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#87 (T1) — CI sécurité durcie.**
|
||||
`bandit` passe en bloquant (`# nosec` justifiés : SHA1 non-crypto,
|
||||
subprocess git à argv fixe, `saxutils.escape` sans parsing ; B105 exclu
|
||||
comme `pyproject.toml`) ; `npm audit --omit=dev` bloquant (0 faille) ;
|
||||
les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`,
|
||||
`mfa-settings`, `config-ai-keys`, vertes en local) rejoignent le job
|
||||
`lint`. `pip-audit` reste consultatif (upgrades starlette/weasyprint à
|
||||
qualifier, chantier dédié).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.0] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T10) — persistance d'état et clôture de la refonte architecturale.**
|
||||
Verrous `RLock` sur les stores JSON sans protection (`revoked_tokens`,
|
||||
`shares`, `webhooks` + secrets, clés d'outils) avec tests de concurrence
|
||||
(`tests/test_store_locks.py` — pertes prouvées sans verrou) ; rate-limit
|
||||
auth persisté en option (`OBSIGATE_RATELIMIT_DB`, SQLite WAL, sémantique
|
||||
identique, défaut mémoire inchangé, `tests/test_ratelimit_store.py`).
|
||||
Contrat `tools/registry.py` audité (permissions/quotas/redaction déjà
|
||||
câblés, rien à coder). Index non persisté : rebuild différentiel #86
|
||||
suffisant (décision documentée). Fiche `docs/features/archi-refonte-85.md`,
|
||||
#85 sorti du backlog (index roadmap).
|
||||
|
||||
## [2.27.12] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T9) — extraction realtime + render hors du monolithe `backend/main.py`.**
|
||||
Le stream SSE `/api/events` et le WebSocket `/ws/collab/*` sont servis par
|
||||
`backend/routers/realtime.py`, le pipeline markdown (mistune, wikilinks,
|
||||
slugs, sanitizer) par `backend/render.py` (imports directs, plus de
|
||||
couplage différé). `main.py` (4 827 → ~760 lignes) ne contient plus que
|
||||
l'assemblage : lifespan, middlewares, montage des 16 routers, racine
|
||||
`/api`, statique/SPA et cales de compatibilité testées.
|
||||
|
||||
## [2.27.11] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T8) — extraction vaults/history/conflicts hors du monolithe `backend/main.py`.**
|
||||
13 routes servies par `backend/routers/vaults.py`, `history.py` et
|
||||
`conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`,
|
||||
handle watcher partagé dans `backend/watcher_state.py`.
|
||||
`tests/test_api_main.py` importe `humanize_mtime` depuis son module
|
||||
canonique (`services.recent`).
|
||||
|
||||
## [2.27.10] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T7) — extraction du domaine `config` hors du monolithe `backend/main.py`.**
|
||||
`/api/config`, ai-keys (get/post/delete/test), tool-keys (×3), ai-models,
|
||||
diagnostics et dashboard sont servis par `backend/routers/config.py`
|
||||
(`_FALLBACK_MODELS`, store clés et config déplacés ; `main` réimporte
|
||||
`_load_config` pour son lifespan, les fixtures de tests inchangées).
|
||||
`tests/test_ai_models.py` patch désormais la référence du router.
|
||||
|
||||
## [2.27.9] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.**
|
||||
file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info,
|
||||
image, media+thumb, attachments (rescan/stats), vault settings (get/post/all)
|
||||
et vault files sont servis par `backend/routers/files_media.py` ; le helper
|
||||
Range partagé vit dans `backend/routers/helpers.py` (tags OpenAPI inchangés,
|
||||
tests statiques frontend `media-viewer`/`image-viewer` réalignés).
|
||||
|
||||
## [2.27.8] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.**
|
||||
`PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE
|
||||
/api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par
|
||||
le nouveau `backend/routers/files_write.py` (effets de bord inchangés :
|
||||
audit, index, SSE, webhooks, plugins, historique) ; 15 modèles dans
|
||||
`schemas.py`.
|
||||
|
||||
## [2.27.7] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
|
||||
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
|
||||
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
|
||||
nouveau `backend/routers/files_read.py` ; modèles dans `schemas.py`,
|
||||
`_content_disposition`/`_media_max_inline_bytes` dans
|
||||
`backend/routers/helpers.py` (partagés avec les tranches suivantes).
|
||||
Correctif au passage : décorateur orphelin `/s/{token}` resté en T3 et
|
||||
double-enregistrement de `/api/conflicts` supprimés.
|
||||
|
||||
## [2.27.6] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.**
|
||||
Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`,
|
||||
`/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`,
|
||||
`/graph/{vault}`, `/index/reload`, `/index/reload/{vault}`) sont servies
|
||||
par le nouveau `backend/routers/search.py` ; les modèles search dans
|
||||
`schemas.py` et le pool de threads dans `backend/search_executor.py`
|
||||
(même dimensionnement, même cycle de vie) — comportement inchangé.
|
||||
|
||||
## [2.27.5] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.**
|
||||
Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`,
|
||||
`/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le
|
||||
nouveau `backend/routers/backups.py` ; `Diff/Restore*` déménagent dans
|
||||
`schemas.py` et le singleton SSE dans `backend/sse.py` (partagé avec
|
||||
`main`) — comportement inchangé, aucun impact utilisateur.
|
||||
|
||||
## [2.27.4] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T3) — extraction du domaine `sharing` hors du monolithe `backend/main.py`.**
|
||||
`POST /api/share/{vault}`, `GET /api/shares`, `DELETE /api/share/{share_id}`
|
||||
et les pages publiques `/s/{token}`, `/s/{token}/raw`, `/s/{token}/pdf`
|
||||
sont servis par le nouveau `backend/routers/sharing.py` — chemins,
|
||||
réponses, tags OpenAPI et authentification inchangés (aucun impact
|
||||
utilisateur).
|
||||
|
||||
## [2.27.3] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T2) — extraction du domaine `webhooks` hors du monolithe `backend/main.py`.**
|
||||
Le CRUD `GET/POST/PATCH/DELETE /api/webhooks` (admin) est servi par le
|
||||
nouveau `backend/routers/webhooks.py` — chemins, réponses, tags OpenAPI et
|
||||
authentification inchangés (aucun impact utilisateur).
|
||||
|
||||
## [2.27.2] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -23,7 +23,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
| Guide | Contenu |
|
||||
|---|---|
|
||||
| 🚀 [Prise en main](docs/GUIDES/PRISE_EN_MAIN.md) | Premier lancement, interface, navigation, vaults, raccourcis |
|
||||
| 🔍 [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
|
||||
| 🔍 [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 |
|
||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🖼️ 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` 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), plus le téléchargement du fichier d'origine
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` 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. 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)`
|
||||
- **🎨 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
|
||||
@@ -673,7 +673,7 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
|
||||
|
||||
## 🔍 Recherche avancée
|
||||
|
||||
> 📖 Guide complet : [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
> 📖 Guide complet : [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Syntaxe de requête
|
||||
|
||||
@@ -976,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.27.2).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.36.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.27.2 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.36.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -21,7 +21,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
| Guide | What it covers |
|
||||
|---|---|
|
||||
| 🚀 [Getting Started](docs/GUIDES/PRISE_EN_MAIN.md) | First run, interface, navigation, vaults, shortcuts |
|
||||
| 🔍 [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF viewer, diagrams |
|
||||
| 🔍 [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 |
|
||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🖼️ 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` 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), plus download of the original file
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` 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. 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
|
||||
- **🎨 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
|
||||
@@ -804,7 +804,7 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
|
||||
|
||||
## 🔍 Advanced Search
|
||||
|
||||
> 📖 Full guide: [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
> 📖 Full guide: [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Query Syntax
|
||||
|
||||
@@ -1151,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.27.2).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.36.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.27.2 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.36.0 | Last updated: September 2026*
|
||||
|
||||
+33
-26
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+54
-14
@@ -5,6 +5,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
@@ -15,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,
|
||||
@@ -56,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):
|
||||
@@ -218,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)
|
||||
@@ -229,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,
|
||||
@@ -253,7 +282,8 @@ 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"],
|
||||
@@ -299,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.
|
||||
@@ -332,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,
|
||||
}
|
||||
|
||||
@@ -426,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.
|
||||
@@ -441,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
|
||||
|
||||
|
||||
@@ -804,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")
|
||||
@@ -812,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"),
|
||||
@@ -847,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")
|
||||
@@ -885,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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
+45
-7
@@ -351,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.
|
||||
|
||||
@@ -561,11 +578,12 @@ def _scan_vault(
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
elif ext == ".xlsx":
|
||||
# #152 — binary workbook: metadata only, the viewer renders
|
||||
# it (parity with _index_single_file_sync).
|
||||
raw = ""
|
||||
# #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 = ""
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
@@ -807,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"])}
|
||||
@@ -882,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
|
||||
@@ -955,9 +987,9 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
elif ext == ".xlsx":
|
||||
# #152 — binary workbook: metadata only (parity with _scan_vault).
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
# #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()
|
||||
@@ -1226,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)
|
||||
|
||||
|
||||
+201
-4063
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,8 @@ def thumb_cache_path(file_path: Path, size: int) -> Path:
|
||||
stamp = f"{st.st_mtime_ns}:{st.st_size}"
|
||||
except OSError:
|
||||
stamp = "0:0"
|
||||
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest()
|
||||
# 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"
|
||||
|
||||
|
||||
|
||||
+21
-1
@@ -182,9 +182,29 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/save"): {
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}},
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
||||
},
|
||||
# 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},
|
||||
|
||||
+172
-1
@@ -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,7 +11,7 @@ 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
|
||||
pyotp>=2.10.0
|
||||
@@ -19,7 +19,7 @@ segno>=1.5.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
mcp==1.9.4
|
||||
mcp==1.28.1
|
||||
sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
python-docx>=1.1
|
||||
|
||||
@@ -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,593 @@
|
||||
"""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,
|
||||
XlsxSheetWindowResponse,
|
||||
)
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory
|
||||
|
||||
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/sheet", response_model=XlsxSheetWindowResponse
|
||||
)
|
||||
def api_file_xlsx_sheet(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx 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 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 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}")
|
||||
if file_path.suffix.lower() != ".xlsx":
|
||||
raise HTTPException(status_code=415, detail="Le fichier n'est pas un classeur .xlsx")
|
||||
|
||||
# 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
|
||||
|
||||
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,
|
||||
# #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}")
|
||||
|
||||
# === CSV: render as HTML table ===
|
||||
if ext == ".csv":
|
||||
import csv
|
||||
import io as csv_io
|
||||
reader = csv.reader(csv_io.StringIO(raw))
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
html = "<p><em>Fichier CSV vide</em></p>"
|
||||
else:
|
||||
headers = rows[0]
|
||||
data_rows = rows[1:]
|
||||
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
|
||||
for h in headers:
|
||||
html += f"<th>{h}</th>"
|
||||
html += "</tr></thead><tbody>"
|
||||
for row in data_rows:
|
||||
html += "<tr>"
|
||||
for cell in row:
|
||||
html += f"<td>{cell}</td>"
|
||||
html += "</tr>"
|
||||
html += "</tbody></table></div>"
|
||||
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,
|
||||
}
|
||||
|
||||
# === 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,590 @@
|
||||
"""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 (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_file as service_rename_file,
|
||||
)
|
||||
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}'
|
||||
),
|
||||
),
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
result = service_edit_xlsx_cells(
|
||||
vault_name, path, sheet, cells, **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"]}
|
||||
|
||||
|
||||
@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}'
|
||||
),
|
||||
),
|
||||
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.
|
||||
|
||||
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")
|
||||
|
||||
result = service_mutate_xlsx_structure(
|
||||
vault_name, path, actions, force=raw_force
|
||||
)
|
||||
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"])}
|
||||
|
||||
|
||||
@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,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,486 @@ 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_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_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 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")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+24
-9
@@ -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,
|
||||
@@ -371,9 +376,15 @@ class InvertedIndex:
|
||||
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.
|
||||
@@ -393,7 +404,7 @@ class InvertedIndex:
|
||||
self.vault_docs = defaultdict(set)
|
||||
self.tag_docs = 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
|
||||
@@ -537,6 +548,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 +693,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 +754,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 +789,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"]
|
||||
]
|
||||
@@ -1603,7 +1618,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
|
||||
|
||||
+350
-28
@@ -16,7 +16,10 @@ 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
|
||||
|
||||
@@ -230,10 +233,67 @@ _XLSX_CELL_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,7}$")
|
||||
# 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 _coerce_xlsx_value(value: Any) -> Any:
|
||||
"""Turn the string sent by the cell editor back into a scalar."""
|
||||
"""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()
|
||||
@@ -243,9 +303,48 @@ def _coerce_xlsx_value(value: Any) -> Any:
|
||||
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,
|
||||
@@ -253,15 +352,29 @@ def edit_xlsx_cells(
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
allow_formula: bool = False,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403) or
|
||||
``invalid`` (400) for a bad sheet, cell reference or value.
|
||||
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`).
|
||||
|
||||
ponytail: openpyxl round-trips values/formulas/styles but drops charts,
|
||||
images and pivot tables; use the SheetJS path if a workbook needs those.
|
||||
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)
|
||||
@@ -286,30 +399,52 @@ def edit_xlsx_cells(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
from openpyxl import load_workbook
|
||||
if not force:
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
try:
|
||||
wb = load_workbook(file_path)
|
||||
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},
|
||||
)
|
||||
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},
|
||||
)
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
from openpyxl import load_workbook
|
||||
|
||||
ws = wb[sheet]
|
||||
for ref, value in cells.items():
|
||||
ws[ref].value = _coerce_xlsx_value(value)
|
||||
wb.save(file_path)
|
||||
try:
|
||||
wb = load_workbook(file_path)
|
||||
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
|
||||
|
||||
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||
return {
|
||||
@@ -320,6 +455,193 @@ def edit_xlsx_cells(
|
||||
}
|
||||
|
||||
|
||||
def mutate_xlsx_structure(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
actions: list[dict[str, Any]],
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
) -> 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)):
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
f"XLSX structure: {vault_name}/{rel_path} {applied}"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
}
|
||||
|
||||
|
||||
def append_to_file(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
|
||||
+48
-39
@@ -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,10 @@ _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"),
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
|
||||
@@ -315,6 +315,61 @@ 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 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")
|
||||
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 CsvInput(BaseModel):
|
||||
"""Create a .csv file in a vault from rows of cells."""
|
||||
|
||||
|
||||
+17
-11
@@ -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,286 @@
|
||||
"""Spreadsheet tools (#153 A6) — read and mutate existing ``.xlsx`` workbooks.
|
||||
|
||||
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
|
||||
file; here the assistant can read and edit one that already exists):
|
||||
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (wraps the guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet.
|
||||
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`,
|
||||
which already carries the #153 P0 guards: per-file lock, atomic replace,
|
||||
formula neutralisation (``allow_formula`` opt-in) and the lossy-write 409.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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 (
|
||||
AppendXlsxRowsInput,
|
||||
ListXlsxSheetsInput,
|
||||
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
|
||||
|
||||
|
||||
def _workbook_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx`` path."""
|
||||
path = (path or "").strip()
|
||||
if not path.lower().endswith(".xlsx"):
|
||||
raise ToolError("Extension attendue : .xlsx", 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 _map_service_error(e: ServiceError) -> ToolError:
|
||||
return ToolError(e.message, code=e.code, details=e.details)
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of an .xlsx workbook 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."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS, _sheet_extent
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
|
||||
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:
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
total_rows, total_cols = _sheet_extent(ws)
|
||||
sheets.append(
|
||||
{
|
||||
"name": ws.title,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
)
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
@tool(
|
||||
name="xlsx_to_markdown",
|
||||
description=(
|
||||
"Read a sheet of an .xlsx workbook 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."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
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 params.sheet:
|
||||
if params.sheet not in wb.sheetnames:
|
||||
raise ToolError(
|
||||
f"Feuille introuvable: {params.sheet}", code="not_found"
|
||||
)
|
||||
ws = wb[params.sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
rows: list[list[str]] = []
|
||||
truncated = False
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=MAX_MD_ROWS, max_col=MAX_MD_COLS, values_only=True
|
||||
):
|
||||
cells = [_fmt(v) for v in row]
|
||||
if not any(c.strip() for c in cells):
|
||||
continue
|
||||
rows.append(cells)
|
||||
# Real tail beyond the caps? Probe one row further.
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=MAX_MD_ROWS + 1,
|
||||
max_row=MAX_MD_ROWS + 1,
|
||||
max_col=MAX_MD_COLS,
|
||||
values_only=True,
|
||||
)
|
||||
)
|
||||
if any(any(str(v or "").strip() for v in r) for r in probe):
|
||||
truncated = True
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
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="update_xlsx_cells",
|
||||
description=(
|
||||
"Edit cells of an existing .xlsx workbook. ``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…)."
|
||||
),
|
||||
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."""
|
||||
from backend.services.mutations import edit_xlsx_cells
|
||||
|
||||
if not params.cells:
|
||||
raise ToolError("Aucune cellule fournie", code="invalid_arguments")
|
||||
try:
|
||||
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 .xlsx workbook. "
|
||||
"Values are typed like in the viewer (numbers, TRUE/FALSE, FR dates "
|
||||
"JJ/MM/AAAA). The workbook is rewritten atomically with a backup."
|
||||
),
|
||||
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 = _workbook_path(params.vault, params.path)
|
||||
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,
|
||||
}
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
|
||||
+54
-43
@@ -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
|
||||
|
||||
|
||||
|
||||
+375
-15
@@ -3,11 +3,17 @@
|
||||
Read-only: formulas are shown as their text (``data_only=False``) so a
|
||||
round-trip through the viewer never depends on Excel's cached values.
|
||||
Write-side lives in ``backend.services.mutations.edit_xlsx_cells``.
|
||||
|
||||
:func:`inspect_workbook` lists the workbook features that an openpyxl
|
||||
round-trip would drop (#153 A1) so the UI can warn before saving.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -15,11 +21,50 @@ from typing import Any
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
logger = logging.getLogger("obsigate.xlsx_reader")
|
||||
|
||||
# ponytail: hard caps bound the rendered grid (500 rows x 40 cols per sheet).
|
||||
# Raise them, or paginate per sheet, if a real workbook needs more.
|
||||
MAX_ROWS = 500
|
||||
MAX_COLS = 40
|
||||
|
||||
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
|
||||
# loading). The endpoint is bounded so a single request can never ask for the
|
||||
# whole workbook back in one JSON payload; the UI pages through the rest.
|
||||
MAX_WINDOW_ROWS = 1_000
|
||||
DEFAULT_WINDOW_ROWS = 200
|
||||
|
||||
# #153 A1 — workbook parts openpyxl does not re-serialize on load+save.
|
||||
# Verified against openpyxl 3.1.5: charts, images, drawings and pivot tables
|
||||
# DO survive the round-trip, so they are deliberately absent from this map.
|
||||
LOSSY_PARTS: dict[str, tuple[str, ...]] = {
|
||||
"slicers": ("xl/slicers/", "xl/slicerCaches/", "xl/timelines/"),
|
||||
"form_controls": ("xl/ctrlProps/", "xl/activeX/"),
|
||||
"connections": ("xl/queryTables/", "xl/connections.xml"),
|
||||
"custom_xml": ("customXml/",),
|
||||
"signature": ("_xmlsignatures/",),
|
||||
"rich_comments": ("xl/threadedComments/", "xl/persons/"),
|
||||
"macros": ("xl/vbaProject.bin",),
|
||||
}
|
||||
|
||||
# A formula cell carrying its last computed result: ``<f>…</f><v>…</v>``.
|
||||
# openpyxl writes an EMPTY ``<v></v>`` itself, hence the ``[^<]`` guard: only a
|
||||
# non-empty value counts. openpyxl keeps the formula but drops the cached result,
|
||||
# so any reader using ``data_only=True`` (pandas, converters) sees ``None`` until
|
||||
# Excel recalculates.
|
||||
_CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
|
||||
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||
_MAX_PROBE_BYTES = 8_000_000
|
||||
|
||||
# #153 A5 — ceiling on the text handed to the TF-IDF / semantic index. A workbook
|
||||
# is a data dump, not prose: indexing every cell would flood the inverted index
|
||||
# and bury the notes. Sheet names + the first rows are enough to make a
|
||||
# spreadsheet findable by its headers.
|
||||
MAX_INDEX_CHARS = 5_000
|
||||
_INDEX_ROWS_PER_SHEET = 20
|
||||
MAX_INDEX_SHEETS = 20
|
||||
|
||||
|
||||
def _fmt(value: Any) -> str:
|
||||
if value is None:
|
||||
@@ -46,7 +91,37 @@ def _trim(grid: list[list[str]]) -> list[list[str]]:
|
||||
return [row[:width] for row in grid]
|
||||
|
||||
|
||||
def _table(grid: list[list[str]]) -> str:
|
||||
def _cell_cached(cached: list[list[str]] | None, r: int, c: int) -> str:
|
||||
"""Return the cached result for a 0-based cell, or ``""``.
|
||||
|
||||
The shadow grid is read positionally and may be narrower than the formula
|
||||
grid (``_trim`` collapses the trailing empty columns of each grid
|
||||
independently), so every lookup is bounds-checked rather than assumed.
|
||||
"""
|
||||
if not cached or r >= len(cached):
|
||||
return ""
|
||||
row = cached[r]
|
||||
return row[c] if c < len(row) else ""
|
||||
|
||||
|
||||
def _table(
|
||||
grid: list[list[str]],
|
||||
cached: list[list[str]] | None = None,
|
||||
row_offset: int = 0,
|
||||
) -> str:
|
||||
"""Render a grid as an HTML table.
|
||||
|
||||
``cached`` is the same grid read with ``data_only=True`` (#153 A12): where a
|
||||
formula cell still carries its last computed result, it is shown as a
|
||||
discreet second line (``<span class="xlsx-cached">``) so the user sees the
|
||||
number Excel last calculated instead of only the formula text. The span
|
||||
carries ``data-cached-value`` and is titled client-side from
|
||||
``xlsx.cached_value_title`` — the backend never emits UI text.
|
||||
|
||||
``row_offset`` is the number of rows skipped before this grid (#153 A9): the
|
||||
row numbers and the ``data-cell`` references must stay the real A1
|
||||
coordinates of the sheet, not of the window.
|
||||
"""
|
||||
if not grid:
|
||||
return "<p><em>Feuille vide</em></p>"
|
||||
n_cols = max(len(row) for row in grid)
|
||||
@@ -58,29 +133,314 @@ def _table(grid: list[list[str]]) -> str:
|
||||
]
|
||||
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
|
||||
out.append("</tr></thead><tbody>")
|
||||
for r, row in enumerate(grid, start=1):
|
||||
for r, row in enumerate(grid, start=row_offset + 1):
|
||||
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
||||
for c, val in enumerate(row, start=1):
|
||||
ref = f"{get_column_letter(c)}{r}"
|
||||
out.append(f'<td data-cell="{ref}">{html.escape(val)}</td>')
|
||||
# The cached result only makes sense for a formula cell: on a plain
|
||||
# value cell the two reads are identical and showing both would
|
||||
# duplicate the text.
|
||||
shadow = ""
|
||||
if cached is not None and val.startswith("="):
|
||||
# `c` is 1-based (A1 notation) and `r` too, while the grid is
|
||||
# 0-based: translate both.
|
||||
cval = _cell_cached(cached, r - 1, c - 1)
|
||||
if cval and cval != val:
|
||||
# The tooltip is translated client-side from
|
||||
# `xlsx.cached_value_title`; never hardcode UI text here.
|
||||
shadow = (
|
||||
f'<span class="xlsx-cached" data-cached-value="1">'
|
||||
f"{html.escape(cval)}</span>"
|
||||
)
|
||||
out.append(
|
||||
f'<td data-cell="{ref}">{html.escape(val)}{shadow}</td>'
|
||||
)
|
||||
out.append("</tr>")
|
||||
out.append("</tbody></table></div>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def render_sheets(file_path: Path) -> list[dict[str, str]]:
|
||||
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``."""
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value."""
|
||||
budget = _MAX_PROBE_BYTES
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"):
|
||||
continue
|
||||
try:
|
||||
with zf.open(name) as fh:
|
||||
while budget > 0:
|
||||
chunk = fh.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
budget -= len(chunk)
|
||||
if _CACHED_FORMULA_RE.search(chunk):
|
||||
return True
|
||||
except (KeyError, OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def inspect_workbook(file_path: Path) -> list[str]:
|
||||
"""Return the sorted keys of :data:`LOSSY_PARTS` present in *file_path*.
|
||||
|
||||
Read-only inspection of the OPC package (central directory + a bounded scan
|
||||
of the sheet XML). Never raises: an unreadable or encrypted workbook simply
|
||||
yields ``[]`` and the save path keeps its current behaviour.
|
||||
|
||||
``cached_values`` is a synthetic key: openpyxl keeps the formula but drops
|
||||
the cached result, so the workbook stays correct once Excel recalculates it.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
names = set(zf.namelist())
|
||||
found = {
|
||||
key
|
||||
for key, prefixes in LOSSY_PARTS.items()
|
||||
if any(name.startswith(prefix) for name in names for prefix in prefixes)
|
||||
}
|
||||
if _has_cached_formulas(zf):
|
||||
found.add("cached_values")
|
||||
return sorted(found)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return []
|
||||
|
||||
|
||||
def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
|
||||
|
||||
Reads the workbook twice: once with ``data_only=False`` for the formulas
|
||||
(what the user must edit) and, when any formula carries a cached result
|
||||
(#153 A12), once with ``data_only=True`` to show what Excel last computed.
|
||||
The second pass is skipped entirely when the archive holds no cached value,
|
||||
so the common case still costs a single load.
|
||||
|
||||
``total_rows``/``total_cols`` are the dimensions the sheet declares and
|
||||
``truncated`` says whether the hard caps actually cut it (#153 A8) — the
|
||||
viewer needs both to stop silently hiding the tail of a sheet.
|
||||
"""
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||
try:
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
grid = [
|
||||
[_fmt(v) for v in row]
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True
|
||||
)
|
||||
]
|
||||
sheets.append({"name": ws.title, "html": _table(_trim(grid))})
|
||||
return sheets
|
||||
formulas = [_sheet_grid(ws) for ws in wb.worksheets]
|
||||
titles = [ws.title for ws in wb.worksheets]
|
||||
extents = [_sheet_extent(ws) for ws in wb.worksheets]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
cached: list[list[list[str]]] | None = None
|
||||
if _has_cached_values(file_path):
|
||||
cached = _read_cached_grids(file_path, titles)
|
||||
|
||||
sheets = []
|
||||
for i, title in enumerate(titles):
|
||||
grid = _trim(formulas[i])
|
||||
# The shadow grid is NOT trimmed independently: _trim drops the
|
||||
# trailing empty columns of each grid on its own width, which would
|
||||
# shift every cached value left of its formula. Indexing it
|
||||
# positionally against the untrimmed grid keeps the two aligned.
|
||||
shadow = cached[i] if cached is not None and i < len(cached) else None
|
||||
total_rows, total_cols = extents[i]
|
||||
sheets.append(
|
||||
{
|
||||
"name": title,
|
||||
"html": _table(grid, shadow),
|
||||
"rows": len(grid),
|
||||
"cols": max((len(r) for r in grid), default=0),
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
# Coverage, not display size: `rows`/`cols` are post-trim (a
|
||||
# sheet of 3 filled cells in a 500-row block renders 1x1), and
|
||||
# the client must announce the cap it stopped at, not how many
|
||||
# cells happen to be non-empty.
|
||||
"max_rows": MAX_ROWS,
|
||||
"max_cols": MAX_COLS,
|
||||
# A sheet is truncated when the caps, not the trailing blanks,
|
||||
# decided its shape: comparing against the *rendered* size would
|
||||
# flag every sheet carrying a few empty formatted rows.
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
)
|
||||
return sheets
|
||||
|
||||
|
||||
def read_sheet_window(
|
||||
file_path: Path,
|
||||
sheet: str,
|
||||
offset: int = 0,
|
||||
limit: int = DEFAULT_WINDOW_ROWS,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a window of rows of one sheet, or ``None`` if the sheet is unknown.
|
||||
|
||||
Backs the lazy per-sheet loading of #153 A9: the viewer asks for the rows
|
||||
it is about to display instead of shipping every sheet in the initial file
|
||||
payload. ``offset`` is 0-based; the row numbers and the ``data-cell``
|
||||
references in the returned ``html`` are the real A1 coordinates of the
|
||||
sheet, so a window is indistinguishable from a full render.
|
||||
|
||||
``limit`` is clamped to :data:`MAX_WINDOW_ROWS`. Raises nothing: an unknown
|
||||
sheet yields ``None`` and a broken workbook propagates the caller's usual
|
||||
500.
|
||||
"""
|
||||
offset = max(int(offset), 0)
|
||||
limit = min(max(int(limit), 1), MAX_WINDOW_ROWS)
|
||||
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||
try:
|
||||
if sheet not in wb.sheetnames:
|
||||
return None
|
||||
ws = wb[sheet]
|
||||
total_rows, total_cols = _sheet_extent(ws)
|
||||
grid = _trim(
|
||||
_sheet_grid(ws, min_row=offset + 1, max_row=offset + limit)
|
||||
)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
shadow: list[list[str]] | None = None
|
||||
# Same A12 rule as the full render: the second read only happens when the
|
||||
# archive really holds cached results.
|
||||
if _has_cached_values(file_path):
|
||||
shadow = _read_cached_window(file_path, sheet, offset, limit)
|
||||
return {
|
||||
"sheet": sheet,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"rows": len(grid),
|
||||
"cols": max((len(r) for r in grid), default=0),
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"max_rows": MAX_ROWS,
|
||||
"max_cols": MAX_COLS,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
"has_more": offset + len(grid) < total_rows,
|
||||
"html": _table(grid, shadow, row_offset=offset),
|
||||
}
|
||||
|
||||
|
||||
def _read_cached_window(
|
||||
file_path: Path, sheet: str, offset: int, limit: int
|
||||
) -> list[list[str]] | None:
|
||||
"""``data_only=True`` grid for one window, or ``None`` if unavailable.
|
||||
|
||||
Best effort like :func:`_read_cached_grids`: a workbook Excel opens but
|
||||
openpyxl cannot re-read must still display (formulas only).
|
||||
"""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
if sheet not in wb.sheetnames:
|
||||
return None
|
||||
return _sheet_grid(
|
||||
wb[sheet], min_row=offset + 1, max_row=offset + limit
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("xlsx cached window unavailable", exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _sheet_extent(ws: Any) -> tuple[int, int]:
|
||||
"""Rows and columns the worksheet declares, never negative.
|
||||
|
||||
``max_row``/``max_column`` come from the sheet's dimension record; a
|
||||
hand-edited file may omit it, hence the defensive coercion.
|
||||
"""
|
||||
try:
|
||||
rows = max(int(getattr(ws, "max_row", 0) or 0), 0)
|
||||
except (TypeError, ValueError):
|
||||
rows = 0
|
||||
try:
|
||||
cols = max(int(getattr(ws, "max_column", 0) or 0), 0)
|
||||
except (TypeError, ValueError):
|
||||
cols = 0
|
||||
return rows, cols
|
||||
|
||||
|
||||
def _sheet_grid(
|
||||
ws: Any, min_row: int = 1, max_row: int = MAX_ROWS, max_col: int = MAX_COLS
|
||||
) -> list[list[str]]:
|
||||
"""Read a worksheet window into a grid of formatted strings, bounded by the caps."""
|
||||
return [
|
||||
[_fmt(v) for v in row]
|
||||
for row in ws.iter_rows(
|
||||
min_row=min_row, max_row=max_row, max_col=max_col, values_only=True
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _has_cached_values(file_path: Path) -> bool:
|
||||
"""True when the archive holds at least one ``<f>…</f><v>…</v>``."""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
return _has_cached_formulas(zf)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return False
|
||||
|
||||
|
||||
def _read_cached_grids(
|
||||
file_path: Path, titles: list[str]
|
||||
) -> list[list[list[str]]] | None:
|
||||
"""Read every sheet with ``data_only=True`` (what Excel last computed).
|
||||
|
||||
Best effort: returns ``None`` on any failure so the viewer falls back to the
|
||||
formula-only rendering. A workbook Excel opens but openpyxl cannot re-read
|
||||
must still display.
|
||||
"""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
grids = [_sheet_grid(ws) for ws in wb.worksheets]
|
||||
if [ws.title for ws in wb.worksheets] != titles:
|
||||
return None
|
||||
return grids
|
||||
except Exception:
|
||||
logger.debug("xlsx cached values unavailable", exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def extract_indexable_text(file_path: Path) -> str:
|
||||
"""Return searchable text for the TF-IDF / semantic index (#153 A5).
|
||||
|
||||
Sheet names plus the first :data:`_INDEX_ROWS_PER_SHEET` rows of each
|
||||
sheet, capped at :data:`MAX_INDEX_CHARS`. Rows are tab-joined so a search
|
||||
for a header matches the sheet it belongs to.
|
||||
|
||||
Never raises: a corrupt, encrypted or unsupported workbook yields ``""`` so
|
||||
the file still gets indexed by name (same contract as :func:`inspect_workbook`).
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
budget = MAX_INDEX_CHARS
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
# Encrypted (BadZipFile) or not a real workbook: name-only indexing.
|
||||
return ""
|
||||
try:
|
||||
for ws in wb.worksheets[:MAX_INDEX_SHEETS]:
|
||||
if budget <= 0:
|
||||
break
|
||||
# The sheet title alone is a strong signal ("Recettes", "Budget").
|
||||
block = [ws.title]
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=_INDEX_ROWS_PER_SHEET, max_col=MAX_COLS, values_only=True
|
||||
):
|
||||
cells = [_fmt(v) for v in row]
|
||||
# Skip blank rows instead of emitting runs of tabs.
|
||||
if not any(c.strip() for c in cells):
|
||||
continue
|
||||
block.append("\t".join(cells).rstrip())
|
||||
text = "\n".join(block)
|
||||
chunks.append(text[:budget])
|
||||
budget -= len(text)
|
||||
except Exception:
|
||||
# Truncated but still useful: keep whatever was collected.
|
||||
pass
|
||||
finally:
|
||||
wb.close()
|
||||
return "\n".join(c for c in chunks if c).strip()
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.27.2"
|
||||
version = "2.36.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.27.2"
|
||||
version = "2.36.0"
|
||||
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.27.2",
|
||||
"version": "2.36.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+6
-1
@@ -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:
|
||||
|
||||
@@ -6,7 +6,7 @@ vaults Obsidian et raccourcis essentiels.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Durée de lecture :** ~10 min
|
||||
> **Voir aussi :** [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) ·
|
||||
> [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) ·
|
||||
> [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) ·
|
||||
> [API REST](./API_REST.md)
|
||||
|
||||
---
|
||||
@@ -185,7 +185,7 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
|
||||
La recherche est un point fort d'ObsiGate : index inversé TF-IDF, stemming
|
||||
français, normalisation des accents, facettes et pagination. La syntaxe complète
|
||||
(`tag:`, `#`, `vault:`, `title:`, `path:`, `ext:`, phrases exactes) est décrite
|
||||
dans le [Guide Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md).
|
||||
dans le [Guide Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md).
|
||||
|
||||
Démarrage rapide :
|
||||
|
||||
@@ -234,7 +234,7 @@ Voir [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md).
|
||||
|
||||
| Objectif | Guide |
|
||||
|---|---|
|
||||
| Mieux chercher, lire PDF et Excalidraw | [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) |
|
||||
| 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) |
|
||||
|
||||
@@ -15,7 +15,7 @@ captures conceptuelles).
|
||||
| Guide | Public | Contenu |
|
||||
|---|---|---|
|
||||
| 🚀 [Prise en main](./PRISE_EN_MAIN.md) | Tous | Premier lancement, interface, navigation, vaults, raccourcis |
|
||||
| 🔍 [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
|
||||
| 🔍 [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 |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 🔍 Guide Recherche, PDF & Excalidraw
|
||||
# 🔍 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
|
||||
@@ -131,7 +131,83 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
||||
|
||||
---
|
||||
|
||||
## 6. Diagrammes Excalidraw
|
||||
## 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, les en-têtes A1/B1 et les
|
||||
numéros de ligne. 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).
|
||||
|
||||
### 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. Si vous refusez, rien n'est écrit.
|
||||
|
||||
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
|
||||
|
||||
### 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`).
|
||||
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
|
||||
**409** `conflict` au lieu d'écraser la première.
|
||||
|
||||
### 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 pas un `.xlsx`.
|
||||
|
||||
### 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.
|
||||
- Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas
|
||||
rendus.
|
||||
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Diagrammes Excalidraw
|
||||
|
||||
Les fichiers `.excalidraw` et `.excalidraw.md` (dont le format compressé du
|
||||
**plugin Obsidian Excalidraw**) s'ouvrent dans un **éditeur visuel Excalidraw
|
||||
@@ -147,7 +223,7 @@ Fiche technique : [`features/excalidraw.md`](../features/excalidraw.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Autres contenus riches
|
||||
## 8. Autres contenus riches
|
||||
|
||||
### Mermaid
|
||||
|
||||
@@ -182,7 +258,7 @@ curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes"
|
||||
|
||||
---
|
||||
|
||||
## 8. Dépannage
|
||||
## 9. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
|
||||
+20
-3
@@ -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-24
|
||||
- **Dernière mise à jour** : 2026-09-27
|
||||
|
||||
---
|
||||
|
||||
@@ -188,13 +188,20 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *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 |
|
||||
|
||||
### 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)* | | | | | | | | | |
|
||||
|
||||
---
|
||||
@@ -207,6 +214,9 @@ 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-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) |
|
||||
@@ -273,6 +283,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -283,7 +298,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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+66
-20
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.27.2 | **Dernière mise à jour :** 2026-09-26
|
||||
> **Version :** 2.36.0 | **Dernière mise à jour :** 2026-09-28
|
||||
> **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)**
|
||||
@@ -42,6 +42,56 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 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 :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17
|
||||
- **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) — 🔵 en cours**
|
||||
- [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é)
|
||||
- [ ] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets`
|
||||
- [ ] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches, `Maj+Entrée`, copie de plage)
|
||||
- [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)
|
||||
- [ ] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI
|
||||
- [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) — ⚪ à faire**
|
||||
- [ ] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection
|
||||
- [ ] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer)
|
||||
- [ ] **A15** Styles minimaux en écriture + lecture fidèle (gras, fond, formats, fusions, volets figés)
|
||||
- [ ] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`, `.ods`, `.csv` éditable)
|
||||
- [ ] **A17** Vue « tableau de bord » (plages nommées, TCD, KPI par feuille, actions IA)
|
||||
- **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).
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
@@ -63,28 +113,20 @@
|
||||
|
||||
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
|
||||
|
||||
### 85. Refonte architecturale — découpage du monolithe & persistance d'état (phase 2)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
||||
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`).
|
||||
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) 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. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
|
||||
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
|
||||
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
|
||||
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
|
||||
- [ ] Extraire le 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/`
|
||||
- **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é **bloquants** (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 ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`)
|
||||
- [ ] 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)
|
||||
- [ ] 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
|
||||
|
||||
---
|
||||
@@ -164,6 +206,7 @@
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -171,17 +214,20 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–115, #117, #92 | ~133 jours réalisés |
|
||||
| ✅ 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 | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
|
||||
| **Total chemin critique** | **#77 fin + #85 + #87** | **~12-18 jours** |
|
||||
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
|
||||
| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; P1 ✅ A5, A8-A10, A12, A9bis — reste A6-A7 ; A13-A17 2-4 j) | 2-4 jours restants |
|
||||
| **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).
|
||||
- **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.
|
||||
|
||||
@@ -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,235 @@
|
||||
# #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 :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17
|
||||
> **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` | 11 tests backend (affichage, index, save, backup, 400) |
|
||||
|
||||
## 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)
|
||||
|
||||
### 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) — 🟢 A5, A10, A12 livrés le 2026-09-28
|
||||
|
||||
- [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).
|
||||
- [ ] **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`.
|
||||
- [ ] **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`).
|
||||
- [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).
|
||||
- [ ] **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.
|
||||
- [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)
|
||||
|
||||
- [ ] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
|
||||
- [ ] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
|
||||
- [ ] **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.
|
||||
- [ ] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
|
||||
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion.
|
||||
- [ ] **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.
|
||||
|
||||
## 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) |
|
||||
+1
-31
@@ -1576,17 +1576,6 @@
|
||||
class="help-search-clear"
|
||||
id="config-search-clear"
|
||||
title="Effacer"
|
||||
onclick="
|
||||
var s =
|
||||
document.getElementById(
|
||||
'config-nav-search',
|
||||
);
|
||||
if (s) {
|
||||
s.value = '';
|
||||
s.dispatchEvent(new Event('input'));
|
||||
s.focus();
|
||||
}
|
||||
"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
@@ -1698,7 +1687,7 @@
|
||||
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
|
||||
</div>
|
||||
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
|
||||
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
|
||||
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout">Déconnexion</button>
|
||||
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -3005,14 +2994,6 @@
|
||||
id="help-hamburger"
|
||||
title="Sommaire"
|
||||
aria-label="Afficher le sommaire"
|
||||
onclick="
|
||||
var n = document.getElementById('help-nav');
|
||||
if (n) {
|
||||
var d = n.style.display;
|
||||
n.style.display =
|
||||
d === 'none' || d === '' ? 'flex' : 'none';
|
||||
}
|
||||
"
|
||||
>
|
||||
<i
|
||||
data-lucide="menu"
|
||||
@@ -3077,17 +3058,6 @@
|
||||
id="help-search-clear"
|
||||
title="Effacer la recherche"
|
||||
aria-label="Effacer"
|
||||
onclick="
|
||||
var s =
|
||||
document.getElementById(
|
||||
'help-nav-search',
|
||||
);
|
||||
if (s) {
|
||||
s.value = '';
|
||||
s.dispatchEvent(new Event('input'));
|
||||
s.focus();
|
||||
}
|
||||
"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
+24
-3
@@ -72,14 +72,25 @@ async function api(path, opts) {
|
||||
}
|
||||
if (!res.ok) {
|
||||
var detail = "";
|
||||
var code = "";
|
||||
var details = null;
|
||||
try {
|
||||
var body = await res.json();
|
||||
detail = body.detail || "";
|
||||
// #153 A1 : the service layer exposes a stable code + details so callers
|
||||
// can branch on the failure (e.g. confirm a lossy .xlsx write) instead of
|
||||
// matching on the message.
|
||||
code = body.code || "";
|
||||
details = body.details || null;
|
||||
} catch (_) {
|
||||
/* no json body */
|
||||
}
|
||||
showToast(detail || "Erreur API : " + res.status, "error");
|
||||
throw new Error(detail || "API error: " + res.status);
|
||||
var apiError = new Error(detail || "API error: " + res.status);
|
||||
apiError.status = res.status;
|
||||
apiError.code = code;
|
||||
apiError.details = details;
|
||||
throw apiError;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -1305,8 +1316,7 @@ async function _startMfaSetup() {
|
||||
// secret when the backend has no QR generator available.
|
||||
const qrImg = data.qr_data_url
|
||||
? `<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code-img"
|
||||
src="${data.qr_data_url}"
|
||||
onerror="this.style.display='none';document.getElementById('mfa-qr-fallback').style.display='block';">`
|
||||
src="${data.qr_data_url}">`
|
||||
: "";
|
||||
const fallbackStyle = data.qr_data_url ? "display:none" : "";
|
||||
flowArea.innerHTML = `
|
||||
@@ -1335,6 +1345,17 @@ async function _startMfaSetup() {
|
||||
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
|
||||
});
|
||||
|
||||
// QR fallback (#87, ex-onerror inline) : si l'image ne charge pas,
|
||||
// afficher la saisie manuelle du secret.
|
||||
const qrImgEl = document.getElementById("mfa-qr-img");
|
||||
if (qrImgEl) {
|
||||
qrImgEl.addEventListener("error", () => {
|
||||
qrImgEl.style.display = "none";
|
||||
const fallback = document.getElementById("mfa-qr-fallback");
|
||||
if (fallback) fallback.style.display = "block";
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("mfa-confirm-btn").addEventListener("click", async () => {
|
||||
const code = codeInput.value.trim();
|
||||
if (code.length !== 6) return;
|
||||
|
||||
@@ -60,12 +60,14 @@ const MUTATING_TOOLS = new Set([
|
||||
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
|
||||
'delete_file', 'delete_directory', 'restore_backup',
|
||||
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
||||
'update_xlsx_cells', 'append_xlsx_rows',
|
||||
]);
|
||||
// Subset carrying a concrete `vault` + `path`: the displayed document is
|
||||
// reloaded from disk so an open viewer/editor reflects the agent's write.
|
||||
const FILE_WRITE_TOOLS = new Set([
|
||||
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
|
||||
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
||||
'update_xlsx_cells', 'append_xlsx_rows',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
+40
-4
@@ -363,6 +363,27 @@ function initHelpModal() {
|
||||
}
|
||||
});
|
||||
|
||||
// Help TOC hamburger + search clear (#87, ex-onclick inline in index.html).
|
||||
var helpHamburger = document.getElementById("help-hamburger");
|
||||
if (helpHamburger) {
|
||||
helpHamburger.addEventListener("click", function() {
|
||||
var n = document.getElementById("help-nav");
|
||||
if (n) {
|
||||
var d = n.style.display;
|
||||
n.style.display = d === "none" || d === "" ? "flex" : "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
var helpSearch = document.getElementById("help-nav-search");
|
||||
var helpSearchClear = document.getElementById("help-search-clear");
|
||||
if (helpSearchClear && helpSearch) {
|
||||
helpSearchClear.addEventListener("click", function() {
|
||||
helpSearch.value = "";
|
||||
helpSearch.dispatchEvent(new Event("input"));
|
||||
helpSearch.focus();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.classList.contains("active")) {
|
||||
closeHelpModal();
|
||||
@@ -883,7 +904,7 @@ function initConfigModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// Logout button — handled inline in index.html (onclick)
|
||||
// Logout button — wired here with addEventListener (#87, no inline onclick)
|
||||
|
||||
// Config nav search
|
||||
var cfgSearch = document.getElementById("config-nav-search");
|
||||
@@ -901,6 +922,16 @@ function initConfigModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// Config search clear button (#87, ex-onclick inline).
|
||||
var cfgSearchClear = document.getElementById("config-search-clear");
|
||||
if (cfgSearchClear && cfgSearch) {
|
||||
cfgSearchClear.addEventListener("click", function() {
|
||||
cfgSearch.value = "";
|
||||
cfgSearch.dispatchEvent(new Event("input"));
|
||||
cfgSearch.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav
|
||||
// rule that hides it below 768px, but — unlike the help modal — the config
|
||||
// modal had no toggle to reveal it, leaving mobile users with no way to
|
||||
@@ -1191,7 +1222,7 @@ function renderDiagnostics(container, data) {
|
||||
["Postings total", data.inverted_index.total_postings.toLocaleString()],
|
||||
["Documents", data.inverted_index.documents],
|
||||
["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"],
|
||||
["Stale", data.inverted_index.is_stale ? "Oui" : "Non"],
|
||||
["Index prêt", data.inverted_index.is_ready ? "Oui" : "Non"],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1575,13 +1606,15 @@ export async function openShareDialog(vault, path) {
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
|
||||
${expiresInfo}
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p>
|
||||
<input type="text" class="share-url-input" value="${url}" readonly onclick="this.select()">
|
||||
<input type="text" class="share-url-input" value="${url}" readonly>
|
||||
<div class="share-dialog-actions">
|
||||
<button class="share-copy-btn">📋 Copier le lien</button>
|
||||
<button class="share-revoke-btn">🗑 Révoquer</button>
|
||||
<button class="share-close-btn">Fermer</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const shareUrlInput = div.querySelector(".share-url-input");
|
||||
if (shareUrlInput) shareUrlInput.addEventListener("click", function() { shareUrlInput.select(); });
|
||||
div.querySelector(".share-copy-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
@@ -2525,7 +2558,10 @@ function initProfile() {
|
||||
} catch(e) {}
|
||||
});
|
||||
|
||||
// Logout button — handled inline in index.html (onclick)
|
||||
// Logout button (#87, ex-onclick inline in index.html).
|
||||
if (logoutBtn && window.handleLogout) {
|
||||
logoutBtn.addEventListener('click', function() { window.handleLogout(); });
|
||||
}
|
||||
|
||||
// ── Avatar (#113) ────────────────────────────────────────────────
|
||||
var avatarField = document.getElementById('profile-avatar-field');
|
||||
|
||||
@@ -35,7 +35,7 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
|
||||
// Build the iframe
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.id = editorId;
|
||||
iframe.src = '/static/excalidraw-editor.html?v=' + Date.now();
|
||||
iframe.src = '/excalidraw-editor.html?v=' + Date.now();
|
||||
iframe.sandbox.add('allow-scripts');
|
||||
iframe.sandbox.add('allow-same-origin');
|
||||
// Let the editor's own Fullscreen button work (native Fullscreen API inside
|
||||
|
||||
@@ -478,8 +478,8 @@ function openTemplateModal() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
|
||||
'<button class="btn btn-secondary" onclick="copyTemplate()">Copy to Clipboard</button>' +
|
||||
'<button class="btn btn-primary btn-modal-close" data-i18n="common.close">Close</button>' +
|
||||
'<button class="btn btn-secondary btn-copy-template">Copy to Clipboard</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
@@ -487,11 +487,11 @@ function openTemplateModal() {
|
||||
|
||||
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||
|
||||
window.copyTemplate = () => {
|
||||
modal.querySelector('.btn-modal-close')?.addEventListener('click', () => modal.remove());
|
||||
modal.querySelector('.btn-copy-template')?.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(template, null, 2));
|
||||
showToast('Template copied to clipboard', 'success');
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -508,12 +508,13 @@ function openCodeModal(name, code) {
|
||||
'<pre class="code-block" style="max-height: 500px; overflow: auto;">' + escapeHtml(code) + '</pre>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
|
||||
'<button class="btn btn-primary btn-modal-close" data-i18n="common.close">Close</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
document.body.appendChild(modal);
|
||||
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
|
||||
modal.querySelector('.btn-modal-close')?.addEventListener('click', () => modal.remove());
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -543,10 +543,16 @@ function showUpdateNotification() {
|
||||
message.innerHTML = `
|
||||
<div class="pwa-update-content">
|
||||
<span>Une nouvelle version d'ObsiGate est disponible !</span>
|
||||
<button class="pwa-update-btn" onclick="window.location.reload()">Mettre à jour</button>
|
||||
<button class="pwa-update-dismiss" onclick="this.parentElement.parentElement.remove()">×</button>
|
||||
<button class="pwa-update-btn">Mettre à jour</button>
|
||||
<button class="pwa-update-dismiss">×</button>
|
||||
</div>
|
||||
`;
|
||||
message.querySelector(".pwa-update-btn").addEventListener("click", function() {
|
||||
window.location.reload();
|
||||
});
|
||||
message.querySelector(".pwa-update-dismiss").addEventListener("click", function() {
|
||||
message.remove();
|
||||
});
|
||||
document.body.appendChild(message);
|
||||
|
||||
// Auto-dismiss after 30 seconds
|
||||
|
||||
+613
-14
@@ -998,27 +998,106 @@ export function renderVideoViewer(area, data) {
|
||||
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
|
||||
// Cells are contenteditable; edits are collected per sheet and sent to
|
||||
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
|
||||
// saved back as formulas (no client-side recalculation — ceiling accepted).
|
||||
function renderXlsxViewer(area, data) {
|
||||
// never recalculated here.
|
||||
//
|
||||
// #153 A1/A4 — the read response carries `xlsx_lossy_features` (parts openpyxl
|
||||
// drops on save): a banner lists them and the first save asks for an explicit
|
||||
// confirmation before retrying with `force: true`. A value starting with "=" or
|
||||
// "@" is stored as text unless the user turns the formula toggle on, so a typed
|
||||
// `=cmd|…` cannot execute when the file is later opened in Excel.
|
||||
// #153 A8 — a sheet bigger than the render caps used to be silently cut: the
|
||||
// user saw a short table and no way to tell the rest of the workbook still
|
||||
// existed. The backend now reports the real dimensions of every sheet, so the
|
||||
// note states exactly what is hidden (and that those cells are not editable
|
||||
// here — the workbook itself is untouched). A payload without those fields
|
||||
// (older cache) simply shows no note.
|
||||
function truncationNote(sheet) {
|
||||
// The cap is the real "shown" figure, not `rows`/`cols`: those are post-trim
|
||||
// (a sparse sheet renders 1x1) while the note must say how far the view
|
||||
// reaches.
|
||||
const cap = { rows: Number(sheet.max_rows) || 0, cols: Number(sheet.max_cols) || 0 };
|
||||
const reasons = [];
|
||||
if (Number(sheet.total_rows) > cap.rows) {
|
||||
reasons.push(t("xlsx.truncated_rows", { shown: cap.rows, total: Number(sheet.total_rows) }));
|
||||
}
|
||||
if (Number(sheet.total_cols) > cap.cols) {
|
||||
reasons.push(t("xlsx.truncated_cols", { shown: cap.cols, total: Number(sheet.total_cols) }));
|
||||
}
|
||||
if (!reasons.length) return "";
|
||||
return `<div class="xlsx-truncated" role="note">
|
||||
<i data-lucide="scissors" class="xlsx-truncated-icon"></i>
|
||||
<div class="xlsx-warning-body">
|
||||
<strong>${escapeHtml(t("xlsx.truncated_title"))}</strong>
|
||||
<span>${escapeHtml(reasons.join(" "))}</span>
|
||||
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.truncated_hint"))}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderXlsxViewer(area, data) {
|
||||
const sheets = data.xlsx_sheets || [];
|
||||
const lossy = data.xlsx_lossy_features || [];
|
||||
// Session-scoped state: once the lossy write is confirmed, the rest of the
|
||||
// session saves without asking again (never persisted — a confirmation is
|
||||
// per workbook, not a global preference).
|
||||
let lossyConfirmed = false;
|
||||
let allowFormula = false;
|
||||
const tabs = sheets.length > 1
|
||||
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
|
||||
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
|
||||
).join("")}</div>`
|
||||
: "";
|
||||
const panels = sheets.map((s, i) =>
|
||||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
|
||||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
|
||||
).join("");
|
||||
const lossWarning = lossy.length
|
||||
? `<div class="xlsx-warning" role="note">
|
||||
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
|
||||
<div class="xlsx-warning-body">
|
||||
<strong>${escapeHtml(t("xlsx.lossy_title"))}</strong>
|
||||
<span class="xlsx-warning-list">${lossy
|
||||
.map((f) => `<span class="xlsx-warning-tag">${escapeHtml(t("xlsx.feature_" + f))}</span>`)
|
||||
.join("")}</span>
|
||||
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.lossy_hint"))}</span>
|
||||
</div>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
area.innerHTML = `
|
||||
<div class="xlsx-viewer">
|
||||
<div class="xlsx-toolbar">
|
||||
${tabs}
|
||||
<span class="xlsx-toolbar-actions">
|
||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
${lossWarning}
|
||||
<div class="xlsx-formula-bar">
|
||||
<span class="xlsx-active-cell" id="xlsx-active-cell">A1</span>
|
||||
<i data-lucide="chevron-right" class="xlsx-formula-sep"></i>
|
||||
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
|
||||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
|
||||
<span class="xlsx-find-group">
|
||||
<input type="text" class="xlsx-find-input" id="xlsx-find-input"
|
||||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.find_placeholder"))}" />
|
||||
<span class="xlsx-find-count" id="xlsx-find-count"></span>
|
||||
<button class="btn-action xlsx-find-btn" id="xlsx-find-prev" title="${escapeHtml(t("xlsx.find_prev"))}">↑</button>
|
||||
<button class="btn-action xlsx-find-btn" id="xlsx-find-next" title="${escapeHtml(t("xlsx.find_next"))}">↓</button>
|
||||
<button class="btn-action xlsx-find-case" id="xlsx-find-case" aria-pressed="false" title="${escapeHtml(t("xlsx.find_case"))}">Aa</button>
|
||||
<button class="btn-action xlsx-sort-reset" id="xlsx-sort-reset" title="${escapeHtml(t("xlsx.sort_reset"))}" style="display:none">
|
||||
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="xlsx-panels">${panels}</div>
|
||||
@@ -1029,30 +1108,228 @@ function renderXlsxViewer(area, data) {
|
||||
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
|
||||
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
|
||||
|
||||
// Editable cells: Enter blurs, Escape reverts, paste stays single-line.
|
||||
area.querySelectorAll(".xlsx-table td").forEach((td) => {
|
||||
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
|
||||
// fetched window by window from GET …/xlsx/sheet when the user reaches the
|
||||
// end of a truncated sheet (scroll sentinel) or clicks « Charger la suite ».
|
||||
// Appended rows reuse the exact same edit pipeline as the initial render.
|
||||
const wireLazyRows = (panel) => {
|
||||
const meta = sheets[Number(panel.dataset.sheet)] || {};
|
||||
if (!meta.truncated) return;
|
||||
const wrapper = panel.querySelector(".csv-table-wrapper");
|
||||
const table = panel.querySelector(".xlsx-table tbody");
|
||||
if (!wrapper || !table) return;
|
||||
|
||||
let offset = Number(meta.rows) || 0;
|
||||
const total = Number(meta.total_rows) || 0;
|
||||
let loading = false;
|
||||
let done = offset >= total;
|
||||
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "xlsx-load-more";
|
||||
const refreshFoot = () => {
|
||||
foot.textContent = done
|
||||
? ""
|
||||
: `${t("xlsx.truncated_rows", { shown: offset, total })} · ${t("xlsx.load_more")}`;
|
||||
foot.classList.toggle("done", done);
|
||||
};
|
||||
refreshFoot();
|
||||
foot.addEventListener("click", () => { if (!done) loadMore(); });
|
||||
wrapper.insertAdjacentElement("afterend", foot);
|
||||
|
||||
const appendWindow = (win) => {
|
||||
const doc = new DOMParser().parseFromString(`<table>${win.html}</table>`, "text/html");
|
||||
// No `tbody` selector: the fragment embeds its own wrapper div, so the
|
||||
// parse yields bare `<tr>` inside `<table>` (the rows we want) — while
|
||||
// any `tbody` in the fragment belongs to the *embedded* wrapper table.
|
||||
doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr));
|
||||
offset = win.offset + win.rows;
|
||||
done = !win.has_more;
|
||||
refreshFoot();
|
||||
// The new rows must behave like the initial ones: contenteditable,
|
||||
// dirty tracking, cached-value tooltip, icons in the fresh footnote.
|
||||
panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell);
|
||||
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
|
||||
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
|
||||
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
|
||||
safeCreateIcons();
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || done) return;
|
||||
loading = true;
|
||||
const label = foot.textContent;
|
||||
foot.textContent = t("xlsx.loading_more");
|
||||
try {
|
||||
const win = await api(
|
||||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/sheet?path=${encodeURIComponent(data.path)}` +
|
||||
`&sheet=${encodeURIComponent(sheets[Number(panel.dataset.sheet)].name)}` +
|
||||
`&offset=${offset}&limit=500`,
|
||||
);
|
||||
appendWindow(win);
|
||||
} catch (err) {
|
||||
foot.textContent = label; // restore: a failed fetch must not eat the button
|
||||
showToast(`${t("xlsx.load_error")}: ${err.message || err}`, "error");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll sentinel: near the bottom of the wrapper, pull the next window.
|
||||
wrapper.addEventListener("scroll", () => {
|
||||
if (loading || done) return;
|
||||
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
|
||||
});
|
||||
};
|
||||
|
||||
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
|
||||
// One active cell per viewer: clicking or arrowing into a cell shows its
|
||||
// A1 name in the bar; the input mirrors the cell text and typing there
|
||||
// edits the cell live (Enter commits, Escape reverts, then re-focuses).
|
||||
let activeTd = null;
|
||||
const activeCellEl = area.querySelector("#xlsx-active-cell");
|
||||
const formulaInput = area.querySelector("#xlsx-formula-input");
|
||||
const syncing = { value: false }; // guard against input-event feedback loops
|
||||
|
||||
const cellName = (td) => td?.dataset.cell || "";
|
||||
|
||||
const setActiveCell = (td) => {
|
||||
activeTd = td || null;
|
||||
if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active");
|
||||
if (!td) {
|
||||
activeCellEl.textContent = "—";
|
||||
formulaInput.value = "";
|
||||
formulaInput.disabled = true;
|
||||
return;
|
||||
}
|
||||
td.classList.add("xlsx-active");
|
||||
activeCellEl.textContent = cellName(td);
|
||||
formulaInput.disabled = false;
|
||||
formulaInput.value = td.textContent;
|
||||
};
|
||||
|
||||
const syncFormulaBar = (td) => {
|
||||
if (td !== activeTd) return;
|
||||
if (!syncing.value) formulaInput.value = td.textContent;
|
||||
};
|
||||
|
||||
// Parse an A1 reference into its (row, col) parts.
|
||||
const parseRef = (ref) => {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
|
||||
if (!m) return null;
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { row: Number(m[2]), col };
|
||||
};
|
||||
|
||||
const findTd = (panel, row, col) =>
|
||||
panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||||
|
||||
const columnName = (col) => {
|
||||
let name = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
name = String.fromCharCode(65 + rem) + name;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const moveActive = (td, key, forward = true) => {
|
||||
const ref = parseRef(cellName(td));
|
||||
if (!ref) return;
|
||||
let { row, col } = ref;
|
||||
if (key === "Tab") col += forward ? 1 : -1;
|
||||
else if (key === "ArrowRight") col += 1;
|
||||
else if (key === "ArrowLeft") col -= 1;
|
||||
else if (key === "ArrowDown") row += 1;
|
||||
else if (key === "ArrowUp") row -= 1;
|
||||
if (row < 1 || col < 1) return;
|
||||
const panel = td.closest(".xlsx-panel");
|
||||
const next = findTd(panel, row, col);
|
||||
if (!next) return; // edge of the rendered window: no wrap
|
||||
td.blur();
|
||||
next.focus();
|
||||
setActiveCell(next);
|
||||
};
|
||||
|
||||
formulaInput.addEventListener("input", () => {
|
||||
if (!activeTd) return;
|
||||
syncing.value = true;
|
||||
activeTd.textContent = formulaInput.value;
|
||||
syncing.value = false;
|
||||
activeTd.classList.add("xlsx-dirty");
|
||||
refreshSaveState();
|
||||
});
|
||||
formulaInput.addEventListener("keydown", (e) => {
|
||||
if (!activeTd) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
activeTd.blur();
|
||||
activeTd.focus();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
activeTd.textContent = activeTd.dataset.orig;
|
||||
activeTd.classList.remove("xlsx-dirty");
|
||||
syncFormulaBar(activeTd);
|
||||
refreshSaveState();
|
||||
activeTd.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const setupCell = (td) => {
|
||||
td.contentEditable = "true";
|
||||
td.spellcheck = false;
|
||||
// Focusable without a pointing device: JSDOM requires it to fire focus
|
||||
// events on contenteditable cells, and a keyboard user tabbing into the
|
||||
// table from outside lands on the first cell thanks to it.
|
||||
td.tabIndex = 0;
|
||||
td.dataset.orig = td.textContent;
|
||||
td.addEventListener("input", () => {
|
||||
td.classList.add("xlsx-dirty");
|
||||
syncFormulaBar(td);
|
||||
refreshSaveState();
|
||||
});
|
||||
td.addEventListener("focus", () => setActiveCell(td));
|
||||
td.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); td.blur(); }
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
|
||||
if (e.key === "Escape") {
|
||||
td.textContent = td.dataset.orig;
|
||||
td.classList.remove("xlsx-dirty");
|
||||
syncFormulaBar(td);
|
||||
refreshSaveState();
|
||||
}
|
||||
if (e.key === "Tab" || e.key === "ArrowUp" || e.key === "ArrowDown"
|
||||
|| e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
// Excel-like: arrows and Tab move to the neighbour cell. Direction is
|
||||
// the key's own (Left/Up = back, Right/Down = forward); Tab follows
|
||||
// Shift. Prevented so Tab never leaves the table and arrows never
|
||||
// move the caret (they move the SELECTION instead).
|
||||
e.preventDefault();
|
||||
const forward = e.key === "Tab"
|
||||
? !e.shiftKey
|
||||
: (e.key === "ArrowRight" || e.key === "ArrowDown");
|
||||
moveActive(td, e.key, forward);
|
||||
}
|
||||
});
|
||||
td.addEventListener("paste", (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " ");
|
||||
document.execCommand("insertText", false, text);
|
||||
});
|
||||
};
|
||||
|
||||
// #153 A12 — the backend marks the last value Excel computed; the wording is
|
||||
// translated here so the tooltip follows the UI language.
|
||||
area.querySelectorAll(".xlsx-cached[data-cached-value]").forEach((el) => {
|
||||
el.title = t("xlsx.cached_value_title");
|
||||
});
|
||||
|
||||
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
|
||||
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
||||
panelEls.forEach(wireLazyRows);
|
||||
// The formula bar starts disabled: nothing is selected yet.
|
||||
setActiveCell(null);
|
||||
|
||||
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
const idx = tab.dataset.sheet;
|
||||
@@ -1061,6 +1338,23 @@ function renderXlsxViewer(area, data) {
|
||||
});
|
||||
});
|
||||
|
||||
// Formula toggle (#153 A4) — opt-in for this viewing session only.
|
||||
const formulaBtn = area.querySelector("#xlsx-formula-btn");
|
||||
formulaBtn.addEventListener("click", () => {
|
||||
allowFormula = !allowFormula;
|
||||
formulaBtn.setAttribute("aria-pressed", String(allowFormula));
|
||||
formulaBtn.classList.toggle("active", allowFormula);
|
||||
});
|
||||
|
||||
const putSheet = (job, force) => api(
|
||||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...job, allow_formula: allowFormula, force }),
|
||||
},
|
||||
);
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
// One PUT per sheet (dirty cells can span tabs before a save).
|
||||
const jobs = panelEls
|
||||
@@ -1074,21 +1368,41 @@ function renderXlsxViewer(area, data) {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
for (const job of jobs) {
|
||||
await api(`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(job),
|
||||
});
|
||||
// 409 xlsx_lossy_content → confirm once, then retry with force: true.
|
||||
// (Also covers a workbook that became lossy while it was open.)
|
||||
let force = lossyConfirmed;
|
||||
for (;;) {
|
||||
try {
|
||||
await putSheet(job, force);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
|
||||
const features = (err.details && err.details.features) || lossy;
|
||||
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
|
||||
if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) throw err;
|
||||
lossyConfirmed = true;
|
||||
force = true;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
|
||||
td.classList.remove("xlsx-dirty");
|
||||
td.dataset.orig = td.textContent;
|
||||
});
|
||||
if (activeTd) syncFormulaBar(activeTd);
|
||||
refreshSaveState();
|
||||
showToast(t("editor.saved"), "success");
|
||||
} catch (err) {
|
||||
refreshSaveState();
|
||||
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
|
||||
// A refused confirmation is a decision, not a failure: neutral toast.
|
||||
if (err && err.code === "xlsx_lossy_content") {
|
||||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||||
} else {
|
||||
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1096,6 +1410,286 @@ function renderXlsxViewer(area, data) {
|
||||
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
|
||||
});
|
||||
|
||||
// ── #153 A13 — sort / filter / find in the sheet + CSV export ──────────
|
||||
// ALL of these act on the RENDERED grid only: the workbook is never
|
||||
// rewritten by a sort or a filter (the save pipeline stays the only write
|
||||
// path, and the note in xlsx.sort_applied says so).
|
||||
const visiblePanel = () =>
|
||||
panelEls.find((p) => p.style.display !== "none") || panelEls[0];
|
||||
|
||||
// Sorting reorders <tr> rows by the text of one column. Dirty cells travel
|
||||
// with their row, so a sort never loses an unsaved edit.
|
||||
let sortState = null; // { col, dir } on the visible panel
|
||||
|
||||
const applySort = (panel, col, dir) => {
|
||||
const tbody = panel.querySelector(".xlsx-table tbody");
|
||||
if (!tbody) return;
|
||||
const rows = [...tbody.querySelectorAll("tr")];
|
||||
const numeric = rows.every((tr) => {
|
||||
const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||||
const v = td ? td.textContent.trim() : "";
|
||||
return v === "" || !isNaN(Number(v));
|
||||
});
|
||||
rows.sort((a, b) => {
|
||||
const ta = a.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||||
const tb = b.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||||
const va = ta ? ta.textContent.trim() : "";
|
||||
const vb = tb ? tb.textContent.trim() : "";
|
||||
const cmp = numeric
|
||||
? (parseFloat(va) || 0) - (parseFloat(vb) || 0)
|
||||
: va.localeCompare(vb, "fr");
|
||||
return dir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach((tr) => tbody.appendChild(tr));
|
||||
sortState = { col, dir };
|
||||
sortResetBtn.style.display = "";
|
||||
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
|
||||
};
|
||||
|
||||
// Header click cycles: asc → desc → back to the sheet order.
|
||||
const wireHeaderSort = (panel) => {
|
||||
const thead = panel.querySelector(".xlsx-table thead");
|
||||
if (!thead || thead.dataset.sortWired) return;
|
||||
thead.dataset.sortWired = "1";
|
||||
thead.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => {
|
||||
th.style.cursor = "pointer";
|
||||
th.title = t("xlsx.sort_asc");
|
||||
th.addEventListener("click", () => {
|
||||
// The corner th is column 0, so the child index IS the column number.
|
||||
const col = [...th.parentElement.children].indexOf(th);
|
||||
const current = sortState && sortState.col === col ? sortState.dir : null;
|
||||
const dir = current === "asc" ? "desc" : "asc";
|
||||
applySort(panel, col, dir);
|
||||
th.title = dir === "asc" ? t("xlsx.sort_desc") : t("xlsx.sort_asc");
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Filter: rows whose cell text lacks the needle are hidden.
|
||||
const applyFilter = (panel, needle) => {
|
||||
const n = needle.trim().toLowerCase();
|
||||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||||
const text = tr.textContent.toLowerCase();
|
||||
tr.style.display = !n || text.includes(n) ? "" : "none";
|
||||
});
|
||||
if (n) sortResetBtn.style.display = "";
|
||||
};
|
||||
|
||||
panelEls.forEach(wireHeaderSort);
|
||||
|
||||
const sortResetBtn = area.querySelector("#xlsx-sort-reset");
|
||||
sortResetBtn.addEventListener("click", () => {
|
||||
// Full reset: re-render the current file (server truth), clear find too.
|
||||
sortResetBtn.style.display = "none";
|
||||
renderXlsxViewer(area, data);
|
||||
});
|
||||
|
||||
// Find-in-sheet: highlight matches, navigate with ↑/↓.
|
||||
let findMatches = [];
|
||||
let findIndex = -1;
|
||||
const findInput = area.querySelector("#xlsx-find-input");
|
||||
const findCount = area.querySelector("#xlsx-find-count");
|
||||
let findCase = false;
|
||||
|
||||
const clearFind = () => {
|
||||
findMatches.forEach(({ td, mark }) => {
|
||||
mark.replaceWith(document.createTextNode(mark.textContent));
|
||||
td.normalize();
|
||||
});
|
||||
findMatches = [];
|
||||
findIndex = -1;
|
||||
findCount.textContent = "";
|
||||
};
|
||||
|
||||
const runFind = () => {
|
||||
clearFind();
|
||||
const needle = findInput.value;
|
||||
if (!needle.trim()) return;
|
||||
const panel = visiblePanel();
|
||||
const hay = findCase ? (s) => s : (s) => s.toLowerCase();
|
||||
const n = hay(needle);
|
||||
panel.querySelectorAll(".xlsx-table td").forEach((td) => {
|
||||
const text = td.textContent;
|
||||
const pos = hay(text).indexOf(n);
|
||||
if (pos === -1 || !text) return;
|
||||
// Wrap the match in a <mark> by splitting the cell's first text node at
|
||||
// the hit (cells are text-first; the cached-value span stays untouched).
|
||||
const first = td.firstChild;
|
||||
if (first && first.nodeType === 3) {
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "xlsx-find-hit";
|
||||
mark.textContent = text.substr(pos, needle.length);
|
||||
const tail = first.splitText(pos);
|
||||
tail.splitText(needle.length);
|
||||
td.replaceChild(mark, tail);
|
||||
findMatches.push({ td, mark });
|
||||
}
|
||||
});
|
||||
findCount.textContent = findMatches.length
|
||||
? t("xlsx.find_count", { index: 1, count: findMatches.length })
|
||||
: t("xlsx.find_no_match");
|
||||
if (findMatches.length) focusMatch(0);
|
||||
};
|
||||
|
||||
const focusMatch = (i) => {
|
||||
findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current"));
|
||||
findIndex = (i + findMatches.length) % findMatches.length;
|
||||
const { td, mark } = findMatches[findIndex];
|
||||
mark.classList.add("xlsx-find-current");
|
||||
// scrollIntoView is missing in JSDOM; guard it (real browsers have it).
|
||||
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
|
||||
findCount.textContent = t("xlsx.find_count", { index: findIndex + 1, count: findMatches.length });
|
||||
};
|
||||
|
||||
// One input drives both the highlight (find) and the row filter (A13):
|
||||
// typing narrows the sheet to the matching rows AND highlights the hits.
|
||||
findInput.addEventListener("input", () => {
|
||||
panelEls.forEach((p) => applyFilter(p, findInput.value));
|
||||
runFind();
|
||||
});
|
||||
findInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); focusMatch(findIndex + (e.shiftKey ? -1 : 1)); }
|
||||
});
|
||||
area.querySelector("#xlsx-find-prev").addEventListener("click", () => focusMatch(findIndex - 1));
|
||||
area.querySelector("#xlsx-find-next").addEventListener("click", () => focusMatch(findIndex + 1));
|
||||
const caseBtn = area.querySelector("#xlsx-find-case");
|
||||
caseBtn.addEventListener("click", () => {
|
||||
findCase = !findCase;
|
||||
caseBtn.setAttribute("aria-pressed", String(findCase));
|
||||
caseBtn.classList.toggle("active", findCase);
|
||||
runFind();
|
||||
});
|
||||
|
||||
// ── #153 A14 — workbook structure menu (sheets, rows, columns) ─────────
|
||||
// Every action is an explicit user gesture (prompt/confirm) and goes to
|
||||
// PUT …/xlsx/structure — one locked, atomic rewrite with a backup.
|
||||
const visibleSheetIndex = () =>
|
||||
Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0;
|
||||
|
||||
const putStructure = async (actions, force = false) => {
|
||||
await api(
|
||||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/structure?path=${encodeURIComponent(data.path)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actions, force }),
|
||||
},
|
||||
);
|
||||
showToast(t("xlsx.structure_saved"), "success");
|
||||
// Re-render from the server so the viewer shows the new structure.
|
||||
const fresh = await api(
|
||||
`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`,
|
||||
);
|
||||
renderXlsxViewer(area, fresh);
|
||||
};
|
||||
|
||||
const structureError = (err, retryActions) => {
|
||||
if (err && err.code === "xlsx_lossy_content") {
|
||||
const features = (err.details && err.details.features) || lossy;
|
||||
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
|
||||
if (confirm(t("xlsx.lossy_confirm", { features: labels }))) {
|
||||
return putStructure(retryActions, true); // re-emitted with force
|
||||
}
|
||||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||||
return null;
|
||||
}
|
||||
showToast(`${t("xlsx.structure_error")}: ${err.message || err}`, "error");
|
||||
return null;
|
||||
};
|
||||
|
||||
// Structure menu: built on demand, positioned under the button.
|
||||
area.querySelector("#xlsx-structure-btn").addEventListener("click", (e) => {
|
||||
const old = area.querySelector(".xlsx-structure-menu");
|
||||
if (old) { old.remove(); return; }
|
||||
const idx = visibleSheetIndex();
|
||||
const sheetName = sheets[idx]?.name || "";
|
||||
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
|
||||
const parsed = parseRef(activeRef);
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "xlsx-structure-menu";
|
||||
const item = (label, fn) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "btn-action xlsx-structure-item";
|
||||
b.textContent = label;
|
||||
b.addEventListener("click", () => { menu.remove(); fn(); });
|
||||
menu.appendChild(b);
|
||||
};
|
||||
item(t("xlsx.sheet_add"), async () => {
|
||||
const name = prompt(t("xlsx.structure_prompt_add"));
|
||||
if (!name) return;
|
||||
const actions = [{ op: "sheet_add", name }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_rename"), async () => {
|
||||
const to = prompt(t("xlsx.structure_prompt_rename"), sheetName);
|
||||
if (!to || to === sheetName) return;
|
||||
const actions = [{ op: "sheet_rename", from: sheetName, to }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_duplicate"), async () => {
|
||||
const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`);
|
||||
if (!as) return;
|
||||
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.sheet_delete"), async () => {
|
||||
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
|
||||
if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return;
|
||||
const actions = [{ op: "sheet_delete", name: sheetName }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
if (parsed) {
|
||||
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
|
||||
item(t("xlsx.row_insert"), async () => {
|
||||
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.row_delete"), async () => {
|
||||
if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return;
|
||||
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.col_insert"), async () => {
|
||||
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
item(t("xlsx.col_delete"), async () => {
|
||||
if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return;
|
||||
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
|
||||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||
});
|
||||
}
|
||||
e.target.closest(".xlsx-toolbar").appendChild(menu);
|
||||
});
|
||||
|
||||
// CSV export of the visible sheet (post-trim, pre-save data).
|
||||
area.querySelector("#xlsx-csv-btn").addEventListener("click", () => {
|
||||
const panel = visiblePanel();
|
||||
const idx = Number(panel.dataset.sheet);
|
||||
const rows = [];
|
||||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||||
rows.push(
|
||||
[...tr.querySelectorAll("td")].map((td) => {
|
||||
const clone = td.cloneNode(true);
|
||||
clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove());
|
||||
return clone.textContent;
|
||||
}),
|
||||
);
|
||||
});
|
||||
const csv = rows
|
||||
.map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";"))
|
||||
.join("\n");
|
||||
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${sheets[idx]?.name || "feuille"}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
@@ -1140,10 +1734,10 @@ export function renderFile(data) {
|
||||
<div class="pdf-viewer-container">
|
||||
<div class="pdf-toolbar">
|
||||
<span class="pdf-info">PDF — ${pages} pages</span>
|
||||
<button class="btn-action" onclick="window.open('${pdfUrl}', '_blank')">
|
||||
<button class="btn-action" data-pdf-url="${pdfUrl}">
|
||||
<i data-lucide="external-link" style="width:14px;height:14px"></i> Plein écran
|
||||
</button>
|
||||
<button class="btn-action" onclick="window.open('/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}', '_blank')">
|
||||
<button class="btn-action" data-download-url="/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
@@ -1152,6 +1746,11 @@ export function renderFile(data) {
|
||||
<iframe src="${pdfUrl}" data-pdf-url="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
|
||||
</div>
|
||||
</div>`;
|
||||
area.querySelectorAll('.pdf-toolbar .btn-action').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
window.open(btn.dataset.pdfUrl || btn.dataset.downloadUrl, '_blank');
|
||||
});
|
||||
});
|
||||
area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1823,6 +1823,59 @@
|
||||
"viewer.copy": "Copy",
|
||||
"viewer.copy_error": "Copy error",
|
||||
"viewer.download": "Download",
|
||||
"xlsx.lossy_title": "Simplified save",
|
||||
"xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.",
|
||||
"xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}",
|
||||
"xlsx.lossy_cancelled": "Save cancelled",
|
||||
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
|
||||
"xlsx.cached_value_title": "Last value calculated by Excel",
|
||||
"xlsx.truncated_title": "Truncated sheet",
|
||||
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
|
||||
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
|
||||
"xlsx.truncated_hint": "Cells outside the displayed area cannot be edited here; the workbook is unchanged.",
|
||||
"xlsx.load_more": "Load more",
|
||||
"xlsx.loading_more": "Loading…",
|
||||
"xlsx.load_error": "Could not load the remaining rows",
|
||||
"xlsx.formula_bar_placeholder": "Active cell content",
|
||||
"xlsx.active_cell": "Cell",
|
||||
"xlsx.find_placeholder": "Search in the sheet…",
|
||||
"xlsx.find_prev": "Previous",
|
||||
"xlsx.find_next": "Next",
|
||||
"xlsx.find_case": "Match case",
|
||||
"xlsx.find_no_match": "No match",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Export the sheet as CSV",
|
||||
"xlsx.sort_asc": "Sort column A→Z",
|
||||
"xlsx.sort_desc": "Sort column Z→A",
|
||||
"xlsx.sort_applied": "Sort applied on {col} — display only, the workbook is unchanged",
|
||||
"xlsx.sort_reset": "Reset sort and filter",
|
||||
"xlsx.filter_placeholder": "Filter rows…",
|
||||
"xlsx.structure_btn": "Sheet structure",
|
||||
"xlsx.structure_title": "Edit the workbook structure",
|
||||
"xlsx.sheet_add": "Add a sheet",
|
||||
"xlsx.sheet_rename": "Rename the current sheet",
|
||||
"xlsx.sheet_duplicate": "Duplicate the current sheet",
|
||||
"xlsx.sheet_delete": "Delete the current sheet",
|
||||
"xlsx.row_insert": "Insert a row above",
|
||||
"xlsx.row_delete": "Delete the active cell's row",
|
||||
"xlsx.col_insert": "Insert a column to the left",
|
||||
"xlsx.col_delete": "Delete the active cell's column",
|
||||
"xlsx.structure_prompt_add": "Name of the new sheet:",
|
||||
"xlsx.structure_prompt_rename": "New name of the sheet:",
|
||||
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
|
||||
"xlsx.structure_saved": "Structure updated",
|
||||
"xlsx.structure_error": "Could not change the structure",
|
||||
"xlsx.last_sheet": "The last sheet cannot be deleted",
|
||||
"xlsx.feature_cached_values": "cached values",
|
||||
"xlsx.feature_slicers": "slicers and timelines",
|
||||
"xlsx.feature_form_controls": "form controls",
|
||||
"xlsx.feature_connections": "connections and queries",
|
||||
"xlsx.feature_custom_xml": "custom XML",
|
||||
"xlsx.feature_signature": "digital signature",
|
||||
"xlsx.feature_rich_comments": "rich comments",
|
||||
"xlsx.feature_macros": "macros",
|
||||
"viewer.download_md": "Download as .md",
|
||||
"viewer.download_file": "Download file",
|
||||
"viewer.pretty": "Pretty",
|
||||
@@ -1987,6 +2040,10 @@
|
||||
"ai.step.git_issues": "Searched issues: {value}",
|
||||
"ai.step.git_file": "Read a repo file: {value}",
|
||||
"ai.step.xlsx_create": "Spreadsheet proposed: {value}",
|
||||
"ai.step.xlsx_sheets": "Workbook sheets listed: {value}",
|
||||
"ai.step.xlsx_read": "Workbook read: {value}",
|
||||
"ai.step.xlsx_update": "Cells edited: {value}",
|
||||
"ai.step.xlsx_append": "Rows appended: {value}",
|
||||
"ai.step.docx_create": "Word document proposed: {value}",
|
||||
"ai.step.csv_create": "CSV file proposed: {value}",
|
||||
"ai.step.pdf_create": "PDF document proposed: {value}",
|
||||
|
||||
@@ -1823,6 +1823,59 @@
|
||||
"viewer.copy": "Copier",
|
||||
"viewer.copy_error": "Erreur lors de la copie",
|
||||
"viewer.download": "Télécharger",
|
||||
"xlsx.lossy_title": "Enregistrement simplifié",
|
||||
"xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.",
|
||||
"xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}",
|
||||
"xlsx.lossy_cancelled": "Sauvegarde annulée",
|
||||
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
|
||||
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
|
||||
"xlsx.truncated_title": "Feuille tronquée",
|
||||
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
|
||||
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
|
||||
"xlsx.truncated_hint": "Les cellules hors de l'affichage ne sont pas éditables ici ; le classeur n'est pas modifié.",
|
||||
"xlsx.load_more": "Charger la suite",
|
||||
"xlsx.loading_more": "Chargement…",
|
||||
"xlsx.load_error": "Chargement de la suite impossible",
|
||||
"xlsx.formula_bar_placeholder": "Contenu de la cellule active",
|
||||
"xlsx.active_cell": "Cellule",
|
||||
"xlsx.find_placeholder": "Rechercher dans la feuille…",
|
||||
"xlsx.find_prev": "Précédent",
|
||||
"xlsx.find_next": "Suivant",
|
||||
"xlsx.find_case": "Respecter la casse",
|
||||
"xlsx.find_no_match": "Aucune correspondance",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Exporter la feuille en CSV",
|
||||
"xlsx.sort_asc": "Trier la colonne A→Z",
|
||||
"xlsx.sort_desc": "Trier la colonne Z→A",
|
||||
"xlsx.sort_applied": "Tri appliqué sur {col} — l'affichage seul, le classeur n'est pas modifié",
|
||||
"xlsx.sort_reset": "Réinitialiser le tri et le filtre",
|
||||
"xlsx.filter_placeholder": "Filtrer les lignes…",
|
||||
"xlsx.structure_btn": "Structure de la feuille",
|
||||
"xlsx.structure_title": "Modifier la structure du classeur",
|
||||
"xlsx.sheet_add": "Ajouter une feuille",
|
||||
"xlsx.sheet_rename": "Renommer la feuille courante",
|
||||
"xlsx.sheet_duplicate": "Dupliquer la feuille courante",
|
||||
"xlsx.sheet_delete": "Supprimer la feuille courante",
|
||||
"xlsx.row_insert": "Insérer une ligne au-dessus",
|
||||
"xlsx.row_delete": "Supprimer la ligne de la cellule active",
|
||||
"xlsx.col_insert": "Insérer une colonne à gauche",
|
||||
"xlsx.col_delete": "Supprimer la colonne de la cellule active",
|
||||
"xlsx.structure_prompt_add": "Nom de la nouvelle feuille :",
|
||||
"xlsx.structure_prompt_rename": "Nouveau nom de la feuille :",
|
||||
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_saved": "Structure mise à jour",
|
||||
"xlsx.structure_error": "Modification de la structure impossible",
|
||||
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
|
||||
"xlsx.feature_cached_values": "valeurs calculées",
|
||||
"xlsx.feature_slicers": "segments et chronologies",
|
||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||
"xlsx.feature_connections": "connexions et requêtes",
|
||||
"xlsx.feature_custom_xml": "XML personnalisé",
|
||||
"xlsx.feature_signature": "signature numérique",
|
||||
"xlsx.feature_rich_comments": "commentaires enrichis",
|
||||
"xlsx.feature_macros": "macros",
|
||||
"viewer.download_md": "Télécharger en .md",
|
||||
"viewer.download_file": "Télécharger le fichier",
|
||||
"viewer.pretty": "Pretty",
|
||||
@@ -1987,6 +2040,10 @@
|
||||
"ai.step.git_issues": "Issues recherchées : {value}",
|
||||
"ai.step.git_file": "Fichier de dépôt lu : {value}",
|
||||
"ai.step.xlsx_create": "Tableur proposé : {value}",
|
||||
"ai.step.xlsx_sheets": "Feuilles du classeur listées : {value}",
|
||||
"ai.step.xlsx_read": "Classeur lu : {value}",
|
||||
"ai.step.xlsx_update": "Cellules modifiées : {value}",
|
||||
"ai.step.xlsx_append": "Lignes ajoutées : {value}",
|
||||
"ai.step.docx_create": "Document Word proposé : {value}",
|
||||
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
||||
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
||||
|
||||
+268
-2
@@ -10931,6 +10931,7 @@ body.desktop-mode .editor-container {
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
position: relative; /* anchors the A14 structure menu */
|
||||
}
|
||||
.xlsx-toolbar-actions {
|
||||
margin-left: auto;
|
||||
@@ -10967,12 +10968,25 @@ body.desktop-mode .editor-container {
|
||||
border-right: 1px solid var(--border-light, var(--border));
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
/* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th`
|
||||
at `top: 0`, so a row number left sticky on both axes piles up in the
|
||||
top-left corner instead of tracking its own row. */
|
||||
top: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
.xlsx-table th.xlsx-corner {
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
}
|
||||
/* #153 A8 — the column headers stay visible while the sheet scrolls down.
|
||||
Declared explicitly (and not inherited from `.csv-table th`) so the stacking
|
||||
order is intentional: thead (3) < row numbers (2) < corner (4). */
|
||||
.xlsx-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
background: var(--surface);
|
||||
}
|
||||
.xlsx-table td[contenteditable] {
|
||||
cursor: text;
|
||||
@@ -10983,10 +10997,262 @@ body.desktop-mode .editor-container {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* #153 A7 — the active cell keeps its outline even when focus moves to the
|
||||
formula bar, so the user never loses track of what the bar edits. */
|
||||
.xlsx-table td.xlsx-active:not(:focus) {
|
||||
outline: 2px dashed var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* #153 A7 — formula bar under the toolbar: [ A1 | > | input ] */
|
||||
.xlsx-formula-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xlsx-active-cell {
|
||||
min-width: 52px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.xlsx-formula-sep {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-formula-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.xlsx-formula-input:focus {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-formula-input:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* #153 A13 — find-in-sheet + filter/sort controls */
|
||||
.xlsx-find-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-find-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xlsx-find-input:focus {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-find-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xlsx-find-btn,
|
||||
.xlsx-find-case,
|
||||
.xlsx-sort-reset {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.xlsx-find-hit {
|
||||
background: var(--warning, #e0a800);
|
||||
color: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.xlsx-find-current {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* #153 A14 — structure menu (sheets / rows / columns) */
|
||||
.xlsx-structure-menu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 240px;
|
||||
margin-top: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px var(--shadow, rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
.xlsx-structure-item {
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-structure-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.xlsx-structure-sep {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* JSDOM shims for the tests that click anchors */
|
||||
mark {
|
||||
font: inherit;
|
||||
}
|
||||
.xlsx-table td.xlsx-dirty {
|
||||
background: rgba(255, 196, 0, 0.18);
|
||||
}
|
||||
|
||||
/* #153 A12 — last result Excel computed, shown under a formula cell.
|
||||
Discreet by design: the formula is what the user edits, the cached value is
|
||||
context (stale until Excel recalculates). */
|
||||
.xlsx-cached {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
padding-left: 6px;
|
||||
border-left: 2px solid var(--border, #d0d7de);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* #153 A1/A4 — lossy-save warning + formula toggle */
|
||||
.xlsx-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--warning, #e0a800);
|
||||
border-left-width: 3px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.xlsx-warning-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
color: var(--warning, #e0a800);
|
||||
}
|
||||
.xlsx-warning-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-warning-body strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.xlsx-warning-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.xlsx-warning-tag {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xlsx-warning-hint {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* #153 A8 — "feuille tronquée" notice. Deliberately NOT the `.xlsx-warning`
|
||||
look: that one is a data-loss alert, this one only says part of the sheet is
|
||||
out of view. */
|
||||
.xlsx-truncated {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent, #4a90d9);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.xlsx-truncated-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
color: var(--accent, #4a90d9);
|
||||
}
|
||||
|
||||
/* #153 A9bis — “charger la suite” footnote under a truncated sheet. Also the
|
||||
scroll sentinel target: clickable whole, disabled look once the sheet is
|
||||
fully loaded. */
|
||||
.xlsx-load-more {
|
||||
display: block;
|
||||
margin: 6px 0 10px;
|
||||
padding: 6px 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.xlsx-load-more:hover {
|
||||
border-color: var(--accent, #4a90d9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-load-more.done {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.xlsx-formula-toggle {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
.xlsx-formula-toggle.active {
|
||||
background: var(--accent, #4a90d9);
|
||||
border-color: var(--accent, #4a90d9);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── JSON Viewer ── */
|
||||
.json-viewer {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v26';
|
||||
const SW_VERSION = 'v27';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.27.2",
|
||||
"version": "2.36.0",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
# Plan: Incremental InvertedIndex for 40k+ files
|
||||
# Incremental InvertedIndex for 40k+ files — livré
|
||||
|
||||
> **Statut : LIVRÉ (BUG-033, v2.3.0).** Ce fichier a servi de plan
|
||||
> d'exécution ; il est conservé comme **trace de conception**. Le code réel
|
||||
> a divergé sur plusieurs points (voir [État réel](#état-réel-corrigé-au-2026-09-27))
|
||||
> — ne pas lire les extraits de code ci-dessous comme du code actuel.
|
||||
|
||||
## Problem Summary
|
||||
|
||||
@@ -17,6 +22,11 @@ Then hook these into `_add_file_to_structures` and `_remove_file_from_structures
|
||||
|
||||
Remove the `is_stale()` / `rebuild()` / cooldown mechanism entirely. The inverted index is always current.
|
||||
|
||||
> ⚠️ **Nuance retenue à l'implémentation** : un unique `rebuild()` reste nécessaire au
|
||||
> démarrage (le hook est inerte tant que l'index n'est pas prêt) et au reindex manuel
|
||||
> d'une vault. Ce qui disparaît, c'est la *staleness* : plus de compteur de génération,
|
||||
> plus de cooldown, plus de rebuild paresseux.
|
||||
|
||||
## Dependency Architecture
|
||||
|
||||
**Current import chain:**
|
||||
@@ -346,3 +356,61 @@ This hack was only needed to reduce the number of inverted index rebuilds. With
|
||||
4. **Sorted tokens performance:** `bisect.insort` and `list.pop(idx)` are O(V) worst case for large V. For 40k files, the vocabulary size V is typically 50k-200k tokens. O(V) for a single insertion is ~0.001ms, acceptable. The rebuild() call at startup handles the initial bulk.
|
||||
|
||||
5. **tag_norm_map / tag_prefix_index growth:** These grow monotonically (never shrink on incremental remove). With 40k files and thousands of tags, this is a few thousand entries — negligible. A manual "Réindexer" button triggers a full `rebuild()` to clean up.
|
||||
|
||||
---
|
||||
|
||||
## État réel (corrigé le 2026-09-27)
|
||||
|
||||
Le plan ci-dessus a servi de brouillon : **le code livré en est différent sur
|
||||
quatre points**. Relevé fait sur `backend/search.py`, `backend/indexer.py` et
|
||||
`backend/main.py`, pas de mémoire.
|
||||
|
||||
| Point prévu | État réel |
|
||||
|---|---|
|
||||
| Étapes 1-2 : hook + `add_document()` / `remove_document()` | ✅ livré tel que prévu |
|
||||
| Étapes 4-5 : `rebuild()` initial via `init_inverted_index()` appelé depuis la lifespan | ✅ livré (`backend/main.py:297`, dans l'exécuteur de recherche) |
|
||||
| Étape 6 : retirer `is_stale()` + `_last_rebuild` / `_rebuild_cooldown` / `_source_generation` | ✅ **déjà fait** avant cette relecture — aucun de ces symboles ne subsiste |
|
||||
| Étape 7 : retirer le hack de coalescence `_index_generation` dans `_on_vault_change` | ✅ **déjà fait** — `_on_vault_change` n'existe plus |
|
||||
| `get_inverted_index()` simplifié | ✅ mais **sans le fallback `_needs_rebuild`** prévu par le plan |
|
||||
|
||||
### Écarts assumés
|
||||
|
||||
1. **`is_stale()` a survécu sous un autre nom.** L'étape 6 est faite, mais la
|
||||
méthode a été conservée car elle répond à une autre question : *l'index
|
||||
initial est-il construit ?* Elle ne mesure plus aucune staleness (le compteur
|
||||
de génération et le cooldown ont disparu) et le nom était trompeur. Elle est
|
||||
donc renommée `is_ready()` — cohérent avec le `is_ready()` déjà exposé par
|
||||
`SemanticIndex` (`backend/semantic_search.py`). L'alias `is_stale()` de
|
||||
`SemanticIndex`, sans aucun appelant, est supprimé.
|
||||
|
||||
Impact : le champ de `/api/diagnostics` passe de `is_stale` à `is_ready`
|
||||
(libellé « Index prêt » côté `frontend/js/config.js`).
|
||||
|
||||
2. **Pas de repli `_needs_rebuild`.** Le plan prévoyait qu'un échec
|
||||
d'incrémentation marque l'index pour reconstruction. L'implémentation
|
||||
retenue se contente de logger un warning et de continuer à servir l'index.
|
||||
Choix assumé : un échec d'incrémentation est exceptionnel, et reconstruire
|
||||
silencieusement serait plus coûteux que l'état dégradé. **Si ce compromis
|
||||
devient critiquique, c'est le point à rouvrir.**
|
||||
|
||||
3. **`_ready` remplace `doc_count == 0`.** Le plan prévoyait de sauter le hook
|
||||
« index vide » ; le drapeau explicite `_ready` est plus sûr (un vault
|
||||
réellement vide serait sinon pris pour un index non construit).
|
||||
|
||||
4. **`rebuild()` reste nécessaire** au démarrage et au reindex manuel d'une
|
||||
vault. Le plan parlait de le supprimer de `get_inverted_index()`, ce qui est
|
||||
fait, mais la méthode elle-même est conservée.
|
||||
|
||||
### Bug trouvé pendant cette relecture (corrigé ici)
|
||||
|
||||
`remove_vault_from_index()` (`backend/indexer.py`) ne notifiait pas le hook.
|
||||
Conséquence mesurée : après suppression d'une vault, ses 8 documents test
|
||||
restaient dans l'index inversé — `postings`, `doc_info`, `doc_vault`,
|
||||
`vault_docs` — et continuaient de correspondre aux recherches pour une vault
|
||||
inexistante. Seul un reindex manuel les effaçait.
|
||||
|
||||
Le correctif déclenche `_on_index_change('remove', …)` pour chaque fichier de
|
||||
la vault, et `_remove_doc_internals()` supprime désormais la clé `vault_docs`
|
||||
quand son set devient vide (c'est un `defaultdict` : une simple lecture la
|
||||
ré créait). Test de non-régression :
|
||||
`TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le patch).
|
||||
|
||||
@@ -9,6 +9,15 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
timeout: 60000,
|
||||
expect: { timeout: 10000 },
|
||||
// BUG-080 : la suite (~120 tests, workers: 1, ~10-15 s/test sur un poste
|
||||
// chargé) ne doit jamais pendre toute la nuit. Au-delà du timeout global,
|
||||
// Playwright abandonne avec un échec explicite au lieu de bloquer.
|
||||
// Surchargable : E2E_GLOBAL_TIMEOUT_MS.
|
||||
globalTimeout: Number(
|
||||
process.env.E2E_GLOBAL_TIMEOUT_MS ??
|
||||
(process.env.CI ? 30 * 60 * 1000 : 25 * 60 * 1000),
|
||||
),
|
||||
reportSlowTests: process.env.CI ? null : { max: 5, threshold: 30000 },
|
||||
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:2029',
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
ObsiGate — cycle de vie du serveur E2E local, avec progression visible.
|
||||
|
||||
.DESCRIPTION
|
||||
Remplace le one-liner opaque de démarrage : chaque étape affiche sa
|
||||
progression (port, PID, attente du health check seconde par seconde,
|
||||
version servie). Memes conditions que le job CI `e2e` et que
|
||||
`scripts/run-e2e-local.ps1` : uvicorn natif, auth désactivée, fixtures
|
||||
TestVault/TestDir, port 2029.
|
||||
|
||||
Le PID est persisté dans `data/e2e-server.pid` pour un arrêt propre
|
||||
(`stop`) — plus de serveurs orphelins qui squattent le port.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/e2e-server.ps1 start # démarre + attend READY (défaut)
|
||||
./scripts/e2e-server.ps1 status # port, PID, version servie
|
||||
./scripts/e2e-server.ps1 logs # queues des logs serveur
|
||||
./scripts/e2e-server.ps1 stop # arrête le serveur + libère le port
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[ValidateSet("start", "stop", "status", "logs")]
|
||||
[string]$Command = "start",
|
||||
|
||||
[string]$Port = $(if ($env:E2E_PORT) { $env:E2E_PORT } else { "2029" })
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location -LiteralPath $Root
|
||||
|
||||
$BaseUrl = "http://127.0.0.1:$Port"
|
||||
$Python = ".\.venv\Scripts\python.exe"
|
||||
$PidFile = "data/e2e-server.pid"
|
||||
$OutLog = "data/e2e-server.log"
|
||||
$ErrLog = "data/e2e-server.err.log"
|
||||
|
||||
function Get-PortOwner {
|
||||
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if (-not $conn) { return $null }
|
||||
$proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue
|
||||
return @{ Pid = $conn.OwningProcess; Name = $(if ($proc) { $proc.ProcessName } else { "?" }) }
|
||||
}
|
||||
|
||||
function Stop-Server {
|
||||
param([string]$Why = "")
|
||||
$killed = @()
|
||||
if (Test-Path -LiteralPath $PidFile) {
|
||||
$srvPid = (Get-Content -LiteralPath $PidFile -TotalCount 1).Trim()
|
||||
if ($srvPid -match '^\d+$') {
|
||||
# BUG-080 : le PID enregistré peut avoir ré-exécuté uvicorn dans un
|
||||
# processus enfant (constaté : parent .venv + enfant uv-python sur
|
||||
# le port) — tuer l'arbre complet, pas seulement la racine.
|
||||
Get-CimInstance Win32_Process -Filter "ParentProcessId=$srvPid" -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $killed += $_.ProcessId }
|
||||
Stop-Process -Id $srvPid -Force -ErrorAction SilentlyContinue
|
||||
$killed += $srvPid
|
||||
}
|
||||
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$owner = Get-PortOwner
|
||||
if ($owner) {
|
||||
Stop-Process -Id $owner.Pid -Force -ErrorAction SilentlyContinue
|
||||
$killed += $owner.Pid
|
||||
}
|
||||
if ($killed.Count) { Write-Host "[OK] Serveur arrêté (PID $($killed -join ', ')). $Why" }
|
||||
else { Write-Host "[OK] Aucun serveur en cours (port $Port libre)." }
|
||||
}
|
||||
|
||||
switch ($Command) {
|
||||
"stop" {
|
||||
Write-Host "[1/1] Arrêt du serveur E2E (port $Port)..."
|
||||
Stop-Server
|
||||
}
|
||||
|
||||
"status" {
|
||||
$owner = Get-PortOwner
|
||||
if (-not $owner) { Write-Host "[INFO] Port $Port libre, aucun serveur."; break }
|
||||
Write-Host "[INFO] Port $Port occupé par PID $($owner.Pid) ($($owner.Name))."
|
||||
try {
|
||||
$health = Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 5 -UseBasicParsing |
|
||||
Select-Object -ExpandProperty Content | ConvertFrom-Json
|
||||
Write-Host "[OK] Health 200 — version $($health.version), $($health.total_files) fichiers indexés."
|
||||
} catch {
|
||||
Write-Host "[WARN] Processus présent mais health injoignable : $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
"logs" {
|
||||
Write-Host "===== $OutLog (stdout) ====="
|
||||
Get-Content -LiteralPath $OutLog -Tail 15 -ErrorAction SilentlyContinue
|
||||
Write-Host "===== $ErrLog (stderr) ====="
|
||||
Get-Content -LiteralPath $ErrLog -Tail 25 -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
"start" {
|
||||
Write-Host "[1/4] Port $Port..."
|
||||
$owner = Get-PortOwner
|
||||
if ($owner) {
|
||||
Write-Host "[ERR] Port $Port déjà occupé par PID $($owner.Pid) ($($owner.Name))."
|
||||
Write-Host " Lancez d'abord : ./scripts/e2e-server.ps1 stop"
|
||||
exit 1
|
||||
}
|
||||
Write-Host " libre."
|
||||
|
||||
Write-Host "[2/4] Interpréteur $Python..."
|
||||
if (-not (Test-Path -LiteralPath $Python)) {
|
||||
Write-Host "[ERR] $Python introuvable. Créez le venv (voir AGENTS.md)."
|
||||
exit 1
|
||||
}
|
||||
Write-Host " présent."
|
||||
New-Item -ItemType Directory -Force -Path "data" | Out-Null
|
||||
|
||||
Write-Host "[3/4] Démarrage uvicorn (auth désactivée, TestVault/TestDir)..."
|
||||
$env:OBSIGATE_AUTH_ENABLED = "false"
|
||||
$env:VAULT_1_NAME = "TestVault"
|
||||
$env:VAULT_1_PATH = (Resolve-Path -LiteralPath "test_vault").Path
|
||||
$env:DIR_1_NAME = "TestDir"
|
||||
$env:DIR_1_PATH = (Resolve-Path -LiteralPath "test_dir").Path
|
||||
$server = Start-Process -FilePath $Python `
|
||||
-ArgumentList "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", $Port `
|
||||
-RedirectStandardOutput $OutLog -RedirectStandardError $ErrLog `
|
||||
-PassThru -WindowStyle Hidden
|
||||
$server.Id | Set-Content -LiteralPath $PidFile
|
||||
Write-Host " PID $($server.Id) (logs : $OutLog / $ErrLog)."
|
||||
|
||||
Write-Host "[4/4] Attente du health check (30 s max)..."
|
||||
$ready = $false
|
||||
for ($i = 1; $i -le 30; $i++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
|
||||
$ready = $true
|
||||
break
|
||||
} catch {
|
||||
if ($server.HasExited) {
|
||||
Write-Host "[ERR] Le serveur a quitté (code $($server.ExitCode)). Fin du log :"
|
||||
Get-Content -LiteralPath $ErrLog -Tail 15 -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
if ($i % 5 -eq 0) { Write-Host " ... $i/30 s (indexation en cours, voir $ErrLog)" }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
if (-not $ready) {
|
||||
Write-Host "[ERR] Injoignable après 30 s. Fin du log :"
|
||||
Get-Content -LiteralPath $ErrLog -Tail 15 -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
$health = Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 5 -UseBasicParsing |
|
||||
Select-Object -ExpandProperty Content | ConvertFrom-Json
|
||||
# BUG-080 : le PID `Start-Process` peut ne pas être celui qui écoute
|
||||
# (ré-exécution enfant constatée) — persister le vrai propriétaire du
|
||||
# port pour un `stop` fiable, sans serveurs orphelins.
|
||||
$owner = Get-PortOwner
|
||||
if ($owner) { $owner.Pid | Set-Content -LiteralPath $PidFile }
|
||||
Write-Host "[OK] READY — ObsiGate v$($health.version) sur $BaseUrl ($($health.total_files) fichiers)."
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
conditions que le job CI `e2e`), lance la suite Playwright puis nettoie.
|
||||
|
||||
.PARAMETER PlaywrightArgs
|
||||
Arguments transmis à `npx playwright test`, ex. `-g "image viewer"`,
|
||||
Arguments transmis à `playwright test` (via `node`), ex. `-g "image viewer"`,
|
||||
`--headed`.
|
||||
|
||||
.EXAMPLE
|
||||
@@ -34,6 +34,40 @@ $BaseUrl = "http://127.0.0.1:$Port"
|
||||
$ServerLog = "data/e2e-server.log"
|
||||
$ServerErrLog = "data/e2e-server.err.log"
|
||||
|
||||
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
|
||||
# E2E_TIMEOUT_SEC dépasse volontairement le globalTimeout Playwright (25 min en
|
||||
# local) pour que ce soit Playwright qui abandonne proprement (avec rapport) en premier.
|
||||
$TestTimeoutSec = if ($env:E2E_TIMEOUT_SEC) { [int]$env:E2E_TIMEOUT_SEC } else { 1800 }
|
||||
$BrowserTimeoutSec = if ($env:E2E_BROWSER_INSTALL_TIMEOUT_SEC) { [int]$env:E2E_BROWSER_INSTALL_TIMEOUT_SEC } else { 600 }
|
||||
|
||||
function Invoke-NativeWithTimeout([string]$Label, [int]$TimeoutSec, [string]$Exe, [string[]]$Arguments) {
|
||||
# Lance un processus natif en gardant la sortie console en direct, et le
|
||||
# tue après $TimeoutSec s'il n'a pas terminé (exit 124, comme `timeout`).
|
||||
# NOTE : le paramètre NE DOIT PAS s'appeler `$Args` (variable automatique
|
||||
# PowerShell qui l'écraserait → `node` lancé sans arguments, exit 0
|
||||
# silencieux immédiat en lisant un stdin vide).
|
||||
$stamp = Get-Date -Format "HH:mm:ss"
|
||||
Write-Host "[$stamp] $Label (timeout ${TimeoutSec}s)..."
|
||||
$proc = Start-Process -FilePath $Exe -ArgumentList $Arguments -NoNewWindow -PassThru
|
||||
$proc | Wait-Process -Timeout $TimeoutSec -ErrorAction SilentlyContinue
|
||||
if (-not $proc.HasExited) {
|
||||
Write-Host "[ERR] $Label : timeout après ${TimeoutSec}s, arrêt du processus (PID $($proc.Id))."
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
return 124
|
||||
}
|
||||
return $proc.ExitCode
|
||||
}
|
||||
|
||||
function Test-ChromiumInstalled {
|
||||
$base = Join-Path $env:USERPROFILE "AppData\Local\ms-playwright"
|
||||
if (-not (Test-Path -LiteralPath $base)) { return $false }
|
||||
$hit = Get-ChildItem -LiteralPath $base -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like "chromium-*" } |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "chrome-win\chrome.exe") } |
|
||||
Select-Object -First 1
|
||||
return ($null -ne $hit)
|
||||
}
|
||||
|
||||
function Assert-Command([string]$Name, [string]$Hint) {
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "[ERR] $Name introuvable. $Hint"
|
||||
@@ -41,7 +75,7 @@ function Assert-Command([string]$Name, [string]$Hint) {
|
||||
}
|
||||
|
||||
Assert-Command "uv" "Installez-le : https://docs.astral.sh/uv/"
|
||||
Assert-Command "npx" "Installez Node.js (>= 20)."
|
||||
Assert-Command "node" "Installez Node.js (>= 20)."
|
||||
|
||||
# ----- Venv Python 3.11 (créé une seule fois) -----
|
||||
$Python = ".venv-e2e/Scripts/python.exe"
|
||||
@@ -101,14 +135,32 @@ try {
|
||||
}
|
||||
Write-Host "[OK] Serveur prêt."
|
||||
|
||||
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
|
||||
npx playwright install chromium
|
||||
# ----- Playwright via node direct (pas npx) -----
|
||||
# BUG-080 : `Start-Process` ne peut pas lancer `npx` (ni le `.ps1` ni le
|
||||
# `.cmd` ne sont des applications Win32 directes) → on appelle la CLI
|
||||
# locale via `node.exe`, sans prompt interactif possible. Skip de
|
||||
# l'install si un chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1),
|
||||
# timeouts dédiés sur chaque étape.
|
||||
$PlaywrightCli = Join-Path $Root "node_modules/@playwright/test/cli.js"
|
||||
if (-not (Test-Path -LiteralPath $PlaywrightCli)) {
|
||||
throw "[ERR] $PlaywrightCli introuvable. Lancez d'abord : npm ci"
|
||||
}
|
||||
if (($env:E2E_INSTALL_BROWSERS -eq "1") -or (-not (Test-ChromiumInstalled))) {
|
||||
$code = Invoke-NativeWithTimeout "playwright install chromium" $BrowserTimeoutSec "node" @($PlaywrightCli, "install", "chromium")
|
||||
if ($code -ne 0) { exit $code }
|
||||
} else {
|
||||
Write-Host "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
|
||||
}
|
||||
|
||||
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
|
||||
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
|
||||
Write-Host "[INFO] BASE_URL=$BaseUrl node $PlaywrightCli test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
|
||||
$env:BASE_URL = $BaseUrl
|
||||
& npx playwright test --project=chromium-desktop @PlaywrightArgs
|
||||
$exitCode = $LASTEXITCODE
|
||||
$testArgs = @($PlaywrightCli, "test", "--project=chromium-desktop")
|
||||
if ($PlaywrightArgs) { $testArgs += @($PlaywrightArgs) }
|
||||
$exitCode = Invoke-NativeWithTimeout "playwright test" $TestTimeoutSec "node" $testArgs
|
||||
} catch {
|
||||
Write-Host "[ERR] $($_.Exception.Message)"
|
||||
$exitCode = 1
|
||||
} finally {
|
||||
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
|
||||
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
@@ -23,6 +23,21 @@ cd "$(dirname "$0")/.."
|
||||
PORT="${E2E_PORT:-2029}"
|
||||
BASE_URL="http://127.0.0.1:$PORT"
|
||||
SERVER_LOG="data/e2e-server.log"
|
||||
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
|
||||
E2E_TIMEOUT_SEC="${E2E_TIMEOUT_SEC:-900}"
|
||||
E2E_BROWSER_INSTALL_TIMEOUT_SEC="${E2E_BROWSER_INSTALL_TIMEOUT_SEC:-600}"
|
||||
|
||||
# Exécute "$@" avec un timeout dur (exit 124 comme `timeout`), sans timeout si
|
||||
# la commande `timeout` est absente (ex. macOS sans coreutils).
|
||||
run_with_timeout() {
|
||||
local limit="$1"; shift
|
||||
if command -v timeout &>/dev/null; then
|
||||
timeout "$limit" "$@"
|
||||
else
|
||||
echo "[WARN] commande 'timeout' absente : $1 sans limite de ${limit}s" >&2
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ----- Prérequis -----
|
||||
if ! command -v uv &>/dev/null; then
|
||||
@@ -102,8 +117,14 @@ curl -sf "$BASE_URL/api/health" >/dev/null || {
|
||||
}
|
||||
|
||||
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
|
||||
npx playwright install chromium
|
||||
# BUG-080 : `--yes` (jamais de prompt interactif npx qui pend), skip si un
|
||||
# chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1), timeout dédié.
|
||||
if [[ "${E2E_INSTALL_BROWSERS:-0}" == "1" ]] || ! ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome &>/dev/null; then
|
||||
run_with_timeout "$E2E_BROWSER_INSTALL_TIMEOUT_SEC" npx --yes playwright install chromium
|
||||
else
|
||||
echo "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
|
||||
fi
|
||||
|
||||
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
|
||||
echo "[INFO] BASE_URL=$BASE_URL npx playwright test --project=chromium-desktop $*"
|
||||
BASE_URL="$BASE_URL" npx playwright test --project=chromium-desktop "$@"
|
||||
BASE_URL="$BASE_URL" run_with_timeout "$E2E_TIMEOUT_SEC" npx --yes playwright test --project=chromium-desktop "$@"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# ObsiGate — règles Semgrep locales (#87 T7).
|
||||
#
|
||||
# Volontairement LOCALES (aucun `--config auto`/registre) : le runner CI a un
|
||||
# accès réseau fragile, et ces règles n'ont besoin d'aucun téléchargement.
|
||||
# Exécution : `semgrep --config semgrep-rules/ backend/` (job CI `lint`,
|
||||
# bloquant). Chaque règle est un garde-fou : aucun code existant ne doit
|
||||
# la déclencher (vérifié à l'ajout) ; toute violation future échoue le CI.
|
||||
rules:
|
||||
- id: obsigate-no-eval-exec
|
||||
message: "Interdit : eval()/exec() sur du contenu dynamique (injection de code). Restructurer sans exécution de code."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: eval(...)
|
||||
- pattern: exec(...)
|
||||
|
||||
- id: obsigate-no-shell-true
|
||||
message: "Interdit : subprocess avec shell=True (injection shell). Passer argv en liste, shell=False."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: subprocess.run(..., shell=True, ...)
|
||||
- pattern: subprocess.Popen(..., shell=True, ...)
|
||||
- pattern: subprocess.call(..., shell=True, ...)
|
||||
- pattern: subprocess.check_output(..., shell=True, ...)
|
||||
- pattern: subprocess.check_call(..., shell=True, ...)
|
||||
|
||||
- id: obsigate-no-os-system
|
||||
message: "Interdit : os.system() (shell implicite). Utiliser subprocess avec argv en liste."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern: os.system(...)
|
||||
|
||||
- id: obsigate-no-pickle-load
|
||||
message: "Interdit : pickle.load/loads sur des données non fiables (exécution arbitraire). Utiliser JSON."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: pickle.load(...)
|
||||
- pattern: pickle.loads(...)
|
||||
|
||||
- id: obsigate-no-yaml-unsafe-load
|
||||
message: "Interdit : yaml.load() sans Loader (exécution arbitraire). Utiliser yaml.safe_load()."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
patterns:
|
||||
- pattern: yaml.load(...)
|
||||
- pattern-not: yaml.load(..., Loader=...)
|
||||
|
||||
- id: obsigate-no-unverified-tls
|
||||
message: "Interdit : verify=False (MITM). Ne jamais désactiver la vérification TLS."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: requests.$METHOD(..., verify=False, ...)
|
||||
- pattern: httpx.$METHOD(..., verify=False, ...)
|
||||
- pattern: httpx.Client(..., verify=False, ...)
|
||||
- pattern: httpx.AsyncClient(..., verify=False, ...)
|
||||
|
||||
- id: obsigate-no-markupsafe-markup
|
||||
message: "Interdit : markupsafe.Markup() (contourne l'échappement XSS, BUG-021/022). Le sanitizer serveur est la seule voie."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern: Markup(...)
|
||||
|
||||
- id: obsigate-no-tempfile-mktemp
|
||||
message: "Interdit : tempfile.mktemp() (race symlink, CWE-377). Utiliser NamedTemporaryFile/mkdtemp."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern: tempfile.mktemp(...)
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* E2E tests — Excel viewer, write guards (ROADMAP #153 P0).
|
||||
*
|
||||
* Fixture : `test_vault/sample-xlsx-lossy.xlsx` — a plain 2x2 workbook whose
|
||||
* sheet XML carries a cached formula result (`<f>B1*2</f><v>200</v>`) and whose
|
||||
* package contains `xl/slicers/slicer1.xml`. Both are dropped by an openpyxl
|
||||
* round-trip, so the read response must report
|
||||
* `xlsx_lossy_features: ["cached_values", "slicers"]` (BUG-085 A1).
|
||||
*
|
||||
* Covered :
|
||||
* - the warning banner lists both features ;
|
||||
* - saving a cell on that workbook asks for confirmation (native dialog) and
|
||||
* then succeeds (the client retries with `force: true`) ;
|
||||
* - the f(x) toggle is off by default, so "=B1*3" is stored as text ;
|
||||
* - the value Excel last computed is shown under the formula (#153 A12).
|
||||
*
|
||||
* Second describe block — `test_vault/sample-xlsx-large.xlsx` (520 rows) :
|
||||
* - a sheet over the render caps SAYS it instead of looking complete (#153 A8) ;
|
||||
* - the column headers stay pinned while the sheet scrolls (#153 A8) ;
|
||||
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
|
||||
* with the real A1 coordinates (#153 A9).
|
||||
*
|
||||
* The fixture is restored byte-for-byte in `afterAll` so a local run never
|
||||
* dirties the working copy.
|
||||
*
|
||||
* Run (local) : BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xlsx-viewer.spec.js
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
const VAULT = 'TestVault';
|
||||
const FIXTURE = 'sample-xlsx-lossy.xlsx';
|
||||
// #153 A8 — 520 rows x 3 columns: the sheet exceeds the 500-row render cap, so
|
||||
// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
|
||||
// committed next to the other fixture; nothing in the suite writes to it.
|
||||
const LARGE = 'sample-xlsx-large.xlsx';
|
||||
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
|
||||
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
||||
|
||||
let originalBytes = null;
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(BASE);
|
||||
const loginForm = page.locator('#login-screen');
|
||||
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
|
||||
if (await loginForm.isVisible()) {
|
||||
await page.fill('#login-username', process.env.OBSIGATE_USER || 'admin');
|
||||
await page.fill('#login-password', process.env.OBSIGATE_PASS || 'test123');
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFixture(page) {
|
||||
return openXlsx(page, FIXTURE);
|
||||
}
|
||||
|
||||
async function openXlsx(page, file) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${file}"]`);
|
||||
if (!(await treeItem.count())) {
|
||||
await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click();
|
||||
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#153)', () => {
|
||||
test.beforeAll(() => {
|
||||
if (existsSync(FIXTURE_PATH)) originalBytes = readFileSync(FIXTURE_PATH);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
if (originalBytes) writeFileSync(FIXTURE_PATH, originalBytes);
|
||||
});
|
||||
|
||||
// Read-only assertions come FIRST, before the mutating tests: saving through
|
||||
// the viewer rewrites the workbook and an openpyxl round-trip drops the cached
|
||||
// formula results (BUG-085), so the shadow line only exists on a pristine
|
||||
// fixture.
|
||||
test('affiche la valeur calculée en cache sous la formule (#153 A12)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>).
|
||||
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await expect(formulaCell).toContainText('=B1*2');
|
||||
|
||||
const cached = formulaCell.locator('.xlsx-cached');
|
||||
await expect(cached).toHaveCount(1);
|
||||
await expect(cached).toHaveText('200');
|
||||
// The tooltip is translated client-side, never hardcoded by the backend.
|
||||
await expect(cached).toHaveAttribute('title', /Excel/);
|
||||
|
||||
// A plain value cell must not be duplicated with a shadow line.
|
||||
await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('affiche la bannière listant les éléments non préservés', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const banner = page.locator('#content-area .xlsx-warning');
|
||||
await expect(banner).toBeVisible();
|
||||
// 2 features : valeurs calculées + segments (jamais de couleur codée en dur,
|
||||
// les libellés viennent bien des locales).
|
||||
await expect(banner.locator('.xlsx-warning-tag')).toHaveCount(2);
|
||||
await expect(banner).toContainText('segments');
|
||||
await expect(banner).toContainText('valeurs calculées');
|
||||
});
|
||||
|
||||
test('demande confirmation puis enregistre la cellule', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
let dialogMessage = null;
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogMessage = dialog.message();
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
const cell = page.locator('#content-area td[data-cell="A2"]');
|
||||
await cell.click();
|
||||
await cell.fill('Total confirmé');
|
||||
await cell.press('Enter');
|
||||
|
||||
const save = page.locator('#xlsx-save-btn');
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
|
||||
await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments');
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// La cellule reste modifiée côté UI (plus de marque « sale »).
|
||||
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('le toggle f(x) est désactivé par défaut (formule stockée en texte)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const toggle = page.locator('#xlsx-formula-btn');
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
// 409 → confirmation, puis reprise avec force (le toggle reste désactivé).
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await cell.click();
|
||||
await cell.fill('=B1*3');
|
||||
await cell.press('Enter');
|
||||
await page.locator('#xlsx-save-btn').click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── A8 — troncature annoncée + en-têtes figés ───────────────────────────────
|
||||
|
||||
test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
||||
test('annonce la feuille tronquée au lieu de la couper en silence', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const note = page.locator('#content-area .xlsx-truncated');
|
||||
await expect(note).toBeVisible();
|
||||
// Libellé traduit (jamais de texte UI backend, jamais de couleur en dur).
|
||||
await expect(note).toContainText('Feuille tronquée');
|
||||
await expect(note).toContainText('500 lignes affichées sur 520');
|
||||
|
||||
// La dernière ligne rendue est la 500e ; les suivantes ne sont pas là.
|
||||
await expect(page.locator('#content-area td[data-cell="A500"]')).toHaveCount(1);
|
||||
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('garde les en-têtes de colonnes visibles au défilement', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const header = page.locator('#content-area .xlsx-table thead th').nth(1);
|
||||
const before = await header.boundingBox();
|
||||
|
||||
await page.locator('#content-area .csv-table-wrapper').evaluate((el) => { el.scrollTop = 800; });
|
||||
await expect.poll(async () => (await header.boundingBox()).y, { timeout: 5000 })
|
||||
.toBeLessThanOrEqual(before.y + 1);
|
||||
|
||||
// Les numéros de ligne ne se superposent pas en haut à gauche (le `top: auto`
|
||||
// de A8) et la première ligne de données reste lisible sous l'en-tête.
|
||||
const first = await page.locator('#content-area th.xlsx-rownum').first().boundingBox();
|
||||
const second = await page.locator('#content-area th.xlsx-rownum').nth(1).boundingBox();
|
||||
expect(second.y - first.y).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
test('l\'endpoint de fenêtre sert les lignes au-delà du plafond (#153 A9)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const res = await page.request.get(
|
||||
`${BASE}/api/file/${VAULT}/xlsx/sheet?path=${encodeURIComponent(LARGE)}&sheet=Journal&offset=500&limit=50`
|
||||
);
|
||||
expect(res.status()).toBe(200);
|
||||
const win = await res.json();
|
||||
expect(win.total_rows).toBe(520);
|
||||
expect(win.offset).toBe(500);
|
||||
expect(win.has_more).toBe(false);
|
||||
// Les coordonnées A1 sont celles de la feuille, pas celles de la fenêtre :
|
||||
// la ligne 520 est servie comme A520, pas comme A20.
|
||||
expect(win.html).toContain('data-cell="A520"');
|
||||
expect(win.html).toContain('Operation 519');
|
||||
expect(win.html).not.toContain('data-cell="A1"');
|
||||
});
|
||||
|
||||
test('le bouton « charger la suite » ajoute les lignes cachées (#153 A9bis)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
// La ligne 500 est la dernière rendue ; le pied de page l'annonce.
|
||||
const foot = page.locator('#content-area .xlsx-load-more');
|
||||
await expect(foot).toBeVisible();
|
||||
await expect(foot).toContainText('Charger la suite');
|
||||
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
|
||||
|
||||
// Un clic fetch la suite (offset 500, 20 lignes) et l'insère dans la table.
|
||||
await foot.click();
|
||||
await expect(page.locator('#content-area td[data-cell="A520"]')).toBeVisible({ timeout: 10000 });
|
||||
// Une ligne nouvellement arrivée est éditable comme les autres.
|
||||
const cell = page.locator('#content-area td[data-cell="A520"]');
|
||||
await cell.click();
|
||||
await expect(cell).toBeFocused();
|
||||
// Tout est chargé → le pied de page est masqué.
|
||||
await expect(foot).toBeHidden();
|
||||
});
|
||||
|
||||
test('barre de formule et navigation clavier (#153 A7)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
// Un clic sur une cellule active la barre avec son nom et son contenu.
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await cell.click();
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('B2');
|
||||
const bar = page.locator('#xlsx-formula-input');
|
||||
await expect(bar).toHaveValue(/Operation 1/);
|
||||
|
||||
// Les flèches déplacent la cellule active.
|
||||
await cell.press('ArrowDown');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('B3');
|
||||
await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('C3');
|
||||
|
||||
// Éditer depuis la barre marque la cellule dirty, Échap annule.
|
||||
await bar.fill('Operation 2 modifiee');
|
||||
await expect(page.locator('#content-area td[data-cell="C3"]')).toHaveClass(/xlsx-dirty/);
|
||||
await page.locator('#content-area td[data-cell="C3"]').press('Escape');
|
||||
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* E2E tests — XSS stocké : page publique de partage + lecteur markdown (#87 T4).
|
||||
*
|
||||
* Non-régression BUG-021 (sanitizer serveur du rendu markdown) et BUG-022
|
||||
* (échappement `title`/frontmatter + neutralisation `</script>` sur `/s/{token}`).
|
||||
* Chaque test arme un guetteur de dialogues `alert` : le moindre JS exécuté
|
||||
* fait échouer le test, en plus des assertions DOM (contenu échappé, aucun
|
||||
* attribut `on*` vivant).
|
||||
*
|
||||
* Run (local, instance de test port 2029, auth désactivée — cf. scripts/run-e2e-local.ps1) :
|
||||
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xss.spec.js --project=chromium-desktop
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
const VAULT = 'TestVault';
|
||||
const XSS_FILE = 'e2e-xss-probe.md';
|
||||
const XSS_TITLE = '<img src=x onerror="window.__xss_title=1">';
|
||||
const XSS_BODY = [
|
||||
'# Sonde XSS',
|
||||
'',
|
||||
'<img src=x onerror="window.__xss_body=1">',
|
||||
'',
|
||||
'<script>window.__xss_script=1</script>',
|
||||
'',
|
||||
'[xss](javascript:window.__xss_js=1)',
|
||||
].join('\n');
|
||||
|
||||
async function api(request, method, path, data) {
|
||||
const resp = await request.fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
data,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!resp.ok()) {
|
||||
throw new Error(`${method} ${path} → ${resp.status()} ${await resp.text()}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function precleanProbeFile(request) {
|
||||
// Idempotence : un run précédent interrompu a pu laisser le fichier sonde.
|
||||
await request.fetch(`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function armAlertTrap(page) {
|
||||
const dialogs = [];
|
||||
page.on('dialog', async (d) => {
|
||||
dialogs.push(d.message());
|
||||
await d.dismiss();
|
||||
});
|
||||
return dialogs;
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
|
||||
if (!(await treeItem.count())) {
|
||||
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
|
||||
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('XSS — page publique de partage (/s/{token}, BUG-022)', () => {
|
||||
test('un titre/frontmatter hostile est échappé et aucun script ne tourne', async ({ page, request }) => {
|
||||
const dialogs = await armAlertTrap(page);
|
||||
|
||||
await precleanProbeFile(request);
|
||||
await api(request, 'POST', `/api/file/${VAULT}`, {
|
||||
path: XSS_FILE,
|
||||
// Titre entre quotes simples YAML (les doubles quotes internes restent
|
||||
// des caractères ordinaires et arrivent intactes au backend).
|
||||
content: `---\ntitle: '${XSS_TITLE}'\n---\n\n${XSS_BODY}\n`,
|
||||
});
|
||||
const share = await api(request, 'POST', `/api/share/${VAULT}`, { path: XSS_FILE });
|
||||
|
||||
await page.goto(`${BASE}/s/${share.token}`);
|
||||
await expect(page.locator('.share-banner')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Le titre affiché est le texte brut (balise neutralisée), pas un <img> vivant.
|
||||
await expect(page.locator('.toolbar-title')).toContainText('<img src=x', { timeout: 5000 });
|
||||
expect(await page.locator('.toolbar-title img').count()).toBe(0);
|
||||
expect(await page.locator('img[onerror]').count()).toBe(0);
|
||||
// Les 2 <script> de la page sont son code statique : le JSON embarqué
|
||||
// (`#raw-content`) doit être neutralisé (aucun `</script>` littéral).
|
||||
const rawEmbedded = await page.evaluate(() => {
|
||||
const el = document.getElementById('raw-content');
|
||||
return { text: el ? el.textContent : null };
|
||||
});
|
||||
expect(rawEmbedded.text).not.toBeNull();
|
||||
expect(rawEmbedded.text).not.toContain('</script');
|
||||
expect(rawEmbedded.text).toContain('\\u003c');
|
||||
|
||||
// Aucun payload n'a tourné (titre, corps, bloc script, lien javascript:).
|
||||
const flags = await page.evaluate(() => ({
|
||||
title: window.__xss_title,
|
||||
body: window.__xss_body,
|
||||
script: window.__xss_script,
|
||||
js: window.__xss_js,
|
||||
}));
|
||||
expect(flags).toEqual({ title: undefined, body: undefined, script: undefined, js: undefined });
|
||||
expect(dialogs).toEqual([]);
|
||||
|
||||
await api(request, 'DELETE', `/api/share/${share.id}`);
|
||||
await api(request, 'DELETE', `/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('XSS — lecteur markdown (BUG-021)', () => {
|
||||
test('le HTML injecté dans une note est neutralisé à l\'affichage', async ({ page, request }) => {
|
||||
const dialogs = await armAlertTrap(page);
|
||||
|
||||
await precleanProbeFile(request);
|
||||
await api(request, 'POST', `/api/file/${VAULT}`, { path: XSS_FILE, content: `${XSS_BODY}\n` });
|
||||
|
||||
await page.goto(BASE);
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await openFile(page, VAULT, XSS_FILE);
|
||||
|
||||
const content = page.locator('#content-area');
|
||||
await expect(content).toContainText('Sonde XSS', { timeout: 10000 });
|
||||
|
||||
// Le sanitizer serveur a retiré les vecteurs : pas d'onerror, pas de script,
|
||||
// pas de lien javascript: exécutable dans la zone de lecture.
|
||||
expect(await content.locator('img[onerror]').count()).toBe(0);
|
||||
expect(await content.locator('script').count()).toBe(0);
|
||||
expect(await content.locator('a[href^="javascript:"]').count()).toBe(0);
|
||||
|
||||
const flags = await page.evaluate(() => ({
|
||||
body: window.__xss_body,
|
||||
script: window.__xss_script,
|
||||
js: window.__xss_js,
|
||||
}));
|
||||
expect(flags).toEqual({ body: undefined, script: undefined, js: undefined });
|
||||
expect(dialogs).toEqual([]);
|
||||
|
||||
await api(request, 'DELETE', `/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`);
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,7 @@ await test("module exports renderExcalidraw + helpers", () => {
|
||||
assert.equal(typeof destroyExcalidrawEditor, "function");
|
||||
});
|
||||
|
||||
await test("renderExcalidraw creates an iframe with sandbox + static src", () => {
|
||||
await test("renderExcalidraw creates an iframe with sandbox + routed src", () => {
|
||||
const container = document.getElementById("content-area");
|
||||
const data = {
|
||||
is_excalidraw: true,
|
||||
@@ -111,7 +111,8 @@ await test("renderExcalidraw creates an iframe with sandbox + static src", () =>
|
||||
renderExcalidraw(container, data, "TestVault", "diagram.excalidraw");
|
||||
const iframe = container.querySelector("iframe");
|
||||
assert.ok(iframe, "iframe should be created");
|
||||
assert.ok(iframe.src.includes("/static/excalidraw-editor.html"), `src: ${iframe.src}`);
|
||||
assert.ok(iframe.src.includes("/excalidraw-editor.html"), `src: ${iframe.src}`);
|
||||
assert.ok(!iframe.src.includes("/static/excalidraw-editor.html"), `route avec nonce CSP: ${iframe.src}`);
|
||||
assert.ok(iframe.sandbox.contains("allow-scripts"), "sandbox allow-scripts");
|
||||
assert.ok(iframe.sandbox.contains("allow-same-origin"), "sandbox allow-same-origin");
|
||||
assert.match(iframe.style.cssText, /100%/);
|
||||
|
||||
@@ -22,6 +22,7 @@ const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf
|
||||
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
|
||||
const filesRead = readFileSync(path.join(ROOT, "backend", "routers", "files_read.py"), "utf8");
|
||||
|
||||
function test(label, fn) {
|
||||
try {
|
||||
@@ -186,8 +187,9 @@ test("utils.js maps image extensions to the Lucide 'image' icon", () => {
|
||||
});
|
||||
|
||||
test("backend api_file_view points <img> at /api/image", () => {
|
||||
assert.match(main, /img_url = f"\/api\/image\//);
|
||||
assert.match(main, /f'<img src="\{img_url\}"/);
|
||||
// #85 T6a : le handler vit dans backend/routers/files_read.py
|
||||
assert.match(filesRead, /img_url = f"\/api\/image\//);
|
||||
assert.match(filesRead, /f'<img src="\{img_url\}"/);
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
|
||||
@@ -24,6 +24,9 @@ const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8"
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
|
||||
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
|
||||
const filesMedia = readFileSync(path.join(ROOT, "backend", "routers", "files_media.py"), "utf8");
|
||||
const filesRead = readFileSync(path.join(ROOT, "backend", "routers", "files_read.py"), "utf8");
|
||||
const routerHelpers = readFileSync(path.join(ROOT, "backend", "routers", "helpers.py"), "utf8");
|
||||
const fr = readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8");
|
||||
const en = readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8");
|
||||
|
||||
@@ -177,9 +180,11 @@ test("service worker never caches streamed media", () => {
|
||||
|
||||
// ── Static checks: backend ─────────────────────────────────────────────────
|
||||
test("backend exposes /api/media and the shared Range helper", () => {
|
||||
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
|
||||
assert.match(main, /def _stream_file_with_range\(/);
|
||||
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
|
||||
// #85 T6c : routes dans backend/routers/files_media.py, helper Range partagé
|
||||
// dans backend/routers/helpers.py, branche audio/vidéo dans files_read.py
|
||||
assert.match(filesMedia, /@router\.get\("\/api\/media\/\{vault_name\}"/);
|
||||
assert.match(routerHelpers, /def stream_file_with_range\(/);
|
||||
assert.match(filesRead, /is_audio\(ext\) or is_video\(ext\)/);
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM integration tests for the Excel viewer (ROADMAP #153 P0).
|
||||
*
|
||||
* Loads the real viewer.js module and drives renderXlsxViewer():
|
||||
* - A1 : `xlsx_lossy_features` renders a warning banner; a save on such a
|
||||
* workbook gets 409 `xlsx_lossy_content`, asks for confirmation and
|
||||
* retries with `force: true` (or gives up when refused);
|
||||
* - A4 : the f(x) toggle flips `allow_formula` in the save payload.
|
||||
* - A8 : a sheet bigger than the render caps shows the truncation notice.
|
||||
* - A9bis : the tail of a truncated sheet is fetched window by window from
|
||||
* GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are
|
||||
* editable like the initial ones.
|
||||
* - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing
|
||||
* from the bar marks the cell dirty; Escape reverts.
|
||||
* - A13 : header click sorts the rendered rows, the filter hides rows, the
|
||||
* find highlights matches, CSV export downloads the visible sheet.
|
||||
* - A14 : the structure menu sends one PUT …/xlsx/structure with the action,
|
||||
* then re-renders from the server; destructive actions confirm first.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-viewer.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="content-area"></div>
|
||||
</body>
|
||||
</html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true }
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.DOMParser = w.DOMParser;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.MouseEvent = w.MouseEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// ── fetch / confirm doubles ─────────────────────────────────────────────────
|
||||
let calls = [];
|
||||
let nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } });
|
||||
let confirmAnswer = true;
|
||||
let confirmCalls = 0;
|
||||
// Every confirm() prompt is captured so the tests can assert on its text.
|
||||
let confirmPrompts = [];
|
||||
|
||||
const FR = JSON.parse(
|
||||
readFileSync(path.join(REPO_ROOT, "frontend", "locales", "fr.json"), "utf8")
|
||||
);
|
||||
|
||||
globalThis.fetch = async (url, opts = {}) => {
|
||||
// The i18n bootstrap fetches the locale files: serve the real FR one so the
|
||||
// assertions run on the shipped strings, not on raw keys.
|
||||
if (url.includes("/static/locales/")) {
|
||||
return { ok: true, status: 200, json: async () => FR };
|
||||
}
|
||||
calls.push({ url, body: opts.body ? JSON.parse(opts.body) : null });
|
||||
const res = nextResponse(url, opts);
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
json: async () => res.body,
|
||||
};
|
||||
};
|
||||
globalThis.confirm = (msg) => { confirmCalls++; confirmPrompts.push(msg); return confirmAnswer; };
|
||||
w.confirm = globalThis.confirm;
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
calls = [];
|
||||
confirmCalls = 0;
|
||||
confirmPrompts = [];
|
||||
confirmAnswer = true;
|
||||
apiQueue = [];
|
||||
nextResponse = () => {
|
||||
if (apiQueue.length) return apiQueue.shift();
|
||||
return { ok: true, status: 200, body: { status: "ok" } };
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passCount++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { renderXlsxViewer } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "viewer.js")).href
|
||||
);
|
||||
// Load the FR catalog so t() resolves the real strings.
|
||||
const { initI18n } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
|
||||
);
|
||||
await initI18n();
|
||||
|
||||
const sheetHtml = (value) =>
|
||||
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
||||
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td><td data-cell="B1">B1</td></tr>` +
|
||||
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">A2</td><td data-cell="B2">B2</td></tr>' +
|
||||
"</tbody></table></div>";
|
||||
|
||||
// The JSDOM fetch double serves locale files; everything else is a recorded
|
||||
// API call answered by `nextResponse`. `apiQueue` lets a test script the
|
||||
// successive windows a lazy-loading sheet will request.
|
||||
let apiQueue = [];
|
||||
|
||||
function mount({ lossy = [], sheet = {} } = {}) {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V",
|
||||
path: "data.xlsx",
|
||||
is_xlsx: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }],
|
||||
xlsx_lossy_features: lossy,
|
||||
});
|
||||
return area;
|
||||
}
|
||||
|
||||
/** Mount a sheet flagged truncated so wireLazyRows() arms the footnote. */
|
||||
function mountTruncated({ total = 520, rows = 1 } = {}) {
|
||||
return mount({
|
||||
sheet: { rows, cols: 3, total_rows: total, total_cols: 3, max_rows: 500, max_cols: 40, truncated: total > 500 },
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a cell dirty the way a user edit would. */
|
||||
function editCell(area, ref, text) {
|
||||
const td = area.querySelector(`td[data-cell="${ref}"]`);
|
||||
td.textContent = text;
|
||||
td.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
return td;
|
||||
}
|
||||
|
||||
const lossyError = {
|
||||
ok: false,
|
||||
status: 409,
|
||||
body: {
|
||||
detail: "Saving this workbook would drop features…",
|
||||
code: "xlsx_lossy_content",
|
||||
details: { features: ["slicers"] },
|
||||
},
|
||||
};
|
||||
|
||||
console.log("\n── xlsx viewer JSDOM integration tests (#153 P0) ──\n");
|
||||
|
||||
// ── A1 — warning banner ─────────────────────────────────────────────────────
|
||||
|
||||
await test("no banner when the workbook has nothing at risk", () => {
|
||||
const area = mount();
|
||||
assert.equal(area.querySelector(".xlsx-warning"), null);
|
||||
});
|
||||
|
||||
await test("banner lists every lossy feature reported by the backend", () => {
|
||||
const area = mount({ lossy: ["cached_values", "slicers"] });
|
||||
const banner = area.querySelector(".xlsx-warning");
|
||||
assert.ok(banner, "banner absent");
|
||||
const tags = [...banner.querySelectorAll(".xlsx-warning-tag")].map((n) => n.textContent);
|
||||
assert.equal(tags.length, 2);
|
||||
assert.ok(tags.includes(FR["xlsx.feature_cached_values"]), tags.join("|"));
|
||||
assert.ok(tags.includes(FR["xlsx.feature_slicers"]), tags.join("|"));
|
||||
assert.ok(banner.textContent.includes(FR["xlsx.lossy_title"]));
|
||||
assert.ok(banner.textContent.includes(FR["xlsx.lossy_hint"]));
|
||||
});
|
||||
|
||||
await test("formula toggle is present and starts unpressed", () => {
|
||||
const area = mount();
|
||||
const btn = area.querySelector("#xlsx-formula-btn");
|
||||
assert.ok(btn);
|
||||
assert.equal(btn.getAttribute("aria-pressed"), "false");
|
||||
assert.equal(btn.getAttribute("title"), FR["xlsx.formula_toggle_title"]);
|
||||
});
|
||||
|
||||
// ── Save payload ────────────────────────────────────────────────────────────
|
||||
|
||||
await test("save sends one PUT per dirty sheet with the cell map", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/save\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.cells, { A1: "250" });
|
||||
assert.equal(calls[0].body.sheet, "Feuille1");
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.equal(calls[0].body.allow_formula, false);
|
||||
});
|
||||
|
||||
await test("save button stays disabled when nothing is dirty", async () => {
|
||||
const area = mount();
|
||||
const btn = area.querySelector("#xlsx-save-btn");
|
||||
assert.equal(btn.disabled, true);
|
||||
btn.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
// ── A4 — formula toggle ─────────────────────────────────────────────────────
|
||||
|
||||
await test("f(x) toggle flips allow_formula on the next save", async () => {
|
||||
const area = mount();
|
||||
area.querySelector("#xlsx-formula-btn").click();
|
||||
assert.equal(area.querySelector("#xlsx-formula-btn").getAttribute("aria-pressed"), "true");
|
||||
editCell(area, "A1", "=B1*2");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(calls[0].body.allow_formula, true);
|
||||
});
|
||||
|
||||
// ── A1 — 409 confirmation & force retry ─────────────────────────────────────
|
||||
|
||||
await test("409 xlsx_lossy_content asks once then retries with force", async () => {
|
||||
const area = mount({ lossy: ["slicers"] });
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
// The prompt names the features the backend reported.
|
||||
assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]);
|
||||
// Save succeeded → cells are no longer dirty.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
});
|
||||
|
||||
await test("confirming once is enough for the following saves", async () => {
|
||||
const area = mount({ lossy: ["cached_values"] });
|
||||
editCell(area, "A1", "1");
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
editCell(area, "A1", "2");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1, "the user is not asked twice");
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[2].body.force, true);
|
||||
});
|
||||
|
||||
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
|
||||
const area = mount({ lossy: ["slicers"] });
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => lossyError;
|
||||
confirmAnswer = false;
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
assert.equal(calls.length, 1, "no retry after a refusal");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
});
|
||||
|
||||
await test("a non-409 failure is not retried", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => ({ ok: false, status: 500, body: { detail: "boom" } });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(confirmCalls, 0);
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
});
|
||||
|
||||
// ── A8 — truncation notice ───────────────────────────────────────────────────
|
||||
|
||||
await test("no notice when the sheet fits within the render caps", () => {
|
||||
const area = mount({
|
||||
sheet: { rows: 500, cols: 40, total_rows: 500, total_cols: 40, max_rows: 500, max_cols: 40, truncated: false },
|
||||
});
|
||||
assert.equal(area.querySelector(".xlsx-truncated"), null);
|
||||
});
|
||||
|
||||
await test("notice states the cap and the real size of a truncated sheet", () => {
|
||||
const area = mount({
|
||||
sheet: { rows: 500, cols: 12, total_rows: 1200, total_cols: 12, max_rows: 500, max_cols: 40, truncated: true },
|
||||
});
|
||||
const note = area.querySelector(".xlsx-truncated");
|
||||
assert.ok(note, "notice absent");
|
||||
const txt = note.textContent;
|
||||
assert.ok(txt.includes(FR["xlsx.truncated_title"]), txt);
|
||||
// {shown} is the CAP (500), not the post-trim row count: a sparse sheet
|
||||
// renders 1 row but the view still reaches 500 of them.
|
||||
const expected = FR["xlsx.truncated_rows"].replace("{shown}", "500").replace("{total}", "1200");
|
||||
assert.ok(txt.includes(expected), `${txt} !includes ${expected}`);
|
||||
// Nothing to say about the columns here (12 < 40).
|
||||
assert.ok(!txt.includes(FR["xlsx.truncated_cols"]), txt);
|
||||
});
|
||||
|
||||
await test("notice mentions both axes when rows AND columns overflow", () => {
|
||||
const area = mount({
|
||||
sheet: { rows: 1, cols: 40, total_rows: 501, total_cols: 45, max_rows: 500, max_cols: 40, truncated: true },
|
||||
});
|
||||
const txt = area.querySelector(".xlsx-truncated").textContent;
|
||||
assert.ok(
|
||||
txt.includes(FR["xlsx.truncated_cols"].replace("{shown}", "40").replace("{total}", "45")),
|
||||
txt
|
||||
);
|
||||
});
|
||||
|
||||
await test("a payload without the dimensions shows no notice", () => {
|
||||
// Backward compatibility: an older cached response must not produce "NaN".
|
||||
const area = mount({ sheet: { name: "Feuille1" } });
|
||||
assert.equal(area.querySelector(".xlsx-truncated"), null);
|
||||
assert.ok(!area.textContent.includes("NaN"));
|
||||
});
|
||||
|
||||
// ── A9bis — lazy loading of the truncated tail ──────────────────────────────
|
||||
|
||||
const windowHtml = (from, to) =>
|
||||
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||
"<tbody>" +
|
||||
Array.from({ length: to - from + 1 }, (_, i) => {
|
||||
const r = from + i;
|
||||
return `<tr><th class="xlsx-rownum">${r}</th><td data-cell="A${r}">Ligne ${r}</td></tr>`;
|
||||
}).join("") +
|
||||
"</tbody></table></div>";
|
||||
|
||||
await test("a truncated sheet gets a load-more footnote, a normal one does not", () => {
|
||||
const truncated = mountTruncated();
|
||||
assert.ok(truncated.querySelector(".xlsx-load-more"), "footnote absent");
|
||||
assert.ok(truncated.querySelector(".xlsx-load-more").textContent.includes(FR["xlsx.load_more"]));
|
||||
|
||||
const plain = mount({ sheet: { rows: 10, cols: 2, total_rows: 10, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false } });
|
||||
assert.equal(plain.querySelector(".xlsx-load-more"), null);
|
||||
});
|
||||
|
||||
await test("clicking the footnote fetches the next window with the right query", async () => {
|
||||
const area = mountTruncated();
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 2, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 3) },
|
||||
});
|
||||
area.querySelector(".xlsx-load-more").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/sheet\?path=data\.xlsx&sheet=Feuille1&offset=1&limit=500/);
|
||||
// Rows 2 and 3 landed in the table with their real coordinates.
|
||||
assert.ok(area.querySelector('td[data-cell="A2"]'));
|
||||
assert.ok(area.querySelector('td[data-cell="A3"]'));
|
||||
assert.ok(area.querySelector('th.xlsx-rownum') && area.textContent.includes("Ligne 3"));
|
||||
// Everything loaded → the footnote is hidden (kept in the DOM, class `done`).
|
||||
const foot = area.querySelector(".xlsx-load-more");
|
||||
assert.ok(foot, "the footnote element survives");
|
||||
assert.equal(foot.classList.contains("done"), true);
|
||||
assert.ok(!foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
|
||||
});
|
||||
|
||||
await test("appended rows are editable and tracked as dirty", async () => {
|
||||
const area = mountTruncated();
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
|
||||
});
|
||||
area.querySelector(".xlsx-load-more").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
editCell(area, "A2", "modifié");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(calls.length, 2, "the save went out");
|
||||
assert.equal(calls[1].url.includes("/xlsx/save"), true);
|
||||
assert.deepEqual(calls[1].body.cells, { A2: "modifié" });
|
||||
assert.equal(calls[1].body.sheet, "Feuille1");
|
||||
});
|
||||
|
||||
await test("a failed window fetch keeps the footnote and shows an error toast", async () => {
|
||||
const area = mountTruncated();
|
||||
apiQueue.push({ ok: false, status: 500, body: { detail: "boom" } });
|
||||
area.querySelector(".xlsx-load-more").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const foot = area.querySelector(".xlsx-load-more");
|
||||
assert.ok(foot, "footnote must survive a failed fetch");
|
||||
assert.ok(foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
|
||||
// `api()` itself toasts the failure (shared behaviour, asserted in E2E);
|
||||
// here we assert the local consequence: the footnote keeps its label.
|
||||
// Retrying works once the server answers again.
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
|
||||
});
|
||||
foot.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.ok(area.querySelector('td[data-cell="A2"]'));
|
||||
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
||||
});
|
||||
|
||||
// ── A7 — formula bar & keyboard navigation ──────────────────────────────────
|
||||
|
||||
await test("the formula bar starts empty and disabled", () => {
|
||||
const area = mount();
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "—");
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").disabled, true);
|
||||
});
|
||||
|
||||
await test("focusing a cell shows its name and content in the bar", () => {
|
||||
const area = mount();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
td.dispatchEvent(new w.Event("focus", { bubbles: false }));
|
||||
// JSDOM does not run the default focus behaviour on dispatchEvent, so go
|
||||
// through the real API:
|
||||
td.focus();
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").value, "100");
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").disabled, false);
|
||||
});
|
||||
|
||||
await test("typing in the bar edits the cell live and marks it dirty", () => {
|
||||
const area = mount();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
td.focus();
|
||||
const input = area.querySelector("#xlsx-formula-input");
|
||||
input.value = "depuis la barre";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
assert.equal(td.textContent, "depuis la barre");
|
||||
assert.equal(td.classList.contains("xlsx-dirty"), true);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
// The save payload carries the cell edit.
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
return new Promise((r) => setTimeout(r, 5)).then(() => {
|
||||
assert.deepEqual(calls[0].body.cells, { A1: "depuis la barre" });
|
||||
});
|
||||
});
|
||||
|
||||
await test("Tab and arrows move to the neighbour cell", () => {
|
||||
const area = mount();
|
||||
const a1 = area.querySelector('td[data-cell="A1"]');
|
||||
a1.focus();
|
||||
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B1");
|
||||
const b1 = area.querySelector('td[data-cell="B1"]');
|
||||
b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B2");
|
||||
const b2 = area.querySelector('td[data-cell="B2"]');
|
||||
b2.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A2");
|
||||
});
|
||||
|
||||
await test("Enter commits and Shift+Tab goes backwards", () => {
|
||||
const area = mount();
|
||||
const a1 = area.querySelector('td[data-cell="A1"]');
|
||||
a1.focus();
|
||||
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
const b1 = area.querySelector('td[data-cell="B1"]');
|
||||
b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
|
||||
});
|
||||
|
||||
await test("a saved edit from the bar resets the dirty flag and orig value", async () => {
|
||||
const area = mount();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
td.focus();
|
||||
const input = area.querySelector("#xlsx-formula-input");
|
||||
input.value = "200";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
assert.equal(td.dataset.orig, "200");
|
||||
});
|
||||
|
||||
// ── A13 — sort / filter / find / CSV export ─────────────────────────────────
|
||||
|
||||
const mountGrid = () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
const grid =
|
||||
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
||||
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">Banane</td><td data-cell="B1">3</td></tr>' +
|
||||
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">Abricot</td><td data-cell="B2">10</td></tr>' +
|
||||
'<tr><th class="xlsx-rownum">3</th><td data-cell="A3">Cerise</td><td data-cell="B3">2</td></tr>' +
|
||||
"</tbody></table></div>";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.xlsx", is_xlsx: true,
|
||||
xlsx_sheets: [{ name: "Fruits", html: grid, rows: 3, cols: 2, total_rows: 3, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
return area;
|
||||
};
|
||||
|
||||
await test("clicking a header sorts the rows numerically or lexically", () => {
|
||||
const area = mountGrid();
|
||||
// Sort by column B (numbers) ascending: 2, 3, 10.
|
||||
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||
let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
||||
assert.deepEqual(cells, ["2", "3", "10"]);
|
||||
// Second click: descending.
|
||||
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||
cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
||||
assert.deepEqual(cells, ["10", "3", "2"]);
|
||||
});
|
||||
|
||||
await test("a dirty cell travels with its row during a sort", () => {
|
||||
const area = mountGrid();
|
||||
editCell(area, "A3", "Cerise modifiée");
|
||||
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||
const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent);
|
||||
assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|"));
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
});
|
||||
|
||||
await test("the filter hides the rows that do not match", () => {
|
||||
const area = mountGrid();
|
||||
// The filter reuses the find input: type and the rows filter live.
|
||||
const input = area.querySelector("#xlsx-find-input");
|
||||
input.value = "abri";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
const visible = [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display !== "none");
|
||||
assert.equal(visible.length, 1);
|
||||
assert.ok(visible[0].textContent.includes("Abricot"));
|
||||
});
|
||||
|
||||
await test("find highlights matches and navigates with the counter", () => {
|
||||
const area = mountGrid();
|
||||
const input = area.querySelector("#xlsx-find-input");
|
||||
const count = area.querySelector("#xlsx-find-count");
|
||||
input.value = "cerise"; // lowercase: the default search ignores the case
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 1);
|
||||
assert.ok(count.textContent.includes("1/1"), count.textContent);
|
||||
// The hit is inside the matching cell.
|
||||
assert.ok(area.querySelector('td[data-cell="A3"] mark.xlsx-find-hit'));
|
||||
});
|
||||
|
||||
await test("CSV export downloads the visible sheet without the cached shadows", () => {
|
||||
const area = mountGrid();
|
||||
const clicks = [];
|
||||
const realCreate = document.createElement.bind(document);
|
||||
const anchor = realCreate("a");
|
||||
document.createElement = (tag) => {
|
||||
if (tag === "a") { clicks.push(1); return anchor; }
|
||||
return realCreate(tag);
|
||||
};
|
||||
let href = "";
|
||||
Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href });
|
||||
URL.createObjectURL = () => "blob:x";
|
||||
URL.revokeObjectURL = () => {};
|
||||
area.querySelector("#xlsx-csv-btn").click();
|
||||
document.createElement = realCreate;
|
||||
assert.equal(clicks.length, 1);
|
||||
assert.equal(anchor.download, "Fruits.csv");
|
||||
});
|
||||
|
||||
// ── A14 — structure menu ───────────────────────────────────────────────────
|
||||
|
||||
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
|
||||
const area = mount();
|
||||
// The prompt is resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
||||
}); // re-read
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
||||
addBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
globalThis.prompt = realPrompt;
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
// The viewer re-rendered from the server payload (a single sheet → no tabs).
|
||||
assert.ok(
|
||||
area.querySelector("#content-area, .xlsx-viewer") || area,
|
||||
"the viewer was rebuilt",
|
||||
);
|
||||
assert.ok(
|
||||
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
|
||||
"the re-render shows the fresh payload",
|
||||
);
|
||||
});
|
||||
|
||||
await test("sheet_delete confirms and is refused on the last sheet", async () => {
|
||||
const area = mount();
|
||||
const delBtn = () => {
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
const b = items.find((x) => x.textContent === FR["xlsx.sheet_delete"]);
|
||||
b.click();
|
||||
};
|
||||
// One sheet only → blocked before even confirming (no network call).
|
||||
delBtn();
|
||||
assert.equal(calls.length, 0, "nothing sent: last sheet");
|
||||
});
|
||||
|
||||
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
|
||||
const area = mount();
|
||||
// The prompt is resolved through the module scope: stub it globally.
|
||||
const realPrompt = globalThis.prompt;
|
||||
globalThis.prompt = () => "Feuille 2";
|
||||
apiQueue.push({
|
||||
ok: false, status: 409,
|
||||
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
|
||||
});
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // retry w/ force
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
|
||||
});
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
globalThis.prompt = realPrompt;
|
||||
assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
});
|
||||
|
||||
await test("two windows in a row walk the whole sheet", async () => {
|
||||
const area = mountTruncated({ total: 1200 });
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 500, total_rows: 1200, truncated: true, has_more: true, html: windowHtml(2, 501) },
|
||||
});
|
||||
area.querySelector(".xlsx-load-more").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.ok(area.querySelector('td[data-cell="A501"]'));
|
||||
// The footnote still shows, with the updated progress.
|
||||
let foot = area.querySelector(".xlsx-load-more");
|
||||
assert.ok(foot, "more rows remain");
|
||||
assert.ok(foot.textContent.includes("501"), foot.textContent);
|
||||
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 501, limit: 500, rows: 200, total_rows: 1200, truncated: true, has_more: false, html: windowHtml(502, 701) },
|
||||
});
|
||||
foot.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.ok(area.querySelector('td[data-cell="A701"]'));
|
||||
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
+14
-3
@@ -12,7 +12,8 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import _FALLBACK_MODELS, app
|
||||
from backend.main import app
|
||||
from backend.routers.config import _FALLBACK_MODELS # ROADMAP #85 T7 — déménagé depuis backend.main
|
||||
|
||||
#: Trimmed-down copy of what api.mistral.ai/v1/models really returns (BUG-044).
|
||||
MISTRAL_LIVE_PAYLOAD = {
|
||||
@@ -28,11 +29,12 @@ MISTRAL_LIVE_PAYLOAD = {
|
||||
@pytest.fixture
|
||||
def admin_client(tmp_path):
|
||||
"""Minimal admin client for the /api/config/ai-models endpoint."""
|
||||
from backend.auth.password import hash_password
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from backend.auth.password import hash_password
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
users = {
|
||||
@@ -69,8 +71,9 @@ def admin_client(tmp_path):
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
from backend.indexer import build_index, index
|
||||
import asyncio
|
||||
|
||||
from backend.indexer import build_index, index
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
loop = asyncio.new_event_loop()
|
||||
@@ -232,9 +235,11 @@ class TestListModelsEndpoint:
|
||||
# in backend.main (which does `from backend.ai import ... get_ai_key`).
|
||||
import backend.ai as aimod
|
||||
import backend.main as bmain
|
||||
import backend.routers.config as rconfig
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-xiaomi-key")
|
||||
if hasattr(bmain, "get_ai_key"):
|
||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-xiaomi-key")
|
||||
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: "fake-xiaomi-key")
|
||||
|
||||
captured = {}
|
||||
|
||||
@@ -316,9 +321,11 @@ class TestListModelsEndpoint:
|
||||
# Patch BOTH the source module AND the imported reference in backend.main
|
||||
import backend.ai as aimod
|
||||
import backend.main as bmain
|
||||
import backend.routers.config as rconfig
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-key")
|
||||
if hasattr(bmain, "get_ai_key"):
|
||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-key")
|
||||
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: "fake-key")
|
||||
|
||||
import urllib.request as global_urllib_mod
|
||||
captured_urls = []
|
||||
@@ -393,10 +400,12 @@ class TestDeclaredCapabilities:
|
||||
"""main.py binds get_ai_key at import: patch that binding too."""
|
||||
import backend.ai as aimod
|
||||
import backend.main as bmain
|
||||
import backend.routers.config as rconfig
|
||||
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-mistral-key")
|
||||
if hasattr(bmain, "get_ai_key"):
|
||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-mistral-key")
|
||||
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: "fake-mistral-key")
|
||||
|
||||
def test_declared_vision_reaches_both_endpoints(self, admin_client, monkeypatch):
|
||||
self._fake_key(monkeypatch)
|
||||
@@ -450,10 +459,12 @@ class TestDeclaredCapabilities:
|
||||
"""No API key: the curated list must still answer correctly (offline)."""
|
||||
import backend.ai as aimod
|
||||
import backend.main as bmain
|
||||
import backend.routers.config as rconfig
|
||||
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: None)
|
||||
if hasattr(bmain, "get_ai_key"):
|
||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: None)
|
||||
monkeypatch.setattr(rconfig, "get_ai_key", lambda name: None)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
|
||||
+19
-19
@@ -589,30 +589,30 @@ class TestHumanizeMtime:
|
||||
pass
|
||||
|
||||
def test_humanize_mtime_now(self):
|
||||
from backend.main import humanize_mtime
|
||||
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
|
||||
import time
|
||||
assert "instant" in humanize_mtime(time.time())
|
||||
|
||||
def test_humanize_mtime_minutes(self):
|
||||
from backend.main import humanize_mtime
|
||||
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
|
||||
import time
|
||||
result = humanize_mtime(time.time() - 120)
|
||||
assert "min" in result
|
||||
|
||||
def test_humanize_mtime_hours(self):
|
||||
from backend.main import humanize_mtime
|
||||
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
|
||||
import time
|
||||
result = humanize_mtime(time.time() - 7200)
|
||||
assert "h" in result or "jour" in result
|
||||
|
||||
def test_humanize_mtime_days(self):
|
||||
from backend.main import humanize_mtime
|
||||
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
|
||||
import time
|
||||
result = humanize_mtime(time.time() - 172800) # 2 days
|
||||
assert "j" in result
|
||||
|
||||
def test_humanize_mtime_old(self):
|
||||
from backend.main import humanize_mtime
|
||||
from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main
|
||||
import time
|
||||
result = humanize_mtime(time.time() - 86400 * 30)
|
||||
assert "202" in result or result # Should show formatted date
|
||||
@@ -624,44 +624,44 @@ class TestHumanizeMtime:
|
||||
|
||||
class TestHeadingSlugify:
|
||||
def test_slugify_simple(self):
|
||||
from backend.main import _heading_slugify
|
||||
from backend.render import _heading_slugify # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
assert _heading_slugify("Hello World") == "hello-world"
|
||||
|
||||
def test_slugify_accented(self):
|
||||
from backend.main import _heading_slugify
|
||||
from backend.render import _heading_slugify # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _heading_slugify("Café Crème")
|
||||
assert "cafe" in result or "caf" in result
|
||||
|
||||
def test_slugify_strips_symbols(self):
|
||||
from backend.main import _heading_slugify
|
||||
from backend.render import _heading_slugify # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _heading_slugify("Hello, World! Test?")
|
||||
assert result.startswith("hello")
|
||||
|
||||
|
||||
class TestRenderMarkdown:
|
||||
def test_render_basic(self):
|
||||
from backend.main import _render_markdown
|
||||
from backend.render import _render_markdown # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _render_markdown("# Hello\n\nThis is a test.", "TestVault")
|
||||
assert "<h1" in result
|
||||
assert "Hello" in result
|
||||
|
||||
def test_render_with_code(self):
|
||||
from backend.main import _render_markdown
|
||||
from backend.render import _render_markdown # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _render_markdown("```python\nprint('hello')\n```", "TestVault")
|
||||
assert "code" in result or "highlight" in result
|
||||
|
||||
def test_render_with_heading_ids(self):
|
||||
from backend.main import _render_markdown
|
||||
from backend.render import _render_markdown # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _render_markdown("# Title\n## Subtitle", "TestVault")
|
||||
assert "id=" in result
|
||||
|
||||
def test_render_wikilink(self):
|
||||
from backend.main import _render_markdown
|
||||
from backend.render import _render_markdown # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _render_markdown("Link [[nonexistent.md]] here", "TestVault")
|
||||
assert "wikilink" in result
|
||||
|
||||
def test_render_image_wikilink(self):
|
||||
from backend.main import _render_markdown
|
||||
from backend.render import _render_markdown # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _render_markdown("![[image.png]]", "TestVault")
|
||||
assert "img" in result or "image" in result or "wikilink" in result
|
||||
|
||||
@@ -709,31 +709,31 @@ class TestCheckVaultWritable:
|
||||
|
||||
class TestConvertWikilinks:
|
||||
def test_convert_wikilink(self):
|
||||
from backend.main import _convert_wikilinks
|
||||
from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
# Missing wikilinks render as span.wikilink-missing
|
||||
result = _convert_wikilinks("See [[Introduction à Python]] for details", "TestVault")
|
||||
assert "Introduction" in result
|
||||
assert "wikilink" in result
|
||||
|
||||
def test_convert_wikilink_with_alias(self):
|
||||
from backend.main import _convert_wikilinks
|
||||
from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _convert_wikilinks("See [[file.md|a different name]] here", "TestVault")
|
||||
assert "a different name" in result
|
||||
|
||||
def test_convert_wikilink_image(self):
|
||||
from backend.main import _convert_wikilinks
|
||||
from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _convert_wikilinks("See ![[image.png]] here", "TestVault")
|
||||
assert "image" in result or "img" in result
|
||||
|
||||
def test_convert_wikilink_anchor(self):
|
||||
from backend.main import _convert_wikilinks
|
||||
from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _convert_wikilinks("[[#Section importante|voir section]]", "TestVault")
|
||||
assert 'href="#section-importante"' in result
|
||||
assert "wikilink-anchor" in result
|
||||
assert "voir section" in result
|
||||
|
||||
def test_convert_wikilink_anchor_without_alias(self):
|
||||
from backend.main import _convert_wikilinks
|
||||
from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _convert_wikilinks("[[#Claude Code]]", "TestVault")
|
||||
assert 'href="#claude-code"' in result
|
||||
assert "Claude Code" in result
|
||||
@@ -741,7 +741,7 @@ class TestConvertWikilinks:
|
||||
|
||||
class TestHeadingSlugifyHtmlStripping:
|
||||
def test_slugify_strips_html_tags(self):
|
||||
from backend.main import _heading_slugify
|
||||
from backend.render import _heading_slugify # ROADMAP #85 T9 — déménagé depuis backend.main
|
||||
result = _heading_slugify('## 1. Agents installés localement <a href="#table-des-matieres">↩</a>')
|
||||
assert result == "1-agents-installes-localement"
|
||||
|
||||
|
||||
+80
-1
@@ -402,4 +402,83 @@ class TestAvatar:
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["user"]["avatar"] == TINY_PNG
|
||||
assert resp.json()["user"]["avatar"] == TINY_PNG
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Secure cookies (#87 T8)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestSecureCookies:
|
||||
"""`Secure` auto par défaut : https → flag, http → pas de flag
|
||||
(les navigateurs jettent les cookies Secure sur http)."""
|
||||
|
||||
@staticmethod
|
||||
def _req(scheme="http", forwarded_proto=None):
|
||||
from types import SimpleNamespace
|
||||
headers = {}
|
||||
if forwarded_proto is not None:
|
||||
headers["x-forwarded-proto"] = forwarded_proto
|
||||
return SimpleNamespace(
|
||||
url=SimpleNamespace(scheme=scheme),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def test_forced_true(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "true")
|
||||
assert is_secure_cookies(self._req("http")) is True
|
||||
|
||||
def test_forced_false(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "false")
|
||||
assert is_secure_cookies(self._req("https")) is False
|
||||
|
||||
def test_auto_http(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
assert is_secure_cookies(self._req("http")) is False
|
||||
|
||||
def test_auto_https(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
assert is_secure_cookies(self._req("https")) is True
|
||||
|
||||
def test_auto_forwarded_proto_trusted(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true")
|
||||
assert is_secure_cookies(self._req("http", "https")) is True
|
||||
|
||||
def test_auto_forwarded_proto_untrusted(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
monkeypatch.delenv("OBSIGATE_TRUST_PROXY", raising=False)
|
||||
assert is_secure_cookies(self._req("http", "https")) is False
|
||||
|
||||
def test_login_http_sets_cookie_without_secure(self, auth_client, monkeypatch):
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
resp = auth_client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
set_cookie = resp.headers.get("set-cookie", "")
|
||||
assert "access_token" in set_cookie
|
||||
assert "secure" not in set_cookie.lower()
|
||||
|
||||
def test_login_https_sets_secure_cookie(self, auth_client, monkeypatch):
|
||||
"""Même app servie en https → flag Secure présent."""
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
https_client = TestClient(app, base_url="https://testserver",
|
||||
raise_server_exceptions=False)
|
||||
try:
|
||||
resp = https_client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "secure" in resp.headers.get("set-cookie", "").lower()
|
||||
finally:
|
||||
if hasattr(https_client, "close"):
|
||||
https_client.close()
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083).
|
||||
|
||||
Sans dépendance (pas de PyYAML) : analyse ligne à ligne de
|
||||
`.gitea/workflows/ci.yml`, suffisante pour les conventions de ce fichier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
CI_YML = Path(__file__).resolve().parent.parent / ".gitea" / "workflows" / "ci.yml"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _run_bodies() -> list[tuple[int, str]]:
|
||||
"""Toutes les lignes shell de chaque bloc `run:` → [(n° ligne, code)]."""
|
||||
lines = CI_YML.read_text(encoding="utf-8").splitlines()
|
||||
bodies: list[tuple[int, str]] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
m = re.match(r"^(\s*)run:(?:\s*\|\s*)?$", lines[i])
|
||||
inline = re.match(r"^(\s*)run:\s+(\S.*)$", lines[i])
|
||||
if m:
|
||||
base = len(m.group(1))
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
cur = lines[i]
|
||||
if not cur.strip():
|
||||
i += 1
|
||||
continue
|
||||
if len(cur) - len(cur.lstrip()) <= base:
|
||||
break
|
||||
bodies.append((i + 1, cur.strip()))
|
||||
i += 1
|
||||
elif inline:
|
||||
bodies.append((i + 1, inline.group(2).strip()))
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return bodies
|
||||
|
||||
|
||||
class TestRunnerProofScripts:
|
||||
def test_no_hash_inside_run_bodies(self):
|
||||
"""BUG-083 : aucun `#` dans le code shell des `run:`.
|
||||
|
||||
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 '"'` (job
|
||||
`security` rouge). Les lignes-commentaires shell (`# ...`) restent
|
||||
autorisées : leur troncature est sémantiquement neutre.
|
||||
"""
|
||||
offenders = [
|
||||
f"L{n}: {code}"
|
||||
for n, code in _run_bodies()
|
||||
if not code.startswith("#") and "#" in code
|
||||
]
|
||||
assert not offenders, (
|
||||
"BUG-083 : `#` interdit dans le code des `run:` "
|
||||
f"(tronqué par le runner) :\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
class TestSemgrepStep:
|
||||
def test_semgrep_local_rules_enforced(self):
|
||||
"""#87 T7 : semgrep bloquant sur règles locales (aucun registre)."""
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
assert "semgrep --config semgrep-rules/ backend/" in text, (
|
||||
"#87 T7 : étape semgrep locale attendue dans le job security"
|
||||
)
|
||||
rules = REPO_ROOT / "semgrep-rules" / "obsigate-python.yaml"
|
||||
assert rules.exists(), "ruleset semgrep manquant"
|
||||
|
||||
|
||||
class TestFrontendStepsHaveTheirDeps:
|
||||
@staticmethod
|
||||
def _root_step_files() -> list[str]:
|
||||
"""Fichiers `node tests/frontend/<f>` de l'étape racine (sans jsdom)."""
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
root_part = text.split("Frontend JSDOM tests", 1)[0]
|
||||
root_steps = root_part.split("Frontend unit tests", 1)[1]
|
||||
return re.findall(r"node tests/frontend/(\S+\.mjs)", root_steps)
|
||||
|
||||
@staticmethod
|
||||
def _has_static_jsdom_import(rel: str) -> bool:
|
||||
path = REPO_ROOT / "tests" / "frontend" / rel
|
||||
return any(
|
||||
re.match(r"^\s*import\b.*\bfrom\s+['\"]jsdom['\"]", line)
|
||||
or re.match(r"""\brequire\(\s*['"]jsdom['"]\s*\)""", line)
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
|
||||
def test_root_step_files_need_no_jsdom(self):
|
||||
"""BUG-082 : l'étape racine tourne sans `tests/frontend/node_modules`
|
||||
(installé seulement par l'étape JSDOM) : aucun de ses fichiers ne
|
||||
doit importer `jsdom` statiquement — sinon `ERR_MODULE_NOT_FOUND`
|
||||
et `lint` rouge (cas `upload.test.mjs`, puis `config-ai-keys.test.mjs`).
|
||||
"""
|
||||
offenders = [f for f in self._root_step_files() if self._has_static_jsdom_import(f)]
|
||||
assert not offenders, (
|
||||
"BUG-082 : ces fichiers importent `jsdom` mais tournent dans "
|
||||
"l'étape racine (sans node_modules) — les déplacer dans l'étape "
|
||||
f"JSDOM :\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
def test_jsdom_dependent_tests_run_in_jsdom_step(self):
|
||||
"""BUG-082 : les suites à import statique `jsdom` tournent bien dans
|
||||
l'étape JSDOM (les deux branches)."""
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
jsdom_part = text.split("Frontend JSDOM tests", 1)[1]
|
||||
for suite in ("node upload.test.mjs", "node config-ai-keys.test.mjs"):
|
||||
assert jsdom_part.count(suite) >= 2, (
|
||||
f"BUG-082 : `{suite}` attendu dans les deux branches de "
|
||||
"l'étape JSDOM"
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests — nonces CSP (ROADMAP #87 T5b).
|
||||
|
||||
- `inject_csp_nonce` ne touche que les scripts inline exécutables
|
||||
(`<script>`, `type="module"` / `type="importmap"` sans `src`), jamais les
|
||||
blocs de données (`type="text/plain"`) ni les scripts externes.
|
||||
- Chaque page HTML servie avec des scripts inline les porte tous avec un
|
||||
nonce après injection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NONCE = "TESTNONCE1234567890"
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (ROOT / "frontend" / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_inject_only_bare_executable_scripts():
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
html = (
|
||||
"<script>var a = 1;</script>"
|
||||
'<script type="module">import x from "y";</script>'
|
||||
'<script type="importmap">{"imports": {}}</script>'
|
||||
'<script type="module" src="/static/js/app.js"></script>'
|
||||
'<script src="https://cdnjs.cloudflare.com/x.js"></script>'
|
||||
'<script id="raw-content" type="text/plain">hello</script>'
|
||||
'<script nonce="OLD">var b = 2;</script>'
|
||||
)
|
||||
out = inject_csp_nonce(html, NONCE)
|
||||
assert out.count(f'nonce="{NONCE}"') == 3
|
||||
assert '<script src="https://cdnjs.cloudflare.com/x.js">' in out
|
||||
assert '<script id="raw-content" type="text/plain">' in out
|
||||
assert '<script nonce="OLD">' in out
|
||||
|
||||
|
||||
def test_new_nonce_unique_per_call():
|
||||
from backend.csp import new_nonce
|
||||
|
||||
assert new_nonce() != new_nonce()
|
||||
|
||||
|
||||
def test_all_pages_fully_nonced():
|
||||
"""Aucun script inline exécutable sans nonce après injection (sauf src=)."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
for name in ("index.html", "popout.html", "admin.html", "editor-poc.html", "excalidraw-editor.html"):
|
||||
out = inject_csp_nonce(_read(name), NONCE)
|
||||
bare = re.findall(r"<script>", out)
|
||||
assert not bare, f"{name} : scripts sans nonce restants"
|
||||
inline_mods = [m for m in re.findall(r'<script type="(?:module|importmap)">', out)]
|
||||
assert not inline_mods, f"{name} : modules/importmap sans nonce restants"
|
||||
|
||||
|
||||
def _nonce_of(csp: str) -> str | None:
|
||||
m = re.search(r"'nonce-([^']+)'", csp or "")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def test_nonce_header_fresh_per_response(client):
|
||||
"""Chaque réponse porte un nonce frais dans `script-src`."""
|
||||
r1 = client.get("/")
|
||||
r2 = client.get("/")
|
||||
n1, n2 = _nonce_of(r1.headers.get("content-security-policy")), _nonce_of(
|
||||
r2.headers.get("content-security-policy")
|
||||
)
|
||||
assert n1 and n2 and n1 != n2
|
||||
|
||||
|
||||
def test_nonce_matches_injected_html(client):
|
||||
"""Le nonce de l'en-tête est celui injecté dans le HTML (`/`, excalidraw)."""
|
||||
for path in ("/", "/excalidraw-editor.html"):
|
||||
resp = client.get(path)
|
||||
assert resp.status_code == 200, path
|
||||
nonce = _nonce_of(resp.headers.get("content-security-policy"))
|
||||
assert nonce, path
|
||||
assert f'nonce="{nonce}"' in resp.text, path
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Garde-fous anti-blocage du harnais E2E local (BUG-080).
|
||||
|
||||
Contexte : un run `npm run test:e2e:ps` est resté pendu toute la nuit —
|
||||
serveurs orphelins sur le port 2029, `npx` sans `--yes` (prompt interactif
|
||||
qui attend indéfiniment), installation des navigateurs systématique et suite
|
||||
Playwright (~130 tests, workers: 1) sans aucun timeout global.
|
||||
|
||||
Ces tests statiques vérifient que chaque couche du harnais possède son
|
||||
garde-fou, afin qu'un run E2E échoue vite au lieu de bloquer indéfiniment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (REPO_ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestE2ELocalPs:
|
||||
SCRIPT = "scripts/run-e2e-local.ps1"
|
||||
|
||||
def test_playwright_via_node_no_npx(self):
|
||||
"""Playwright est lancé via `node` direct, jamais via `npx`.
|
||||
|
||||
`Start-Process` ne peut pas exécuter `npx` (ni le `.ps1` ni le
|
||||
`.cmd` ne sont des applications Win32 directes : "%1 is not a valid
|
||||
Win32 application"), et `npx` sans `--yes` peut pendre sur un prompt
|
||||
interactif. Seules les mentions en commentaires/logs sont tolérées.
|
||||
"""
|
||||
content = _read(self.SCRIPT)
|
||||
bare = [
|
||||
line.strip()
|
||||
for line in content.splitlines()
|
||||
if re.match(r"^\s*(?:&\s*)?npx\s", line)
|
||||
]
|
||||
assert not bare, f"invocations npx nues : {bare}"
|
||||
assert "node_modules/@playwright/test/cli.js" in content, (
|
||||
"CLI Playwright locale attendue (via node)"
|
||||
)
|
||||
# `$Args` est une variable automatique PowerShell : un paramètre de
|
||||
# ce nom serait écrasé (helper lancé sans arguments → exit 0 muet).
|
||||
# (commentaires `#` exclus : la mise en garde elle-même le cite).
|
||||
code_lines = [
|
||||
line for line in content.splitlines()
|
||||
if not line.strip().startswith("#")
|
||||
]
|
||||
assert not re.search(r"\$Args\b", "\n".join(code_lines)), (
|
||||
"BUG-080 : paramètre `$Args` interdit (shadowing par $args automatique)"
|
||||
)
|
||||
|
||||
def test_browser_install_skippable(self):
|
||||
"""Install navigateurs sautée si chromium déjà présent (sauf forçage)."""
|
||||
content = _read(self.SCRIPT)
|
||||
assert "Test-ChromiumInstalled" in content
|
||||
assert "E2E_INSTALL_BROWSERS" in content
|
||||
|
||||
def test_test_step_has_timeout(self):
|
||||
"""L'étape `playwright test` est bornée (E2E_TIMEOUT_SEC, défaut 1800).
|
||||
|
||||
Le défaut dépasse le globalTimeout Playwright (25 min en local) pour
|
||||
que ce soit Playwright qui abandonne proprement (avec rapport) en premier.
|
||||
"""
|
||||
content = _read(self.SCRIPT)
|
||||
assert "E2E_TIMEOUT_SEC" in content
|
||||
assert "Wait-Process -Timeout" in content
|
||||
assert re.search(r"E2E_TIMEOUT_SEC.*else\s*\{\s*1800\s*\}", content), (
|
||||
"défaut E2E_TIMEOUT_SEC=1800 attendu"
|
||||
)
|
||||
|
||||
|
||||
class TestE2ELocalSh:
|
||||
SCRIPT = "scripts/run-e2e-local.sh"
|
||||
|
||||
def test_npx_never_prompts(self):
|
||||
content = _read(self.SCRIPT)
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#") or stripped.startswith("echo") or "npx" not in stripped:
|
||||
continue
|
||||
if "playwright" in stripped:
|
||||
assert "--yes" in stripped, f"appel npx sans --yes : {stripped}"
|
||||
|
||||
def test_test_step_has_timeout(self):
|
||||
content = _read(self.SCRIPT)
|
||||
assert "E2E_TIMEOUT_SEC" in content
|
||||
assert "run_with_timeout" in content
|
||||
|
||||
|
||||
class TestE2EServerPs:
|
||||
SCRIPT = "scripts/e2e-server.ps1"
|
||||
|
||||
def test_pidfile_refreshed_with_port_owner(self):
|
||||
"""Le pidfile est resynchronisé sur le vrai PID d'écoute après READY."""
|
||||
content = _read(self.SCRIPT)
|
||||
assert "Get-PortOwner" in content
|
||||
ready_pos = content.find("[OK] READY")
|
||||
assert ready_pos != -1
|
||||
assert "Set-Content -LiteralPath $PidFile" in content[ready_pos - 600:ready_pos]
|
||||
|
||||
def test_stop_kills_process_tree(self):
|
||||
"""`stop` tue aussi les enfants du PID enregistré (pas d'orphelins)."""
|
||||
content = _read(self.SCRIPT)
|
||||
assert "ParentProcessId=$srvPid" in content
|
||||
|
||||
|
||||
class TestPlaywrightConfig:
|
||||
CONFIG = "playwright.config.ts"
|
||||
|
||||
def test_global_timeout_set(self):
|
||||
"""Timeout global : la suite abandonne au lieu de pendre toute la nuit."""
|
||||
content = _read(self.CONFIG)
|
||||
assert "globalTimeout" in content
|
||||
assert "E2E_GLOBAL_TIMEOUT_MS" in content
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests de durcissement — concurrence users.json + fuzzing regex (ROADMAP #87 T2).
|
||||
|
||||
- `users.json` : les read-modify-write sont sérialisés par `_users_lock`
|
||||
(BUG-029). Ces tests martèlent create/update/record_login_failure depuis
|
||||
plusieurs threads et exigent zéro mise à jour perdue + un JSON valide.
|
||||
- Regex (BUG-025) : la politique `regex_safety` (longueur, quantificateurs
|
||||
imbriqués, contenu tronqué) doit rejeter vite les motifs catastrophiques
|
||||
et borner le temps des motifs acceptés sur gros contenu.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
N_THREADS = 6
|
||||
|
||||
|
||||
def test_users_concurrent_create_and_update(tmp_path, monkeypatch):
|
||||
"""Créations + mises à jour concurrentes : aucun utilisateur perdu."""
|
||||
from backend.auth import user_store
|
||||
|
||||
monkeypatch.setattr(user_store, "USERS_FILE", tmp_path / "users.json")
|
||||
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def worker(n: int):
|
||||
try:
|
||||
for i in range(3):
|
||||
name = f"user-{n}-{i}"
|
||||
user_store.create_user(name, "Motdepasse1!", display_name=name)
|
||||
user_store.update_user(name, {"display_name": f"{name}-renamed"})
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(N_THREADS)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
users = user_store._read()["users"]
|
||||
assert len(users) == N_THREADS * 3
|
||||
assert all(u["display_name"].endswith("-renamed") for u in users.values())
|
||||
|
||||
|
||||
def test_users_concurrent_login_failures_no_lost_count(tmp_path, monkeypatch):
|
||||
"""`record_login_failure` concurrents : compteur exact (pas de lost update)."""
|
||||
from backend.auth import user_store
|
||||
|
||||
monkeypatch.setattr(user_store, "USERS_FILE", tmp_path / "users.json")
|
||||
user_store.create_user("victim", "Motdepasse1!")
|
||||
|
||||
def worker():
|
||||
for _ in range(10):
|
||||
try:
|
||||
user_store.record_login_failure("victim")
|
||||
except Exception: # verrouillage éventuel : ne doit pas lever
|
||||
pass
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(N_THREADS)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
user = user_store.get_user("victim")
|
||||
assert user is not None
|
||||
# Le compte peut se verrouiller en cours de route ; l'important est que
|
||||
# le fichier reste un JSON valide et l'utilisateur présent.
|
||||
assert user["username"] == "victim"
|
||||
|
||||
|
||||
def test_users_file_stays_valid_json_under_load(tmp_path, monkeypatch):
|
||||
"""Le fichier reste lisible à tout moment pendant les écritures."""
|
||||
import json
|
||||
|
||||
from backend.auth import user_store
|
||||
|
||||
target = tmp_path / "users.json"
|
||||
monkeypatch.setattr(user_store, "USERS_FILE", target)
|
||||
user_store.create_user("base", "Motdepasse1!")
|
||||
|
||||
stop = threading.Event()
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def writer(n: int):
|
||||
i = 0
|
||||
while not stop.is_set():
|
||||
try:
|
||||
user_store.update_user("base", {"display_name": f"w{n}-{i}"})
|
||||
i += 1
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
|
||||
def reader():
|
||||
# Lecture brute sans verrou (comme le `_read` de production) : une
|
||||
# déchirure transitoire est possible pendant le remplacement du
|
||||
# fichier — l'invariant est qu'une relecture immédiate réussit
|
||||
# (jamais de corruption permanente).
|
||||
while not stop.is_set():
|
||||
try:
|
||||
raw = target.read_text(encoding="utf-8")
|
||||
json.loads(raw)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
time.sleep(0.01)
|
||||
json.loads(target.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(n,)) for n in range(4)]
|
||||
threads.append(threading.Thread(target=reader))
|
||||
for t in threads:
|
||||
t.start()
|
||||
time.sleep(2.0)
|
||||
stop.set()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fuzzing regex — budget temps (BUG-025)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Motifs classiquement catastrophiques : doivent être REJETÉS vite.
|
||||
CATASTROPHIC = [
|
||||
"^(a+)+$",
|
||||
"(a+)+$",
|
||||
"(.*)*$",
|
||||
"(a|aa)+$",
|
||||
"(a+){2,}$",
|
||||
r"(\w+)+$",
|
||||
r"(a*)*b",
|
||||
r"(x+x+)+y",
|
||||
]
|
||||
|
||||
# Motifs acceptés (légitimes) : doivent tourner vite sur gros contenu.
|
||||
ACCEPTED = [
|
||||
r"hello",
|
||||
r"h.llo",
|
||||
r"\b\w+@\w+\.\w+\b",
|
||||
r"[A-ZÉÈÊ][a-zéèêàâîôûç]+",
|
||||
r"(ab|cd)+e",
|
||||
r"\d{4}-\d{2}-\d{2}",
|
||||
r"foo|bar|baz",
|
||||
]
|
||||
|
||||
|
||||
def test_catastrophic_patterns_rejected_fast():
|
||||
"""Les motifs à backtracking catastrophique sont refusés en < 1 s."""
|
||||
from backend.services.regex_safety import validate_regex
|
||||
|
||||
start = time.perf_counter()
|
||||
for pattern in CATASTROPHIC:
|
||||
try:
|
||||
validate_regex(pattern)
|
||||
except ValueError:
|
||||
pass
|
||||
assert time.perf_counter() - start < 1.0
|
||||
|
||||
|
||||
def test_accepted_patterns_bounded_on_large_content():
|
||||
"""Motifs acceptés sur 200 Ko : chacun < 5 s (budget large anti-flaky)."""
|
||||
from backend.services.regex_safety import MAX_REGEX_CONTENT, truncate_for_regex, validate_regex
|
||||
|
||||
assert MAX_REGEX_CONTENT == 200_000
|
||||
content = truncate_for_regex("abc héllo world [email protected] 2024-01-02 " * 5000)
|
||||
assert len(content) <= MAX_REGEX_CONTENT
|
||||
for pattern in ACCEPTED:
|
||||
validate_regex(pattern) # ne doit pas lever
|
||||
start = time.perf_counter()
|
||||
re.search(pattern, content)
|
||||
assert time.perf_counter() - start < 5.0, f"motif lent : {pattern!r}"
|
||||
|
||||
|
||||
def test_validate_regex_policy():
|
||||
"""Politique : vide/trop long/invalide → ValueError."""
|
||||
from backend.services.regex_safety import MAX_PATTERN_LENGTH, validate_regex
|
||||
|
||||
for bad in ("", "x" * (MAX_PATTERN_LENGTH + 1), "(unclosed"):
|
||||
try:
|
||||
validate_regex(bad)
|
||||
except ValueError:
|
||||
pass
|
||||
else: # pragma: no cover - doit lever
|
||||
raise AssertionError(f"motif accepté à tort : {bad!r}")
|
||||
assert validate_regex("simple") == "simple"
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Parité i18n FR/EN des locales du frontend (#87 T9).
|
||||
|
||||
`frontend/locales/fr.json` et `en.json` doivent exposer exactement les mêmes
|
||||
clés (comparaison profonde) : toute clé manquante fait afficher la clé brute
|
||||
dans l'UI au lieu du libellé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
LOCALES = Path(__file__).resolve().parent.parent / "frontend" / "locales"
|
||||
|
||||
|
||||
def _flat(d: dict, prefix: str = "") -> set[str]:
|
||||
keys = set()
|
||||
for k, v in d.items():
|
||||
name = f"{prefix}.{k}" if prefix else str(k)
|
||||
if isinstance(v, dict):
|
||||
keys |= _flat(v, name)
|
||||
else:
|
||||
keys.add(name)
|
||||
return keys
|
||||
|
||||
|
||||
def _load(lang: str) -> set[str]:
|
||||
return _flat(json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
class TestI18nParity:
|
||||
def test_fr_en_same_keys(self):
|
||||
fr, en = _load("fr"), _load("en")
|
||||
assert not (fr - en), f"clés sans traduction EN : {sorted(fr - en)[:10]}"
|
||||
assert not (en - fr), f"clés sans traduction FR : {sorted(en - fr)[:10]}"
|
||||
@@ -367,3 +367,64 @@ class TestMfaApiEndpoints:
|
||||
data = login_resp.json()
|
||||
assert "access_token" in data
|
||||
assert data.get("mfa_required") is None
|
||||
|
||||
|
||||
# ── BUG-081 : /api/auth/mfa/status avec auth désactivée ──────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def mfa_client_noauth():
|
||||
"""TestClient avec auth DÉSACTIVÉE (OBSIGATE_AUTH_ENABLED=false)."""
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
data_dir = tmp / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
orig_cwd = os.getcwd()
|
||||
test_vault_path = os.path.abspath("test-vault")
|
||||
os.chdir(str(tmp))
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = test_vault_path
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.main import app
|
||||
from backend.indexer import build_index, index
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
from backend.search import init_inverted_index
|
||||
init_inverted_index()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
|
||||
if hasattr(client, 'close'):
|
||||
client.close()
|
||||
loop.run_until_complete(asyncio.sleep(0))
|
||||
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
class TestMfaStatusAuthDisabled:
|
||||
"""BUG-081 : `GET /api/auth/mfa/status` ne doit pas répondre 500 quand
|
||||
l'auth est désactivée (pseudo-user `anonymous` sans entrée en store)."""
|
||||
|
||||
def test_mfa_status_anonymous_returns_disabled(self, mfa_client_noauth):
|
||||
resp = mfa_client_noauth.get("/api/auth/mfa/status")
|
||||
assert resp.status_code == 200, f"BUG-081: {resp.status_code} {resp.text[:200]}"
|
||||
body = resp.json()
|
||||
assert body["mfa_enabled"] is False
|
||||
assert body["totp_enabled"] is False
|
||||
assert body["webauthn_credentials"] == 0
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Tests — rate-limit SQLite optionnel (ROADMAP #85 T10b).
|
||||
|
||||
Le store mémoire reste le défaut (comportement inchangé) ; si
|
||||
``OBSIGATE_RATELIMIT_DB`` pointe vers un fichier SQLite, les compteurs y
|
||||
sont persistés (partagés entre workers/processus, conservés au redémarrage)
|
||||
avec une sémantique identique (fenêtre glissante, budgets IP + compte,
|
||||
reset au succès).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def _use_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OBSIGATE_RATELIMIT_DB", str(tmp_path / "ratelimit.db"))
|
||||
|
||||
|
||||
def test_memory_default_unchanged(monkeypatch):
|
||||
"""Sans la variable d'env : le store mémoire historique est utilisé."""
|
||||
from backend import ratelimit
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_RATELIMIT_DB", raising=False)
|
||||
assert ratelimit._db_path() is None
|
||||
ip = "10.9.9.1"
|
||||
ratelimit._ip_attempts.pop(ip, None)
|
||||
assert not ratelimit.is_rate_limited(ip)
|
||||
ratelimit.record_failure(ip)
|
||||
assert not ratelimit.is_rate_limited(ip)
|
||||
ratelimit.record_success(ip)
|
||||
assert not ratelimit.is_rate_limited(ip)
|
||||
|
||||
|
||||
def test_sqlite_failures_and_limit(monkeypatch, tmp_path):
|
||||
"""Budget IP : N échecs → limité ; succès → reset (SQLite)."""
|
||||
from backend import ratelimit
|
||||
|
||||
_use_db(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(ratelimit, "MAX_ATTEMPTS", 3)
|
||||
ip = "10.8.8.1"
|
||||
assert not ratelimit.is_rate_limited(ip)
|
||||
ratelimit.record_failure(ip)
|
||||
ratelimit.record_failure(ip)
|
||||
assert not ratelimit.is_rate_limited(ip)
|
||||
failures, remaining = ratelimit.record_failure(ip)
|
||||
assert (failures, remaining) == (3, 0)
|
||||
assert ratelimit.is_rate_limited(ip)
|
||||
ratelimit.record_success(ip)
|
||||
assert not ratelimit.is_rate_limited(ip)
|
||||
|
||||
|
||||
def test_sqlite_account_budget_case_insensitive(monkeypatch, tmp_path):
|
||||
"""Budget par compte : insensible à la casse, indépendant des IP."""
|
||||
from backend import ratelimit
|
||||
|
||||
_use_db(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(ratelimit, "ACCOUNT_MAX_ATTEMPTS", 2)
|
||||
assert not ratelimit.is_account_rate_limited("Alice")
|
||||
ratelimit.record_account_failure("alice")
|
||||
assert not ratelimit.is_account_rate_limited("ALICE")
|
||||
ratelimit.record_account_failure("ALICE")
|
||||
assert ratelimit.is_account_rate_limited("alice")
|
||||
# Le budget IP n'est pas affecté par le budget compte.
|
||||
assert not ratelimit.is_rate_limited("1.2.3.4")
|
||||
ratelimit.record_account_success("alice")
|
||||
assert not ratelimit.is_account_rate_limited("alice")
|
||||
|
||||
|
||||
def test_sqlite_window_expiry(monkeypatch, tmp_path):
|
||||
"""Les tentatives hors fenêtre ne comptent plus (SQLite)."""
|
||||
from backend import ratelimit
|
||||
|
||||
_use_db(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(ratelimit, "MAX_ATTEMPTS", 2)
|
||||
monkeypatch.setattr(ratelimit, "WINDOW_SECONDS", 1)
|
||||
ip = "10.7.7.1"
|
||||
ratelimit.record_failure(ip)
|
||||
ratelimit.record_failure(ip)
|
||||
assert ratelimit.is_rate_limited(ip)
|
||||
time.sleep(1.1)
|
||||
assert not ratelimit.is_rate_limited(ip)
|
||||
|
||||
|
||||
def test_sqlite_persists_across_restart(monkeypatch, tmp_path):
|
||||
"""Les compteurs survivent au redémarrage (même fichier)."""
|
||||
import os
|
||||
|
||||
from backend import ratelimit
|
||||
|
||||
db = tmp_path / "ratelimit.db"
|
||||
monkeypatch.setenv("OBSIGATE_RATELIMIT_DB", str(db))
|
||||
monkeypatch.setattr(ratelimit, "MAX_ATTEMPTS", 5)
|
||||
for _ in range(3):
|
||||
ratelimit.record_failure("10.6.6.6")
|
||||
assert os.path.exists(db)
|
||||
# "Redémarrage" : le module relit le même fichier (connexions courtes).
|
||||
assert ratelimit.get_status("10.6.6.6")["failures"] == 3
|
||||
with sqlite3.connect(str(db)) as conn:
|
||||
(rows,) = conn.execute("SELECT COUNT(*) FROM attempts").fetchone()
|
||||
assert rows == 3
|
||||
|
||||
|
||||
def test_sqlite_get_status_shapes(monkeypatch, tmp_path):
|
||||
"""`get_status` garde les mêmes formes qu'en mémoire."""
|
||||
from backend import ratelimit
|
||||
|
||||
_use_db(monkeypatch, tmp_path)
|
||||
ratelimit.record_failure("10.5.5.5")
|
||||
ratelimit.record_account_failure("bob")
|
||||
per_ip = ratelimit.get_status("10.5.5.5")
|
||||
assert per_ip == {
|
||||
"ip": "10.5.5.5",
|
||||
"failures": 1,
|
||||
"max": ratelimit.MAX_ATTEMPTS,
|
||||
"limited": False,
|
||||
"window_seconds": ratelimit.WINDOW_SECONDS,
|
||||
}
|
||||
glob = ratelimit.get_status()
|
||||
assert glob["tracked_ips"] == 1
|
||||
assert glob["tracked_accounts"] == 1
|
||||
assert glob["limited_ips"] == 0
|
||||
assert glob["max_attempts"] == ratelimit.MAX_ATTEMPTS
|
||||
|
||||
|
||||
def test_sqlite_concurrent_writes(monkeypatch, tmp_path):
|
||||
"""Écritures concurrentes : aucun échec compté perdu (SQLite/WAL)."""
|
||||
from backend import ratelimit
|
||||
|
||||
_use_db(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(ratelimit, "MAX_ATTEMPTS", 10_000)
|
||||
|
||||
def worker(n: int):
|
||||
for _ in range(25):
|
||||
ratelimit.record_failure("10.4.4.4")
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert ratelimit.get_status("10.4.4.4")["failures"] == 200
|
||||
@@ -115,6 +115,25 @@ class TestInvertedIndex:
|
||||
inv.remove_document("V", "p.md")
|
||||
assert inv.doc_count == 0 # Skipped
|
||||
|
||||
def test_is_ready_tracks_initial_build(self, client):
|
||||
"""is_ready() is the only freshness signal: no generation counter,
|
||||
no cooldown, no lazy rebuild (plan.md step 6)."""
|
||||
inv = InvertedIndex()
|
||||
assert inv.is_ready() is False
|
||||
inv.rebuild()
|
||||
assert inv.is_ready() is True
|
||||
# The old staleness API is gone for good.
|
||||
assert not hasattr(inv, "is_stale")
|
||||
|
||||
def test_is_ready_survives_incremental_updates(self, client):
|
||||
"""Incremental add/remove must not flip readiness back (which would
|
||||
silently push search() onto the O(N) full-scan fallback)."""
|
||||
self.inv.rebuild()
|
||||
assert self.inv.is_ready() is True
|
||||
self.inv.add_document("V", "p.md", {"path": "p.md", "title": "T", "tags": [], "content": "x"})
|
||||
self.inv.remove_document("V", "p.md")
|
||||
assert self.inv.is_ready() is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Search / Advanced Search integration tests
|
||||
@@ -192,3 +211,40 @@ class TestSearchFunctions:
|
||||
def test_suggest_tags_no_match(self, client):
|
||||
suggestions = suggest_tags("xyznonexistent", vault_filter="all")
|
||||
assert len(suggestions) == 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Vault removal — inverted index must not keep ghost documents
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVaultRemovalPurgesInvertedIndex:
|
||||
"""`remove_vault_from_index()` must notify the inverted-index hook.
|
||||
|
||||
Regression: it only cleaned the indexer's own structures, so every document
|
||||
of the removed vault survived in the inverted index (postings, doc_info,
|
||||
doc_vault, vault_docs) and kept matching searches for a vault that no
|
||||
longer exists — a leak that only a manual reindex used to clear.
|
||||
"""
|
||||
|
||||
def test_removing_a_vault_purges_its_documents(self, client):
|
||||
import asyncio
|
||||
|
||||
import backend.indexer as ix
|
||||
import backend.search as bs
|
||||
|
||||
ix.set_index_change_hook(bs._on_index_change_hook)
|
||||
inv = bs._inverted_index
|
||||
inv.rebuild()
|
||||
|
||||
vault_keys = [k for k in inv.doc_info if k.startswith("TestVault::")]
|
||||
assert vault_keys, "vault non indexe, test sans valeur"
|
||||
before = inv.doc_count
|
||||
|
||||
asyncio.run(ix.remove_vault_from_index("TestVault"))
|
||||
|
||||
ghosts = [k for k in inv.doc_info if k.startswith("TestVault::")]
|
||||
assert not ghosts, f"documents fantomes dans l'index inverse : {ghosts[:5]}"
|
||||
assert inv.doc_count == before - len(vault_keys)
|
||||
assert "TestVault" not in inv.vault_docs
|
||||
# The index stays usable for the remaining vaults.
|
||||
assert inv.is_ready() is True
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user