Compare commits

...
14 Commits
Author SHA1 Message Date
bruno ce1944d437 fix: coller du menu contextuel tableur muet + planchers sécurité CI (BUG-108)
CI / lint (push) Successful in 3m6s
CI / security (push) Successful in 2m18s
CI / test (push) Successful in 4m55s
CI / build (push) Successful in 3m1s
CI / e2e (push) Successful in 19m27s
BUG-108 — closeContextMenu() retirait le nœud du menu AVANT de déférencer
_menu : le retrait émet focusout en synchrone (le menu tient le focus), qui
rappelle closeContextMenu() ; le second remove() sur un nœud démonté lève
NotFoundError et avorte le handler de l'action « Coller » du viewer — le
coller par menu contextuel était silencieusement mort (régression de la
fermeture au focus #179, reproduite par l'E2E « coller une plage », CI
#850→#854). Déférencement avant retrait.

Sécurité CI (job security, run #853) :
- plancher multidict>=6.9.1 (CVE-2026-104874 ; 6.7.x préinstallée dans la
  toolcache de l'image du runner, même piège « already satisfied » que pypdf
  BUG-093) + entrée dans le garde-fou TestDependencySecurityFloors ;
- exception documentée CVE-2026-85394 (python-jose, AUCUN correctif upstream) :
  non atteignable ici, jwt.decode passe toujours algorithms=["HS256"] avec un
  secret symétrique serveur — jamais de clé publique comme clé HMAC.

Vérifié : E2E -g « coller une plage » 1/1 ; xlsx-menus 11/11,
xlsx-formula 14/14, ai 100/100, ai-quick-actions 13/13 ; pytest 1586
passés ; ruff/mypy 0 erreur ; ISSUES_TODOLIST + CHANGELOG à jour.
2026-10-07 16:49:31 -04:00
bruno 39bad990c0 test: ai-quick-actions suit #187 - plus de flag agent sur les actions
CI / lint (push) Successful in 3m1s
CI / security (push) Failing after 2m14s
CI / test (push) Successful in 4m58s
CI / build (push) Successful in 3m17s
CI / e2e (push) Failing after 16m56s
Le test assertait encore `agent: true` sur les quick actions frontmatter,
depuis la suppression du toggle mode agent (#187, v2.53.2). Job lint CI
(run 853) en échec. Commit rattaché à la livraison 2.53.2 déjà publiée :
pas de nouvel incrément (SKIP_VERSION_BUMP).
2026-10-07 08:29:57 -04:00
bruno 431d6e9338 docs: i18n et suivi #187 - assistant agent toujours actif
CI / lint (push) Failing after 2m51s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 2m14s
2026-10-07 08:18:45 -04:00
bruno d453969708 docs: i18n et suivi #187 - assistant agent toujours actif 2026-10-07 08:17:59 -04:00
bruno 778fa65b4c fix: assistant IA toujours en mode agent, retrait du bouton toggle #187
- Bouton « mode agent » du panneau supprimé : l'assistant est toujours
  agent (toute requête texte part sur /api/ai/bookslm/agent, les images
  restent sur /chat multimodal). Les mutations gardent la confirmation
  two-step. Deep Research et les quick actions `agent: true` basculaient
  déjà le mode en silence : le toggle ne protégeait plus rien.
- /agent résout le provider comme /chat (req.provider brut transmis à
  l'adapter pouvait désigner un fournisseur indisponible et retomber
  silencieusement sur un autre que l'étiquette SSE affichée).
- Schémas des outils mis en cache par (scope, taille du registre) avec
  copies fraîches par appelant (~27 ms de pydantic économisées par
  requête agent/MCP).
- Aide in-app réécrite, i18n FR/EN (retrait de ai.agent_mode_*),
  tests frontend adaptés (ai.test.mjs 100/100) + non-régression pytest
  (provider résolu, cache sûr).
2026-10-07 08:16:56 -04:00
bruno 69c3817579 docs: diagramme de flux de l'agent AI (boucle run_agent + pipeline call_tool + 50 outils)
CI / lint (push) Successful in 3m1s
CI / security (push) Successful in 2m12s
CI / test (push) Successful in 4m56s
CI / build (push) Successful in 2m5s
CI / e2e (push) Failing after 17m3s
2026-10-05 12:59:51 -04:00
bruno b89af917b1 fix: ordre du flag --no-cache dans build.ps1 (procedure Test refonctionne)
CI / lint (push) Successful in 2m54s
CI / security (push) Successful in 2m10s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 1m59s
CI / e2e (push) Failing after 17m13s
2026-10-04 19:58:38 -04:00
bruno d79202e698 feat: menus tableur a icones, fermeture au focus et polish mobile #179
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 2m11s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 2m0s
CI / e2e (push) Failing after 16m50s
2026-10-04 19:27:50 -04:00
bruno 4ce677902a feat: agent IA phase 4 — doublons #166, notifications externes #168 (Discord/Telegram/SMTP/webhook), taches planifiees #170
CI / lint (push) Successful in 2m53s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m52s
CI / build (push) Successful in 2m5s
CI / e2e (push) Successful in 16m14s
2026-10-04 13:11:25 -04:00
bruno fe9f7b49f7 feat: premier lancement ObsiGate + section Configuration harmonisée (#160)
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m44s
CI / build (push) Successful in 1m54s
CI / e2e (push) Successful in 16m25s
- Premier lancement : %USERPROFILE%\ObsiGate créé et monté comme vault de
  démarrage « ObsiGate » (remplace voute_obsidian), vault_path dessus,
  racine home nommée d'après son dernier segment (fin du « bruno » codé en
  dur), champs de fenêtre préservés.
- Document « Prise en main.md » embarqué dans le binaire
  (include_str!) et écrit dans ce répertoire si absent — jamais écrasé.
- Section « 🖥️ Vaults & dossiers (Desktop) » refondue : markup à classes
  (zéro style inline), bloc CSS desktop-roots-* sur variables (motif
  webauthn-key-item), boutons .config-btn-sm primaire/secondaire,
  cibles tactiles 44px en mobile.
- Tests : test_default_first_run_config, test_welcome_doc_embedded
  (cargo test 28 passed), suites frontend vertes, E2E 123/123.
2026-10-03 10:02:16 -04:00
bruno 649f965c52 fix: drag & drop de fichiers — fenêtre créée sans le handler Tauri (BUG-107)
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 4m40s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m25s
2026-10-02 23:42:01 -04:00
bruno d6fa8c7bb1 fix: injecte le nonce CSP dans les pages /docs et /redoc générées par FastAPI (BUG-106)
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m40s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 15m57s
2026-10-02 22:04:25 -04:00
bruno d2734dc8ce feat: gestion desktop des vaults & dossiers — retrait par menu contextuel et ajout en configuration #159
CI / lint (push) Successful in 3m2s
CI / security (push) Successful in 2m2s
CI / test (push) Successful in 4m42s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 15m54s
2026-10-02 21:04:17 -04:00
bruno bab272bb5c fix: desktop — commandes Tauri bloquées par l'ACL et crash heap à l'ouverture (BUG-104, BUG-105)
CI / lint (push) Successful in 2m45s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 4m44s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m16s
2026-10-02 20:17:20 -04:00
73 changed files with 4762 additions and 265 deletions
+11 -1
View File
@@ -39,6 +39,7 @@ jobs:
run: |
node tests/frontend/unit.test.mjs
node tests/frontend/navfacets.test.mjs
node tests/frontend/desktop-roots.test.mjs
node tests/frontend/image-viewer.test.mjs
node tests/frontend/pdf-viewer.test.mjs
node tests/frontend/forge-completion.test.mjs
@@ -180,13 +181,22 @@ jobs:
# pypdf>=6.16.1 (BUG-093).
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
# corrigés par le plancher urllib3>=2.8.0.
# CVE-2026-104874 (multidict 6.7.x) corrigé par le plancher
# multidict>=6.9.1 (transitive aiohttp/yarl ; 6.7.x est dans la
# toolcache de l'image du runner — même piège « already satisfied »).
# CVE-2026-85394 (python-jose ≤3.5.0, forgery HS256 par clé publique
# DER passée comme secret HMAC) : AUCUN correctif upstream (projet
# sans release depuis 2025). Non atteignable dans ObsiGate :
# jwt.decode passe toujours algorithms=["HS256"] et un secret
# symétrique serveur (backend/auth/jwt_handler.py,
# backend/mcp/confirmations.py) — jamais une clé publique comme clé.
# Ces planchers doivent rester *au-dessus* des versions préinstallées
# dans la toolcache de l'image du runner : en dessous, pip répond
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
# TestDependencySecurityFloors verrouille ces planchers.
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
run: pip-audit --ignore-vuln PYSEC-2026-1325
run: pip-audit --ignore-vuln PYSEC-2026-1325 --ignore-vuln CVE-2026-85394
# ── Docker build ──────────────────────────────────────────────────
build:
+156 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.49.2**.
> [Unreleased](#unreleased). La dernière version livrée est **2.53.3**.
---
@@ -14,6 +14,161 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.53.3] — 2026-10-07
### Corrigé
- **BUG-108 - Tableur : « Coller » du menu contextuel muet**
- `closeContextMenu()` retirait le nœud du menu **avant** de déférencer
`_menu` : retirer le menu portant le focus émet un `focusout` synchrone
qui rappelle la fonction, et le second `remove()` sur un nœud déjà
démonté lève `NotFoundError` — le handler de l'action « Coller » du
viewer est avorté avant son exécution (régression de la fermeture au
focus #179 ; reproduite par l'E2E « coller une plage », CI #850-#854).
Déférencement avant retrait ; l'E2E passe après correctif.
---
## [2.53.2] — 2026-10-07
### Modifié
- **#187 - Assistant IA : mode agent toujours actif**
- Le bouton « mode agent » du panneau a été **supprimé** : l'assistant utilise
en permanence ses outils (lire, lister, chercher, modifier) ; la sécurité
est inchangée — chaque modification demande une confirmation avec aperçu.
Les images jointes passent toujours par l'endpoint multimodal classique.
- Le provider réel de l'agent est désormais résolu comme pour le chat simple
(l'étiquette « fournisseur · modèle » affichée est exacte), et les schémas
d'outils sont mis en cache (≈ 27 ms économisées par requête).
---
## [2.53.1] — 2026-10-04
---
## [2.53.0] — 2026-10-04
### Ajouté
- **#179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile**
- Une icône Lucide par entrée (grille, Structure, Mise en forme, Export) et
sur les boutons Enregistrer / « + » ; suppressions marquées danger,
`aria-haspopup`/`aria-expanded` sur les boutons à menu.
- Tout menu se ferme à la perte de focus (clic extérieur, `focusout`,
`Échap`, défilement, redimensionnement) via `trackDismissable()` ;
navigation clavier (`↑`/`↓`/`Home`/`End`) dans le menu grille.
- Mobile : entrées ≥ 44 px (`pointer: coarse`), menus bornés au viewport,
ruban sans débordement horizontal.
### Corrigé
- **Build Docker** : `build.ps1` plaçait `--no-cache` avant `build`
(`unknown flag`) — flag déplacé après `docker compose -f … build`, la
procédure `.\build.ps1 -Test` refonctionne.
---
## [2.52.0] — 2026-10-04
### Ajouté
- **#166 — Assistant IA : détection & fusion de doublons**
- Score déterministe (Jaccard 70 % + titre 30 %, frontmatter exclu),
scan borné (500 fichiers, `truncated` exposé).
- Outils `find_duplicates` (READ) / `merge_duplicate_notes` (DANGEROUS,
backup préalable) ; API `GET /api/duplicates` et
`POST /api/duplicates/merge` (`confirm: true` obligatoire, stratégies
`append` / `prefer_target` / `prefer_source`).
- **#168 — Assistant IA : notifications externes Discord / Telegram / SMTP / webhook**
- Canaux avec déclencheurs (`manual`, `schedule_failure`,
`schedule_success`, `duplicate_found`), secrets hors config
(`notify_secrets.json` 0600 ou `OBSIGATE_NOTIFY_SECRET_<ID>`), SSRF-safe.
- CRUD admin `/api/notify/channels`, test `/api/notify/test`,
outil `notify_external` (WRITE).
- **#170 — Assistant IA : tâches planifiées type cron**
- Actions `create_file` / `append_to_file` / `notify` (services existants),
planifications `interval_hours` / `daily_time` / `once_at`, tick 60 s
(`OBSIGATE_SCHEDULER=0` pour désactiver), échec enregistré + notifié.
- API `/api/scheduler/tasks` (CRUD + `POST …/run`), outils
`create/list/delete/run_scheduled_task*`.
---
## [2.51.0] — 2026-10-03
### Ajouté
- **#160 — Premier lancement professionnel + section Configuration harmonisée**
- **Premier lancement** : `%USERPROFILE%\ObsiGate` est créé et monté comme
vault de démarrage (nom « ObsiGate », remplace `voute_obsidian`), le
contexte initial s'ouvre dessus, le dossier home reste une racine nommée
d'après son chemin (fin du « bruno » codé en dur), et le document
**`Prise en main.md`** (contenu embarqué) y est écrit une seule fois —
jamais écrasé.
- **Section « 🖥️ Vaults & dossiers (Desktop) »** refondue : lignes au motif
des cartes de la configuration (icône, nom, chemin tronqué), boutons
Retirer/Ajouter en `.config-btn-sm` secondaire/ primaire, zéro style
inline, variables CSS uniquement, cibles tactiles 44px en mobile.
---
## [2.50.2] — 2026-10-02
---
## [2.50.1] — 2026-10-02
### Corrigé
- **BUG-106 — page blanche sur `/docs`** : la CSP `script-src` sans
`unsafe-inline` (#87 T5c) refusait le script inline d'init de Swagger UI,
généré par FastAPI et jamais noncé — `SecurityHeadersMiddleware` injecte
désormais le nonce dans le HTML de `/docs` et `/redoc` (helper existant
`inject_csp_nonce`, corps ré-encodé gzip), avec 3 tests de non-régression.
- **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry
remplaçait le gestionnaire de drag & drop du WebView2 par le sien (aucun
événement natif écouté) — les dépôts depuis l'Explorateur n'atteignaient
jamais la page. La fenêtre est désormais créée en code (`create: false`) avec
`disable_drag_drop_handler()` : le HTML5 drag & drop (#89) fonctionne comme
sur le web, avec un garde-fou de test.
---
## [2.50.0] — 2026-10-02
### Ajouté
- **#159 — Gestion desktop des vaults & dossiers** : retrait d'un vault ou dossier
racine par menu contextuel (« Retirer de l'application », desktop uniquement,
confirmation — déregistration locale, aucun fichier supprimé) et nouvelle section
Configuration « 🖥️ Vaults & dossiers (Desktop) » avec listes, retrait par ligne et
ajout de vault / dossier racine (nouvelle commande `pick_folder`, sélecteur natif
sans effet de bord). La jump list des raccourcis suit les ajouts et retraits de
vaults.
---
## [2.49.4] — 2026-10-02
### Corrigé
- **BUG-104 — commandes desktop bloquées par l'ACL Tauri** : la fenêtre desktop sert
sa page depuis `http://127.0.0.1` (origine *remote*) et aucune commande applicative
n'était déclarée — toutes étaient rejetées « not allowed by ACL », erreurs avalées
par `desktop.js:invoke()`. Nouveau manifeste `desktop/permissions/commands.toml`
(`allow-app-commands`, 19 commandes) référencé par la capability, avec un test
garde-fou (`test_frontend_invokes_are_acl_allowed`). Le bouton « Choisir mon
dossier » du wizard et toute la gestion vaults/dossiers desktop fonctionnent.
- **BUG-105 — crash du desktop à l'ouverture (heap corruption `c0000374`)** : le
setter de titre du menu contextuel des raccourcis (#77) confiait un `PROPVARIANT
VT_LPWSTR` sur un buffer heap Rust au property store du shell, qui le libérait avec
l'allocateur Windows — l'application se fermait 2 à 15 s après le lancement. Fonction
supprimée (libellé assuré par `SetDescription`).
---
## [2.49.2] — 2026-10-02
### Corrigé
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.49.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.53.3-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.49.2).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.3).
---
*Projet : ObsiGate | Version : 2.49.2 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.53.3 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.49.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.53.3-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.49.2).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.3).
---
*Project: ObsiGate | Version: 2.49.2 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.53.3 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.49.2
2.53.3
+17 -12
View File
@@ -536,18 +536,6 @@ async def api_bookslm_agent(
# run no longer pauses on every subsequent mutating call.
ctx.confirmed = True
async def _llm(msgs, tool_schemas):
return await chat_completion(
msgs,
tools=tool_schemas,
provider=req.provider,
model=req.model,
temperature=0.3,
# Tool-call arguments can carry a whole file body (e.g. a generated
# table): leave more room than the plain-chat default.
max_tokens=8192,
)
async def generate_sse():
import asyncio
@@ -561,6 +549,23 @@ async def api_bookslm_agent(
yield f"event: error\ndata: {error_data}\n\n"
return
# #187: resolve the provider like /chat does — the agent must use
# the same engine the SSE "provider" tag reports (the raw
# req.provider could name an unavailable provider and silently
# fall back to another one via _get_provider_config).
async def _llm(msgs, tool_schemas):
return await chat_completion(
msgs,
tools=tool_schemas,
provider=cfg_name,
model=req.model,
temperature=0.3,
# Tool-call arguments can carry a whole file body (e.g. a
# generated table): leave more room than the plain-chat
# default.
max_tokens=8192,
)
# Stream tool events live: each executed step is pushed on the
# queue by the loop callback and emitted as soon as it happens,
# so the UI can grow its « N steps » block while thinking.
+49 -1
View File
@@ -167,7 +167,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all HTTP responses."""
async def dispatch(self, request, call_next):
from backend.csp import new_nonce
from backend.csp import inject_csp_nonce, new_nonce
# Nonce CSP frais par réponse (#87 T5b) : injecté dans script-src et
# dans le balisage HTML par les routes (backend.csp.inject_csp_nonce).
@@ -200,6 +200,30 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"form-action 'self'; "
"frame-ancestors 'self';"
)
# BUG-106 : /docs et /redoc sont générés par FastAPI, hors de nos
# routes qui appellent inject_csp_nonce — leur balisage inline
# restait sans nonce et était bloqué par script-src (page blanche).
# On injecte ici avec le même helper, seul le HTML est retouché.
if request.url.path.startswith(("/docs", "/redoc")) and "text/html" in (
response.headers.get("Content-Type") or ""
):
import gzip
# La compression (GZipMiddleware) est plus proche de la route :
# le corps arrive déjà gunzippé, on recomprime à l'identique.
raw = b"".join([chunk async for chunk in response.body_iterator])
is_gz = "gzip" in (response.headers.get("Content-Encoding") or "")
if is_gz:
raw = gzip.decompress(raw)
patched = inject_csp_nonce(raw.decode("utf-8", "replace"), nonce).encode("utf-8")
if is_gz:
patched = gzip.compress(patched)
response.headers["Content-Length"] = str(len(patched))
async def _docs_body():
yield patched
response.body_iterator = _docs_body()
# Static assets are NOT content-hashed, so they must revalidate:
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
# a stale build for a year (the service worker cache compounded it).
@@ -344,6 +368,24 @@ async def lifespan(app: FastAPI):
asyncio.create_task(_background_startup())
async def _scheduler_loop():
"""Background tick for scheduled tasks (#170) — every 60 s, best effort."""
from backend.scheduler import tick
await asyncio.sleep(60)
while True:
try:
outcomes = await asyncio.to_thread(tick)
if outcomes:
logger.info(f"Scheduler tick: {len(outcomes)} task(s) executed")
except Exception as e:
logger.warning(f"Scheduler tick failed: {e}")
await asyncio.sleep(60)
if os.environ.get("OBSIGATE_SCHEDULER", "1") != "0":
asyncio.create_task(_scheduler_loop())
logger.info("Scheduler loop started (#170, 60 s tick).")
logger.info("ObsiGate ready (listening for requests while indexing).")
yield
@@ -466,12 +508,15 @@ from backend.routers.backups import router as backups_router
from backend.routers.config import _load_config
from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router
from backend.routers.duplicates import router as duplicates_router
from backend.routers.files_media import router as files_media_router
from backend.routers.files_read import router as files_read_router
from backend.routers.files_write import router as files_write_router
from backend.routers.health import router as health_router
from backend.routers.history import router as history_router
from backend.routers.notify import router as notify_router
from backend.routers.realtime import router as realtime_router
from backend.routers.scheduler import router as scheduler_router
from backend.routers.search import router as search_router
from backend.routers.sharing import router as sharing_router
from backend.routers.vaults import router as vaults_router
@@ -495,6 +540,9 @@ app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks
app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing
app.include_router(vaults_router) # ROADMAP #85 T8 — Vaults
app.include_router(duplicates_router) # ROADMAP #166 — Doublons
app.include_router(notify_router) # ROADMAP #168 — Notifications externes
app.include_router(scheduler_router) # ROADMAP #170 — Tâches planifiées
# Admin Dashboard endpoints (system stats, audit logs, backups, stream)
try:
+350
View File
@@ -0,0 +1,350 @@
"""External notifications — Discord, Telegram, SMTP, generic webhook (#168).
Configuration is persisted in ``data/notify_channels.json``; secrets live in
``data/notify_secrets.json`` (0600) or in ``OBSIGATE_NOTIFY_SECRET_<ID>``
environment variables — never in the public config file (same pattern as
``backend/webhooks.py``, BUG-026).
Supported channel types:
* ``discord`` — Discord webhook URL (``POST {"content": ...}``).
* ``telegram`` — Bot API (``POST https://api.telegram.org/bot<token>/sendMessage``).
* ``smtp`` — Email via stdlib ``smtplib`` (STARTTLS, auth login).
* ``webhook`` — generic JSON ``POST`` (SSRF-safe, same policy as #9).
Each channel declares ``triggers`` chosen among :data:`VALID_TRIGGERS`.
The scheduler (#170) broadcasts on ``schedule_failure``; file-event fan-out
stays on the historical ``backend/webhooks.py`` path.
"""
from __future__ import annotations
import json
import logging
import os
import smtplib
import threading
import uuid
from datetime import datetime, timezone
from email.message import EmailMessage
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.notify")
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
CHANNELS_FILE = DATA_DIR / "notify_channels.json"
SECRETS_FILE = DATA_DIR / "notify_secrets.json"
CHANNEL_TYPES = ("discord", "telegram", "smtp", "webhook")
VALID_TRIGGERS = ("manual", "schedule_failure", "schedule_success", "duplicate_found")
_lock = threading.RLock()
# ── Store helpers ──────────────────────────────────────────────────────────
def _read_channels() -> list[dict[str, Any]]:
if not CHANNELS_FILE.exists():
return []
try:
data = json.loads(CHANNELS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except (json.JSONDecodeError, OSError):
return []
def _write_channels(channels: list[dict[str, Any]]) -> None:
CHANNELS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = CHANNELS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(channels, indent=2, default=str), encoding="utf-8")
tmp.replace(CHANNELS_FILE)
def _read_secrets() -> dict[str, str]:
if not SECRETS_FILE.exists():
return {}
try:
data = json.loads(SECRETS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
def _write_secrets(secrets: dict[str, str]) -> None:
SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = SECRETS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(secrets, indent=2), encoding="utf-8")
tmp.replace(SECRETS_FILE)
try:
SECRETS_FILE.chmod(0o600)
except OSError:
pass # Windows: pas de permissions Unix
def _secret_key(channel_id: str) -> str:
return "OBSIGATE_NOTIFY_SECRET_" + channel_id.replace("-", "_").upper()
def _get_secret(channel_id: str) -> str | None:
"""Resolve a channel secret: env > dedicated store > legacy inline config."""
env_val = os.environ.get(_secret_key(channel_id))
if env_val:
return env_val
stored = _read_secrets().get(channel_id)
if stored:
return stored
for ch in _read_channels():
if ch.get("id") == channel_id:
cfg = ch.get("config", {})
for key in ("webhook_url", "bot_token", "password"):
if cfg.get(key):
return str(cfg[key])
return None
def _public_view(channel: dict[str, Any]) -> dict[str, Any]:
clean = {k: v for k, v in channel.items() if k != "config"}
cfg = dict(channel.get("config", {}))
for secret_field in ("webhook_url", "bot_token", "password"):
if cfg.get(secret_field):
cfg[secret_field] = "***"
clean["config"] = cfg
clean["has_secret"] = bool(_get_secret(channel["id"]))
return clean
# ── CRUD ───────────────────────────────────────────────────────────────────
def _validate_config(channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
"""Validate (sans secret) and normalize a channel config. Raises ValueError."""
config = dict(config or {})
if channel_type == "discord":
url = str(config.get("webhook_url") or config.get("url") or "").strip()
if not url.startswith(("https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/")):
# Laisse passer les URLs de test locales quand le mode privé est ouvert.
from backend.webhooks import validate_webhook_url
validate_webhook_url(url)
if "discord" not in url and not os.environ.get("OBSIGATE_WEBHOOK_ALLOW_PRIVATE"):
raise ValueError("URL Discord invalide (webhook discord.com attendu)")
config["webhook_url"] = url
elif channel_type == "telegram":
if not str(config.get("chat_id") or "").strip():
raise ValueError("chat_id Telegram requis")
config["chat_id"] = str(config["chat_id"]).strip()
if config.get("bot_token"):
config["bot_token"] = str(config["bot_token"]).strip()
elif channel_type == "smtp":
for field in ("host", "from_addr", "to_addr"):
if not str(config.get(field) or "").strip():
raise ValueError(f"Champ SMTP requis : {field}")
config["port"] = int(config.get("port") or 587)
config["use_tls"] = bool(config.get("use_tls", True))
config["username"] = str(config.get("username") or "").strip()
elif channel_type == "webhook":
from backend.webhooks import validate_webhook_url
url = str(config.get("url") or "").strip()
validate_webhook_url(url)
config["url"] = url
else:
raise ValueError(f"Type de canal inconnu : {channel_type}")
triggers = [t for t in (config.get("triggers") or ["manual"]) if t in VALID_TRIGGERS]
config["triggers"] = triggers or ["manual"]
return config
def list_channels() -> list[dict[str, Any]]:
"""Return public views of all notification channels."""
return [_public_view(ch) for ch in _read_channels()]
def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
"""Create a notification channel. Secrets are split into the secret store."""
if channel_type not in CHANNEL_TYPES:
raise ValueError(f"Type de canal inconnu : {channel_type}")
with _lock:
channels = _read_channels()
channel_id = str(uuid.uuid4())
normalized = _validate_config(channel_type, config)
secrets = _read_secrets()
for field in ("webhook_url", "bot_token", "password"):
if normalized.get(field) and len(str(normalized[field])) > 8:
secrets[channel_id] = str(normalized[field])
normalized[field] = "***" # placeholder : le secret vit dans le store dédié
_write_secrets(secrets)
channel = {
"id": channel_id,
"name": (name or channel_type).strip() or channel_type,
"type": channel_type,
"enabled": True,
"config": normalized,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_sent_at": None,
"last_error": None,
}
channels.append(channel)
_write_channels(channels)
logger.info(f"Created notify channel '{name}' ({channel_type})")
return _public_view(channel)
def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
"""Update a channel (name/enabled/config). Returns None when unknown."""
with _lock:
channels = _read_channels()
for channel in channels:
if channel.get("id") != channel_id:
continue
if updates.get("name"):
channel["name"] = str(updates["name"])
if "enabled" in updates:
channel["enabled"] = bool(updates["enabled"])
if "config" in updates and isinstance(updates["config"], dict):
merged = {**channel.get("config", {}), **updates["config"]}
normalized = _validate_config(channel["type"], merged)
secrets = _read_secrets()
for field in ("webhook_url", "bot_token", "password"):
if updates["config"].get(field):
secrets[channel_id] = str(updates["config"][field])
normalized[field] = "***"
_write_secrets(secrets)
channel["config"] = normalized
_write_channels(channels)
return _public_view(channel)
return None
def delete_channel(channel_id: str) -> bool:
"""Delete a channel and its secret. Returns False when unknown."""
with _lock:
channels = _read_channels()
remaining = [c for c in channels if c.get("id") != channel_id]
if len(remaining) == len(channels):
return False
_write_channels(remaining)
secrets = _read_secrets()
if secrets.pop(channel_id, None) is not None:
_write_secrets(secrets)
return True
# ── Dispatch ───────────────────────────────────────────────────────────────
def _send_discord(webhook_url: str, title: str, message: str) -> None:
import httpx
content = f"**{title}**\n{message}"[:2000]
resp = httpx.post(webhook_url, json={"content": content}, timeout=10.0)
resp.raise_for_status()
def _send_telegram(bot_token: str, chat_id: str, title: str, message: str) -> None:
import httpx
from backend.webhooks import is_safe_target
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
if not is_safe_target(url):
raise RuntimeError("Cible Telegram bloquée par la politique SSRF")
text = f"*{title}*\n{message}"[:4000]
resp = httpx.post(
url,
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
timeout=10.0,
)
resp.raise_for_status()
def _send_smtp(config: dict[str, Any], password: str | None, title: str, message: str) -> None:
msg = EmailMessage()
msg["Subject"] = f"[ObsiGate] {title}"
msg["From"] = config["from_addr"]
msg["To"] = config["to_addr"]
msg.set_content(message)
with smtplib.SMTP(str(config["host"]), int(config.get("port", 587)), timeout=10) as client:
if config.get("use_tls", True):
client.starttls()
if config.get("username") and password:
client.login(str(config["username"]), password)
client.send_message(msg)
def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
import httpx
from backend.webhooks import is_safe_target
if not is_safe_target(url):
raise RuntimeError("Cible webhook bloquée par la politique SSRF")
resp = httpx.post(
url,
json={
"event": trigger,
"title": title,
"message": message,
"timestamp": datetime.now(timezone.utc).isoformat(),
"source": "obsigate-notify",
},
timeout=10.0,
)
resp.raise_for_status()
def send_via_channel(channel: dict[str, Any], title: str, message: str, trigger: str = "manual") -> None:
"""Send a notification through one raw channel record. Raises on failure."""
channel_type = channel.get("type")
cfg = dict(channel.get("config", {}))
secret = _get_secret(channel["id"])
if channel_type == "discord":
url = secret or cfg.get("webhook_url") or ""
if not url or url == "***":
raise RuntimeError("URL webhook Discord manquante")
_send_discord(url, title, message)
elif channel_type == "telegram":
token = secret or cfg.get("bot_token") or os.environ.get("OBSIGATE_TELEGRAM_BOT_TOKEN") or ""
if not token or token == "***":
raise RuntimeError("Token bot Telegram manquant")
_send_telegram(token, str(cfg.get("chat_id", "")), title, message)
elif channel_type == "smtp":
_send_smtp(cfg, secret, title, message)
elif channel_type == "webhook":
url = str(cfg.get("url") or "").strip()
if not url:
raise RuntimeError("URL webhook manquante")
_send_webhook(url, title, message, trigger)
else:
raise RuntimeError(f"Type de canal inconnu : {channel_type}")
def broadcast(trigger: str, title: str, message: str) -> list[dict[str, Any]]:
"""Send to every enabled channel subscribed to *trigger*. Never raises."""
results: list[dict[str, Any]] = []
for channel in _read_channels():
if not channel.get("enabled", True):
continue
if trigger not in channel.get("config", {}).get("triggers", ["manual"]):
continue
try:
send_via_channel(channel, title, message, trigger)
results.append({"channel_id": channel["id"], "ok": True})
_mark_sent(channel["id"], None)
except Exception as e:
logger.warning(f"Notify channel '{channel.get('name')}' failed: {e}")
results.append({"channel_id": channel["id"], "ok": False, "error": str(e)})
_mark_sent(channel["id"], str(e))
return results
def _mark_sent(channel_id: str, error: str | None) -> None:
with _lock:
channels = _read_channels()
for channel in channels:
if channel.get("id") == channel_id:
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
channel["last_error"] = error
_write_channels(channels)
+9
View File
@@ -41,6 +41,9 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
{"name": "Notify", "description": "External notifications: Discord, Telegram, SMTP and generic webhooks (#168)."},
{"name": "Scheduler", "description": "Scheduled automatic tasks reusing the vault mutation services (#170)."},
{"name": "Frontend", "description": "Static assets and SPA fallback routes."},
]
@@ -95,6 +98,9 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^/api/shares"), "Sharing"),
(re.compile(r"^/s/"), "Sharing"),
(re.compile(r"^/api/webhooks"), "Webhooks"),
(re.compile(r"^/api/duplicates"), "Duplicates"),
(re.compile(r"^/api/notify"), "Notify"),
(re.compile(r"^/api/scheduler"), "Scheduler"),
(re.compile(r"^/api/conflicts"), "Conflicts"),
(re.compile(r"^/api/backups"), "Backups"),
(re.compile(r"^/api/file/[^/]+/(backups|diff|restore)"), "Backups"),
@@ -148,6 +154,9 @@ _TAG_ALIASES: dict[str, str] = {
"export": "Export",
"sharing": "Sharing",
"webhooks": "Webhooks",
"duplicates": "Duplicates",
"notify": "Notify",
"scheduler": "Scheduler",
"conflicts": "Conflicts",
"system": "System",
"frontend": "Frontend",
+5
View File
@@ -39,3 +39,8 @@ reportlab>=4.0
pillow>=10.0
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
urllib3>=2.8.0
# Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive
# (aiohttp/yarl). 6.7.x est la version pré-installée dans la toolcache de
# l'image du runner — sans plancher, pip répond « already satisfied » et
# n'aligne jamais (même piège que pypdf, BUG-093).
multidict>=6.9.1
+88
View File
@@ -0,0 +1,88 @@
"""Duplicate detection & merge endpoints (#166).
Read endpoints require vault access; the merge endpoint is destructive
(backup first in the service layer) and additionally requires the
confirmation token pattern used by mutating routes — here enforced by an
explicit ``confirm=true`` body flag, mirroring the agent two-step flow.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from backend.auth.middleware import check_vault_access, require_auth
from backend.services import duplicates as _duplicates
from backend.services.errors import ServiceError
router = APIRouter(prefix="/api/duplicates", tags=["duplicates"])
class DuplicatePair(BaseModel):
"""One candidate duplicate pair."""
model_config = ConfigDict(extra="allow")
file_a: str = Field(description="First file (vault-relative)")
file_b: str = Field(description="Second file (vault-relative)")
score: float = Field(description="Blended similarity in [0, 1]")
class DuplicatesResponse(BaseModel):
"""Response for GET /api/duplicates."""
model_config = ConfigDict(extra="allow")
vault: str = Field(description="Vault name")
threshold: float = Field(description="Applied threshold")
files_scanned: int = Field(description="Markdown files compared")
truncated: bool = Field(description="True when the scan hit the file cap")
pairs: list[DuplicatePair] = Field(description="Candidate pairs, best score first")
class MergeResponse(BaseModel):
"""Response for POST /api/duplicates/merge."""
model_config = ConfigDict(extra="allow")
strategy: str = Field(description="Applied merge strategy")
target: str = Field(description="Surviving note")
deleted: str = Field(description="Absorbed note (deleted after merge)")
@router.get("", response_model=DuplicatesResponse)
async def api_duplicates_list(
vault: str = Query(..., description="Vault name"),
threshold: float = Query(0.75, ge=0.3, le=1.0, description="Minimum similarity"),
limit: int = Query(20, ge=1, le=200, description="Max pairs"),
subdir: str = Query("", description="Directory scope"),
current_user: dict[str, Any] = Depends(require_auth),
):
"""List candidate duplicate notes ordered by descending score."""
if not check_vault_access(vault, current_user):
raise HTTPException(403, f"No access to vault '{vault}'")
try:
return _duplicates.find_duplicate_pairs(vault, threshold=threshold, limit=limit, subdir=subdir)
except ServiceError as e:
raise HTTPException(e.status or 400, e.message) from e
@router.post("/merge", response_model=MergeResponse)
async def api_duplicates_merge(
body: dict[str, Any] = Body(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Merge *source_path* into *target_path* (``confirm: true`` required)."""
vault = str(body.get("vault") or "")
if not check_vault_access(vault, current_user):
raise HTTPException(403, f"No access to vault '{vault}'")
if body.get("confirm") is not True:
raise HTTPException(400, "Fusion destructive : confirmez avec {confirm: true}")
try:
return _duplicates.merge_duplicates(
vault,
str(body.get("source_path") or ""),
str(body.get("target_path") or ""),
strategy=str(body.get("strategy") or "append"),
)
except ServiceError as e:
raise HTTPException(e.status or 400, e.message) from e
+96
View File
@@ -0,0 +1,96 @@
"""External notification channels endpoints (#168).
Channel CRUD is admin-only (secrets involved); sending a test notification
requires authentication. Responses mask secrets (``***`` + ``has_secret``).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from backend import notify as _notify
from backend.auth.middleware import require_admin, require_auth
from backend.schemas import StatusResponse
router = APIRouter(prefix="/api/notify", tags=["notify"])
class NotifyChannel(BaseModel):
"""Public view of a notification channel (secrets masked)."""
model_config = ConfigDict(extra="allow")
id: str = Field(description="Channel id")
name: str = Field(description="Display name")
type: str = Field(description="discord | telegram | smtp | webhook")
enabled: bool = Field(description="Whether the channel receives broadcasts")
config: dict[str, Any] = Field(description="Channel config (secrets masked)")
has_secret: bool = Field(description="True when a secret is configured")
class NotifySendResult(BaseModel):
"""Outcome of a test send / broadcast."""
model_config = ConfigDict(extra="allow")
ok: bool = Field(description="True when every delivery succeeded")
deliveries: list[dict[str, Any]] = Field(default_factory=list)
@router.get("/channels", response_model=list[NotifyChannel])
async def api_notify_list(current_user=Depends(require_admin)):
"""List notification channels (admin)."""
return _notify.list_channels()
@router.post("/channels", response_model=NotifyChannel)
async def api_notify_create(body: dict = Body(...), current_user=Depends(require_admin)):
"""Create a channel (``{name, type, config}``). Secrets go to the secret store."""
try:
return _notify.create_channel(
str(body.get("name") or ""),
str(body.get("type") or ""),
dict(body.get("config") or {}),
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/channels/{channel_id}", response_model=NotifyChannel)
async def api_notify_update(channel_id: str, body: dict = Body(...), current_user=Depends(require_admin)):
"""Update a channel (name / enabled / config)."""
try:
result = _notify.update_channel(channel_id, body)
except ValueError as e:
raise HTTPException(400, str(e)) from e
if result is None:
raise HTTPException(404, "Channel not found")
return result
@router.delete("/channels/{channel_id}", response_model=StatusResponse)
async def api_notify_delete(channel_id: str, current_user=Depends(require_admin)):
"""Delete a channel and its secret."""
if not _notify.delete_channel(channel_id):
raise HTTPException(404, "Channel not found")
return {"status": "deleted"}
@router.post("/test", response_model=NotifySendResult)
async def api_notify_test(body: dict = Body(...), current_user=Depends(require_auth)):
"""Send a test notification (broadcast or single ``channel_id``)."""
title = str(body.get("title") or "Test ObsiGate")
message = str(body.get("message") or "Notification de test.")
channel_id = str(body.get("channel_id") or "")
if channel_id:
channel = next((c for c in _notify._read_channels() if c.get("id") == channel_id), None)
if channel is None:
raise HTTPException(404, "Channel not found")
try:
_notify.send_via_channel(channel, title, message, "manual")
except Exception as e:
raise HTTPException(502, f"Envoi échoué : {e}") from e
return {"ok": True, "deliveries": [{"channel_id": channel_id, "ok": True}]}
deliveries = _notify.broadcast("manual", title, message)
return {"ok": all(d.get("ok") for d in deliveries), "deliveries": deliveries}
+118
View File
@@ -0,0 +1,118 @@
"""Scheduled tasks endpoints (#170).
Tasks reuse the existing mutation/notification services — this router only
validates, persists and triggers. File-writing actions check vault access
at creation time; the background tick re-checks nothing (system context) but
records failures and notifies on ``schedule_failure`` (#168).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from backend import scheduler as _scheduler
from backend.auth.middleware import check_vault_access, require_auth
from backend.schemas import StatusResponse
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
class ScheduledTask(BaseModel):
"""A programmed automatic task."""
model_config = ConfigDict(extra="allow")
id: str = Field(description="Task id")
name: str = Field(description="Display name")
action: dict[str, Any] = Field(description="{kind, params}")
schedule: dict[str, Any] = Field(description="{kind, ...}")
enabled: bool = Field(description="Whether the tick executes it")
created_by: str = Field(description="Owner username")
created_at: str = Field(description="ISO-8601 creation time")
last_run_at: str | None = Field(default=None)
last_status: str | None = Field(default=None)
last_error: str | None = Field(default=None)
run_count: int = Field(default=0)
next_run_at: str = Field(description="ISO-8601 next due time")
class TaskRunResult(BaseModel):
"""Outcome of a manual or due run."""
model_config = ConfigDict(extra="allow")
task_id: str = Field(description="Task id")
ok: bool = Field(description="True on success")
result: dict[str, Any] | None = Field(default=None)
error: str | None = Field(default=None)
def _check_action_vault(action: dict[str, Any], user: dict[str, Any]) -> None:
from backend.services.errors import ServiceError
from backend.services.vaults import get_vault_root
kind = (action or {}).get("kind")
params = (action or {}).get("params") or {}
if kind in ("create_file", "append_to_file"):
vault = str(params.get("vault") or "")
if not check_vault_access(vault, user):
raise HTTPException(403, f"No access to vault '{vault}'")
try:
get_vault_root(vault)
except ServiceError as e:
raise HTTPException(404, f"Unknown vault '{vault}'") from e
@router.get("/tasks", response_model=list[ScheduledTask])
async def api_scheduler_list(current_user: dict[str, Any] = Depends(require_auth)):
"""List scheduled tasks (newest first)."""
return _scheduler.list_tasks()
@router.post("/tasks", response_model=ScheduledTask)
async def api_scheduler_create(body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
"""Create a task (``{name, action, schedule, enabled?}``)."""
action = dict(body.get("action") or {})
_check_action_vault(action, current_user)
try:
return _scheduler.create_task(
str(body.get("name") or ""),
action,
dict(body.get("schedule") or {}),
created_by=str(current_user.get("username", "api")),
enabled=bool(body.get("enabled", True)),
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/tasks/{task_id}", response_model=ScheduledTask)
async def api_scheduler_update(task_id: str, body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
"""Update a task (name / enabled / action / schedule)."""
if "action" in body:
_check_action_vault(dict(body["action"] or {}), current_user)
try:
result = _scheduler.update_task(task_id, body)
except ValueError as e:
raise HTTPException(400, str(e)) from e
if result is None:
raise HTTPException(404, "Task not found")
return result
@router.delete("/tasks/{task_id}", response_model=StatusResponse)
async def api_scheduler_delete(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Delete a task."""
if not _scheduler.delete_task(task_id):
raise HTTPException(404, "Task not found")
return {"status": "deleted"}
@router.post("/tasks/{task_id}/run", response_model=TaskRunResult)
async def api_scheduler_run(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Execute a task immediately (manual run)."""
try:
return _scheduler.run_task(task_id, manual=True)
except KeyError:
raise HTTPException(404, "Task not found") from None
+331
View File
@@ -0,0 +1,331 @@
"""Scheduled tasks — automatic agent actions, type cron (#170).
Tasks are persisted in ``data/scheduled_tasks.json`` (guarded by an RLock,
same pattern as the other JSON stores). Supported actions reuse the existing
mutation/notification services — no new write path:
* ``create_file`` → ``backend.services.mutations.create_file``;
* ``append_to_file`` → ``backend.services.mutations.append_to_file``;
* ``notify`` → ``backend.notify.broadcast`` (trigger ``manual``).
Supported schedules:
* ``interval_hours`` — every N hours (N >= 0.25);
* ``daily_time`` — once a day at ``HH:MM`` (local server time);
* ``once_at`` — one shot at an ISO-8601 datetime (past = due immediately).
On failure the task records ``last_error`` and a ``schedule_failure``
broadcast is emitted to the notification channels (#168) — best effort,
never recursive (a failing ``notify`` action does not rebroadcast).
"""
from __future__ import annotations
import json
import logging
import os
import threading
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.scheduler")
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
TASKS_FILE = DATA_DIR / "scheduled_tasks.json"
ACTION_KINDS = ("create_file", "append_to_file", "notify")
SCHEDULE_KINDS = ("interval_hours", "daily_time", "once_at")
_lock = threading.RLock()
# ── Store ──────────────────────────────────────────────────────────────────
def _read_tasks() -> list[dict[str, Any]]:
if not TASKS_FILE.exists():
return []
try:
data = json.loads(TASKS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except (json.JSONDecodeError, OSError):
return []
def _write_tasks(tasks: list[dict[str, Any]]) -> None:
TASKS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = TASKS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(tasks, indent=2, default=str), encoding="utf-8")
tmp.replace(TASKS_FILE)
def list_tasks() -> list[dict[str, Any]]:
"""Return all scheduled tasks (newest first)."""
return sorted(_read_tasks(), key=lambda t: t.get("created_at", ""), reverse=True)
def get_task(task_id: str) -> dict[str, Any] | None:
"""Return one task by id, or None."""
for task in _read_tasks():
if task.get("id") == task_id:
return task
return None
# ── Validation ─────────────────────────────────────────────────────────────
def _validate_action(action: dict[str, Any]) -> dict[str, Any]:
kind = action.get("kind")
if kind not in ACTION_KINDS:
raise ValueError(f"Action inconnue : {kind} (attendu : {', '.join(ACTION_KINDS)})")
params = dict(action.get("params") or {})
if kind in ("create_file", "append_to_file"):
if not str(params.get("vault") or "").strip():
raise ValueError("params.vault requis pour create_file/append_to_file")
if not str(params.get("path") or "").strip():
raise ValueError("params.path requis pour create_file/append_to_file")
if kind == "append_to_file" and not str(params.get("content") or ""):
raise ValueError("params.content requis pour append_to_file")
elif kind == "notify":
if not str(params.get("title") or "").strip():
raise ValueError("params.title requis pour notify")
if not str(params.get("message") or "").strip():
raise ValueError("params.message requis pour notify")
return {"kind": kind, "params": params}
def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
kind = schedule.get("kind")
if kind not in SCHEDULE_KINDS:
raise ValueError(f"Planification inconnue : {kind} (attendu : {', '.join(SCHEDULE_KINDS)})")
if kind == "interval_hours":
hours = float(schedule.get("hours") or 0)
if hours < 0.25:
raise ValueError("hours doit être >= 0.25")
return {"kind": kind, "hours": hours}
if kind == "daily_time":
at = str(schedule.get("at") or "").strip()
try:
datetime.strptime(at, "%H:%M")
except ValueError:
raise ValueError("at doit être au format HH:MM (ex. 08:30)") from None
return {"kind": kind, "at": at}
# once_at
at = str(schedule.get("at") or "").strip()
try:
parsed = datetime.fromisoformat(at)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
except ValueError:
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
return {"kind": kind, "at": parsed.isoformat()}
# ── CRUD ───────────────────────────────────────────────────────────────────
def create_task(
name: str,
action: dict[str, Any],
schedule: dict[str, Any],
*,
created_by: str = "api",
enabled: bool = True,
) -> dict[str, Any]:
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
validated_action = _validate_action(action)
validated_schedule = _validate_schedule(schedule)
now = datetime.now(timezone.utc)
with _lock:
tasks = _read_tasks()
task = {
"id": str(uuid.uuid4()),
"name": (name or validated_action["kind"]).strip() or validated_action["kind"],
"action": validated_action,
"schedule": validated_schedule,
"enabled": bool(enabled),
"created_by": created_by,
"created_at": now.isoformat(),
"last_run_at": None,
"last_status": None,
"last_error": None,
"run_count": 0,
"next_run_at": compute_next_run(
{"schedule": validated_schedule, "last_run_at": None}, now
).isoformat(),
}
tasks.append(task)
_write_tasks(tasks)
logger.info(f"Scheduled task created: '{task['name']}' ({validated_schedule['kind']})")
return task
def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
"""Update name/enabled/action/schedule. Returns None when unknown."""
with _lock:
tasks = _read_tasks()
for task in tasks:
if task.get("id") != task_id:
continue
if updates.get("name"):
task["name"] = str(updates["name"])
if "enabled" in updates:
task["enabled"] = bool(updates["enabled"])
if "action" in updates:
task["action"] = _validate_action(updates["action"])
if "schedule" in updates:
task["schedule"] = _validate_schedule(updates["schedule"])
task["next_run_at"] = compute_next_run(task).isoformat()
_write_tasks(tasks)
return task
return None
def delete_task(task_id: str) -> bool:
"""Delete a task. Returns False when unknown."""
with _lock:
tasks = _read_tasks()
remaining = [t for t in tasks if t.get("id") != task_id]
if len(remaining) == len(tasks):
return False
_write_tasks(remaining)
return True
# ── Scheduling ─────────────────────────────────────────────────────────────
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
"""Compute the next due datetime for *task*."""
now = now or datetime.now(timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
schedule = task.get("schedule", {})
kind = schedule.get("kind")
last_run_at = task.get("last_run_at")
last = None
if last_run_at:
try:
last = datetime.fromisoformat(str(last_run_at))
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
except ValueError:
last = None
if kind == "interval_hours":
hours = float(schedule.get("hours", 24))
base = last or now
nxt = base + timedelta(hours=hours)
# Première planification : due dès maintenant + intervalle ? Non —
# la tâche démarre au prochain intervalle, sauf retard déjà accumulé.
if last is None:
nxt = now + timedelta(hours=hours)
return max(now, nxt)
if kind == "daily_time":
hour, minute = (str(schedule.get("at", "08:00")) + ":00").split(":")[:2]
candidate = now.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
if candidate <= now:
candidate += timedelta(days=1)
return candidate
if kind == "once_at":
try:
at = datetime.fromisoformat(str(schedule.get("at")))
if at.tzinfo is None:
at = at.replace(tzinfo=timezone.utc)
except ValueError:
return now
if task.get("last_run_at"):
return datetime.max.replace(tzinfo=timezone.utc) # déjà exécutée
return at
return now + timedelta(hours=24)
def _execute_action(task: dict[str, Any]) -> dict[str, Any]:
action = task["action"]
kind = action["kind"]
params = action["params"]
if kind == "create_file":
from backend.services.mutations import create_file
return create_file(
params["vault"],
params["path"],
params.get("content", ""),
overwrite=bool(params.get("overwrite", False)),
)
if kind == "append_to_file":
from backend.services.mutations import append_to_file
return append_to_file(params["vault"], params["path"], params.get("content", ""))
if kind == "notify":
from backend.notify import broadcast
results = broadcast("manual", str(params["title"]), str(params.get("message", "")))
return {"broadcast": results}
raise ValueError(f"Action inconnue : {kind}")
def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
"""Execute one task now (manual or due). Records status; notifies on failure."""
with _lock:
tasks = _read_tasks()
task = next((t for t in tasks if t.get("id") == task_id), None)
if task is None:
raise KeyError(task_id)
if not task.get("enabled", True) and not manual:
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
try:
result = _execute_action(task)
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
task["last_status"] = "ok"
task["last_error"] = None
task["run_count"] = int(task.get("run_count", 0)) + 1
if task.get("schedule", {}).get("kind") == "once_at":
task["enabled"] = False # one-shot consommé
task["next_run_at"] = compute_next_run(task).isoformat()
_write_tasks(tasks)
if manual:
from backend.notify import broadcast
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
return {"task_id": task_id, "ok": True, "result": result}
except Exception as e:
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
task["last_status"] = "error"
task["last_error"] = str(e)
task["run_count"] = int(task.get("run_count", 0)) + 1
task["next_run_at"] = compute_next_run(task).isoformat()
_write_tasks(tasks)
logger.warning(f"Scheduled task '{task.get('name')}' failed: {e}")
if task["action"]["kind"] != "notify":
try:
from backend.notify import broadcast
broadcast(
"schedule_failure",
f"Échec tâche « {task.get('name')} »",
f"{e}",
)
except Exception:
logger.debug("Failure notification broadcast failed", exc_info=True)
return {"task_id": task_id, "ok": False, "error": str(e)}
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
"""Run every due task. Returns per-task outcomes (empty when idle)."""
now = now or datetime.now(timezone.utc)
outcomes: list[dict[str, Any]] = []
for task in _read_tasks():
if not task.get("enabled", True):
continue
try:
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc)
except ValueError:
next_run = compute_next_run(task, now)
if next_run <= now:
outcomes.append(run_task(task["id"]))
return outcomes
+215
View File
@@ -0,0 +1,215 @@
"""Duplicate detection & merge services (#166).
Single source of truth consumed by the REST routes
(``/api/duplicates``) and the AI tool layer (``find_duplicates``,
``merge_duplicate_notes``).
Method is deterministic stdlib-only: frontmatter stripped, token-set
Jaccard blended with a title similarity. No embedding dependency —
the semantic index (#70) stays an optional refinement, not a requirement.
Fusion never runs without an explicit confirmation: the tool layer
registers the merge as ``DANGEROUS`` (two-step propose/apply) and this
service takes an automatic backup before any destructive write.
"""
from __future__ import annotations
import logging
import re
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
from backend.services.backups import create_backup
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
from backend.services.vaults import get_vault_root
logger = logging.getLogger("obsigate.services.duplicates")
MAX_FILES_SCANNED = 500
MAX_FILE_BYTES = 200_000
MAX_CONTENT_CHARS = 50_000
_WORD_RE = re.compile(r"[\w]+", re.UNICODE)
_FRONTMATTER_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)
def _strip_frontmatter(text: str) -> str:
"""Remove a leading YAML frontmatter block, if present."""
return _FRONTMATTER_RE.sub("", text, count=1)
def _tokens(text: str) -> set[str]:
"""Lowercase word tokens (keeps accents), stop-words free but tiny tokens dropped."""
return {t for t in _WORD_RE.findall(text.lower()) if len(t) > 2}
def similarity_score(a: str, b: str) -> float:
"""Blend Jaccard (0.7) + title/first-line similarity (0.3) in [0, 1].
Pure function — unit-tested directly.
"""
ta, tb = _tokens(_strip_frontmatter(a)), _tokens(_strip_frontmatter(b))
if not ta or not tb:
return 0.0
jaccard = len(ta & tb) / len(ta | tb)
head_a = (a.strip().splitlines() or [""])[:1][0][:200].lower()
head_b = (b.strip().splitlines() or [""])[:1][0][:200].lower()
title_sim = SequenceMatcher(None, head_a, head_b).ratio() if head_a and head_b else 0.0
return round(0.7 * jaccard + 0.3 * title_sim, 4)
def _iter_markdown_files(root: Path, subdir: str = "") -> list[Path]:
base = resolve_safe_path(root, subdir) if subdir else root.resolve()
if not base.exists() or not base.is_dir():
raise ServiceError(
f"Directory not found: {subdir or '.'}",
code="not_found",
status=404,
details={"path": subdir},
)
files = sorted(
(p for p in base.rglob("*.md") if p.is_file() and not p.is_symlink()),
key=lambda p: str(p),
)
return files[:MAX_FILES_SCANNED]
def _read_capped(path: Path) -> str:
try:
if path.stat().st_size > MAX_FILE_BYTES:
return ""
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
return text[:MAX_CONTENT_CHARS]
def find_duplicate_pairs(
vault: str,
threshold: float = 0.75,
limit: int = 50,
subdir: str = "",
) -> dict[str, Any]:
"""Return candidate duplicate pairs ordered by descending score.
Args:
vault: Vault name.
threshold: Minimum blended score in [0.3, 1.0].
limit: Max pairs returned (1-200).
subdir: Optional vault-relative directory scope.
"""
if not 0.3 <= threshold <= 1.0:
raise ServiceError(
"threshold must be between 0.3 and 1.0",
code="invalid_arguments",
status=400,
)
limit = max(1, min(limit, 200))
root = get_vault_root(vault)
files = _iter_markdown_files(root, subdir)
contents: dict[str, str] = {}
token_sets: dict[str, set[str]] = {}
for path in files:
rel = str(path.relative_to(root)).replace("\\", "/")
text = _read_capped(path)
if not text.strip():
continue
contents[rel] = text
token_sets[rel] = _tokens(_strip_frontmatter(text))
rels = sorted(contents)
pairs: list[dict[str, Any]] = []
for i in range(len(rels)):
for j in range(i + 1, len(rels)):
a, b = rels[i], rels[j]
ta, tb = token_sets[a], token_sets[b]
if not ta or not tb:
continue
# Cheap pre-filter: Jaccard lower bound before the full score.
inter = len(ta & tb)
union = len(ta | tb)
if union == 0 or inter / union < threshold * 0.6:
continue
score = similarity_score(contents[a], contents[b])
if score >= threshold:
pairs.append({"file_a": a, "file_b": b, "score": score})
pairs.sort(key=lambda p: p["score"], reverse=True)
return {
"vault": vault,
"threshold": threshold,
"files_scanned": len(contents),
"truncated": len(files) >= MAX_FILES_SCANNED,
"pairs": pairs[:limit],
}
def merge_duplicates(
vault: str,
source_path: str,
target_path: str,
strategy: str = "append",
) -> dict[str, Any]:
"""Merge *source_path* into *target_path*, then delete the source.
Strategies:
``append`` — source content appended after target (separator + origin
marker), source deleted.
``prefer_target`` — source deleted, target untouched (dedupe only).
``prefer_source`` — target overwritten with source content, source deleted.
A backup of both files is taken first; the source deletion also goes
through the backup-aware mutation service.
"""
from backend.services import mutations as _mutations
if strategy not in ("append", "prefer_target", "prefer_source"):
raise ServiceError(
f"Unknown strategy: {strategy}",
code="invalid_arguments",
status=400,
)
if source_path == target_path:
raise ServiceError(
"source_path and target_path must differ",
code="invalid_arguments",
status=400,
)
root = get_vault_root(vault)
src = resolve_safe_path(root, source_path)
dst = resolve_safe_path(root, target_path)
if not src.is_file() or src.suffix.lower() != ".md":
raise ServiceError(
f"Source not found: {source_path}",
code="not_found",
status=404,
details={"path": source_path},
)
if not dst.is_file() or dst.suffix.lower() != ".md":
raise ServiceError(
f"Target not found: {target_path}",
code="not_found",
status=404,
details={"path": target_path},
)
# Backup préalable (jamais de fusion sans filet — critère #166).
create_backup(src, vault, source_path)
create_backup(dst, vault, target_path)
if strategy == "prefer_target":
result = _mutations.delete_file(vault, source_path)
return {"strategy": strategy, "target": target_path, "deleted": source_path, "delete": result}
if strategy == "prefer_source":
content = src.read_text(encoding="utf-8", errors="replace")
result = _mutations.edit_file(vault, target_path, content)
deleted = _mutations.delete_file(vault, source_path)
return {"strategy": strategy, "target": target_path, "edit": result, "deleted": source_path, "delete": deleted}
# append
target_text = dst.read_text(encoding="utf-8", errors="replace")
source_text = src.read_text(encoding="utf-8", errors="replace")
merged = target_text.rstrip() + f"\n\n---\n\n_Fusionné depuis `{source_path}` (#166)_\n\n" + source_text.lstrip()
result = _mutations.edit_file(vault, target_path, merged)
deleted = _mutations.delete_file(vault, source_path)
return {"strategy": strategy, "target": target_path, "edit": result, "deleted": source_path, "delete": deleted}
+3
View File
@@ -12,6 +12,9 @@ which ``.gitignore`` excludes via ``_*.py``), hence this explicit facade.
from backend.tools import connected as _connected # noqa: F401 (registers connected-source tools)
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 duplicates as _duplicates # noqa: F401 (registers duplicate tools #166)
from backend.tools import notify as _notify_tools # noqa: F401 (registers notify tool #168)
from backend.tools import scheduled as _scheduled # noqa: F401 (registers scheduler tools #170)
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)
+59
View File
@@ -0,0 +1,59 @@
"""Duplicate detection & merge tools (#166).
* ``find_duplicates`` — READ, vault-scoped: candidate pairs with scores.
* ``merge_duplicate_notes`` — DANGEROUS: confirmed fusion with automatic
backup (service layer), never without an explicit approval.
"""
from __future__ import annotations
from typing import Any
from backend.services import duplicates as _duplicates
from backend.services.errors import ServiceError
from backend.tools.context import ToolContext, ToolError, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import FindDuplicatesInput, MergeDuplicatesInput
@tool(
name="find_duplicates",
description="Find candidate duplicate markdown notes in a vault (similarity scores).",
input_model=FindDuplicatesInput,
risk=ToolRisk.READ,
requires_vault=True,
)
def find_duplicates(ctx: ToolContext, params: FindDuplicatesInput) -> dict[str, Any]:
"""List duplicate candidates ordered by descending score."""
try:
return _duplicates.find_duplicate_pairs(
params.vault,
threshold=params.threshold,
limit=params.limit,
subdir=params.subdir,
)
except ServiceError as e:
raise ToolError(e.message, code=e.code, details=e.details) from e
@tool(
name="merge_duplicate_notes",
description=(
"Merge one note into another and delete the source (backup first). "
"Destructive: requires confirmation."
),
input_model=MergeDuplicatesInput,
risk=ToolRisk.DANGEROUS,
requires_vault=True,
)
def merge_duplicate_notes(ctx: ToolContext, params: MergeDuplicatesInput) -> dict[str, Any]:
"""Fuse *source_path* into *target_path* using the chosen strategy."""
try:
return _duplicates.merge_duplicates(
params.vault,
params.source_path,
params.target_path,
strategy=params.strategy,
)
except ServiceError as e:
raise ToolError(e.message, code=e.code, details=e.details) from e
+7
View File
@@ -62,6 +62,13 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
"create_docx": ("docx_create", "path"),
"create_csv": ("csv_create", "path"),
"create_pdf": ("pdf_create", "path"),
"find_duplicates": ("duplicates", "vault"),
"merge_duplicate_notes": ("duplicates_merge", "source_path"),
"notify_external": ("notify", "title"),
"create_scheduled_task": ("schedule_create", "name"),
"list_scheduled_tasks": ("schedule_list", None),
"delete_scheduled_task": ("schedule_delete", "task_id"),
"run_scheduled_task_now": ("schedule_run", "task_id"),
}
GENERIC_KEY = "generic"
+42
View File
@@ -0,0 +1,42 @@
"""External notification tool (#168) — Discord, Telegram, SMTP, webhook.
``notify_external`` is WRITE (external side effect → confirmation card in the
UI, propose/apply over MCP). Delivery itself lives in :mod:`backend.notify`.
"""
from __future__ import annotations
from typing import Any
from backend import notify as _notify
from backend.tools.context import ToolContext, ToolError, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import NotifyExternalInput
@tool(
name="notify_external",
description="Send a notification through external channels (Discord, Telegram, SMTP, webhook).",
input_model=NotifyExternalInput,
risk=ToolRisk.WRITE,
)
def notify_external(ctx: ToolContext, params: NotifyExternalInput) -> dict[str, Any]:
"""Broadcast to the trigger scope, or target a single channel id."""
try:
if params.channel_id:
channel = next(
(c for c in _notify._read_channels() if c.get("id") == params.channel_id),
None,
)
if channel is None:
raise ToolError(f"Unknown channel: {params.channel_id}", code="not_found")
if not channel.get("enabled", True):
raise ToolError(f"Channel disabled: {params.channel_id}", code="invalid_arguments")
_notify.send_via_channel(channel, params.title, params.message, params.trigger)
return {"ok": True, "channel_id": params.channel_id}
results = _notify.broadcast(params.trigger, params.title, params.message)
return {"ok": True, "deliveries": results}
except ToolError:
raise
except Exception as e:
raise ToolError(f"Notification failed: {e}", code="notify_failed") from e
+15 -2
View File
@@ -117,9 +117,22 @@ def list_tools(*, scope: ToolScope | None = None) -> list[ToolSpec]:
return specs
# ponytail: tool schemas are static after import (registration is decorator
# only); keying the cache on len(_REGISTRY) invalidates it if a tool is ever
# registered at runtime. Rebuilding 50 pydantic JSON schemas cost ~27 ms per
# agent/MCP request.
_SCHEMAS_CACHE: dict[Any, list[dict[str, Any]]] = {}
def get_tool_schemas(*, scope: ToolScope | None = None) -> list[dict[str, Any]]:
"""Return OpenAI-compatible schemas for registered tools."""
return [spec.openai_schema() for spec in list_tools(scope=scope)]
"""Return OpenAI-compatible schemas for registered tools (cached)."""
key = (scope, len(_REGISTRY))
cached = _SCHEMAS_CACHE.get(key)
if cached is None:
cached = [spec.openai_schema() for spec in list_tools(scope=scope)]
_SCHEMAS_CACHE.clear()
_SCHEMAS_CACHE[key] = cached
return [dict(s) for s in cached]
def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: bool, error: str | None = None) -> None:
+78
View File
@@ -0,0 +1,78 @@
"""Scheduled-task tools (#170) — the agent programs its own cron.
* ``create_scheduled_task`` — WRITE (a future write, confirmed once now).
* ``list_scheduled_tasks`` — READ.
* ``delete_scheduled_task`` — WRITE (removes a future side effect).
* ``run_scheduled_task_now`` — WRITE (immediate side effect).
"""
from __future__ import annotations
from typing import Any
from backend import scheduler as _scheduler
from backend.tools.context import ToolContext, ToolError, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import (
CreateScheduledTaskInput,
DeleteScheduledTaskInput,
ListVaultsInput,
RunScheduledTaskInput,
)
@tool(
name="create_scheduled_task",
description="Program an automatic task (create_file, append_to_file, notify) on a cron-like schedule.",
input_model=CreateScheduledTaskInput,
risk=ToolRisk.WRITE,
)
def create_scheduled_task(ctx: ToolContext, params: CreateScheduledTaskInput) -> dict[str, Any]:
"""Create a task owned by the requesting user."""
try:
return _scheduler.create_task(
params.name,
params.action,
params.schedule,
created_by=ctx.username,
)
except ValueError as e:
raise ToolError(str(e), code="invalid_arguments") from e
@tool(
name="list_scheduled_tasks",
description="List automatic tasks programmed in ObsiGate.",
input_model=ListVaultsInput,
risk=ToolRisk.READ,
)
def list_scheduled_tasks(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]:
"""Return tasks newest first."""
return _scheduler.list_tasks()
@tool(
name="delete_scheduled_task",
description="Delete a programmed automatic task.",
input_model=DeleteScheduledTaskInput,
risk=ToolRisk.WRITE,
)
def delete_scheduled_task(ctx: ToolContext, params: DeleteScheduledTaskInput) -> dict[str, Any]:
"""Delete by id; unknown id is a not_found tool error."""
if not _scheduler.delete_task(params.task_id):
raise ToolError(f"Unknown task: {params.task_id}", code="not_found")
return {"ok": True, "task_id": params.task_id}
@tool(
name="run_scheduled_task_now",
description="Execute a programmed task immediately (manual run).",
input_model=RunScheduledTaskInput,
risk=ToolRisk.WRITE,
)
def run_scheduled_task_now(ctx: ToolContext, params: RunScheduledTaskInput) -> dict[str, Any]:
"""Run now and return the outcome (failures are recorded + notified)."""
try:
return _scheduler.run_task(params.task_id, manual=True)
except KeyError as e:
raise ToolError(f"Unknown task: {params.task_id}", code="not_found") from e
+47
View File
@@ -441,6 +441,53 @@ class PdfInput(BaseModel):
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
class FindDuplicatesInput(BaseModel):
"""Find candidate duplicate notes in a vault (#166)."""
vault: str = Field(..., description="Vault name")
threshold: float = Field(0.75, ge=0.3, le=1.0, description="Minimum similarity score")
limit: int = Field(20, ge=1, le=200, description="Maximum number of pairs")
subdir: str = Field("", description="Vault-relative directory scope (empty = whole vault)")
class MergeDuplicatesInput(BaseModel):
"""Merge one note into another, then delete the source (#166, destructive)."""
vault: str = Field(..., description="Vault name")
source_path: str = Field(..., description="Vault-relative path of the note to absorb")
target_path: str = Field(..., description="Vault-relative path of the surviving note")
strategy: str = Field("append", description="'append', 'prefer_target' or 'prefer_source'")
class NotifyExternalInput(BaseModel):
"""Send a notification through external channels (#168)."""
title: str = Field(..., min_length=1, description="Notification title")
message: str = Field(..., min_length=1, description="Notification body")
trigger: str = Field("manual", description="Trigger scope: manual, schedule_failure, schedule_success")
channel_id: str = Field("", description="Single channel id (empty = broadcast to trigger)")
class CreateScheduledTaskInput(BaseModel):
"""Create an automatic task executed by the scheduler (#170)."""
name: str = Field(..., min_length=1, description="Task display name")
action: dict[str, Any] = Field(..., description="{kind, params} (create_file, append_to_file, notify)")
schedule: dict[str, Any] = Field(..., description="{kind, ...} (interval_hours, daily_time, once_at)")
class DeleteScheduledTaskInput(BaseModel):
"""Delete a scheduled task by id (#170)."""
task_id: str = Field(..., min_length=1, description="Task id")
class RunScheduledTaskInput(BaseModel):
"""Execute a scheduled task immediately (#170)."""
task_id: str = Field(..., min_length=1, description="Task id")
class ToolResult(BaseModel):
"""Uniform result returned by :func:`backend.tools.registry.call_tool`."""
+4 -2
View File
@@ -115,7 +115,9 @@ $env:VERSION = $Version
Write-Info "Version : $Version"
# ----- Build the image -----
$BuildArgs = @("-f", $ComposeFile)
# NOTE : `--no-cache` est un flag de `docker compose build` (après `build`),
# pas un flag global (avant) — sinon `unknown flag: --no-cache`.
$BuildArgs = @("-f", $ComposeFile, "build")
if (-not $UseCache) {
$BuildArgs += "--no-cache"
Write-Info "Construction de l'image Docker (sans cache)..."
@@ -123,7 +125,7 @@ if (-not $UseCache) {
Write-Info "Construction de l'image Docker (avec cache)..."
}
docker compose @BuildArgs build
docker compose @BuildArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Échec de la construction de l'image Docker."
exit $LASTEXITCODE
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.49.2"
version = "2.53.3"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.49.2"
version = "2.53.3"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+2
View File
@@ -38,5 +38,7 @@ fn main() {
println!("cargo:rerun-if-changed=.git/refs/heads/main");
println!("cargo:rerun-if-changed=.git/refs/tags");
println!("cargo:rerun-if-changed=permissions");
tauri_build::build()
}
+1
View File
@@ -7,6 +7,7 @@
},
"permissions": [
"core:default",
"allow-app-commands",
"shell:allow-open",
"shell:allow-execute",
"dialog:default",
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","allow-app-commands","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
+6
View File
@@ -2210,6 +2210,12 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
"type": "string",
"const": "allow-app-commands",
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+6
View File
@@ -2210,6 +2210,12 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
"type": "string",
"const": "allow-app-commands",
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+36
View File
@@ -0,0 +1,36 @@
# Commandes applicatives ObsiGate — autorisées depuis l'origine distante.
#
# La fenêtre Tauri redirige vers http://127.0.0.1:<port> (page servie par le
# backend Python). Pour Tauri v2 c'est une origine *remote* : sans entrée ACL
# explicite, TOUTE commande de l'app (invoke_handler) est rejetée avec
# "Command ... not allowed by ACL" — le bouton « Choisir mon dossier » et le
# wizard ne faisaient alors rien (erreurs avalées par desktop.js:invoke()).
#
# Auto-généré en… non : édité à la main. Liste = tauri::generate_handler![…]
# dans src/main.rs — ajouter toute nouvelle commande ici.
[[permission]]
identifier = "allow-app-commands"
description = "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
commands.allow = [
"get_backend_url",
"get_version",
"get_config",
"save_vault_path",
"get_vault_path",
"get_wizard_state",
"complete_wizard",
"pick_vault_folder",
"pick_folder",
"get_system_theme",
"restart_backend",
"check_backend_health",
"save_window_state",
"get_window_state",
"add_vault",
"remove_vault",
"list_vaults",
"add_dir",
"remove_dir",
"list_dirs",
]
+19 -43
View File
@@ -10,48 +10,23 @@ use log::{info, warn};
#[cfg(target_os = "windows")]
mod imp {
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, HSTRING, PCWSTR};
use windows::Win32::Foundation::PROPERTYKEY;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
use windows::Win32::System::Variant::VT_LPWSTR;
use windows::Win32::UI::Shell::{
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
DestinationList, EnumerableObjectCollection, ShellLink,
};
use windows::Win32::UI::Shell::Common::IObjectCollection;
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, HSTRING, PCWSTR};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::Shell::{
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
DestinationList, EnumerableObjectCollection, ShellLink,
};
use windows::Win32::UI::Shell::Common::IObjectCollection;
const APP_ID: &str = "com.obsigate.desktop";
const APP_ID: &str = "com.obsigate.desktop";
// PKEY_Title (System.Title): {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, pid 2
const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
fmtid: windows::core::GUID::from_u128(0xF29F85E0_4FF9_1068_AB91_08002B27B3D9),
pid: 2,
};
fn wide_null_terminated(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
/// Set the jump-list display title via the shell link's property store.
fn set_link_title(link: &IShellLinkW, title: &str) -> windows::core::Result<()> {
let store: IPropertyStore = link.cast()?;
let mut wide = wide_null_terminated(title);
let mut pv: PROPVARIANT = unsafe { std::mem::zeroed() };
unsafe {
let inner = &mut *pv.Anonymous.Anonymous;
inner.vt = VT_LPWSTR;
inner.Anonymous.pwszVal = windows::core::PWSTR(wide.as_mut_ptr());
store.SetValue(&PKEY_TITLE, &pv)?;
store.Commit()?;
}
Ok(())
}
fn wide_null_terminated(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
/// Build one shell link for a vault. `arg` is the command-line argument the
/// app expects to open that vault (see file associations / single-instance).
@@ -70,9 +45,10 @@ mod imp {
}
link.SetDescription(&HSTRING::from(name))?;
}
// Title (display name) is best-effort — a missing title only means the
// jump-list entry falls back to the executable name.
let _ = set_link_title(&link, name);
// Title (display name): the raw PROPVARIANT setter that used to live
// here corrupted the process heap (STATUS_HEAP_CORRUPTION c0000374 —
// the shell property store freed our Rust-allocated VT_LPWSTR buffer).
// SetDescription below already gives jump-list entries their label.
Ok(link)
}
+202 -18
View File
@@ -48,6 +48,7 @@ fn pick_free_port() -> u16 {
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use tauri::Manager;
use tauri::webview::WebviewWindowBuilder;
// ── State ───────────────────────────────────────────────────────────────────
@@ -160,6 +161,39 @@ fn save_config(config: &AppConfig) {
}
}
// ── Premier lancement (#160) ───────────────────────────────────────────────
/// Répertoire de démarrage monté comme vault au premier lancement.
const STARTER_VAULT_NAME: &str = "ObsiGate";
/// Document d'accueil écrit (si absent) dans le répertoire de démarrage.
const WELCOME_DOC_NAME: &str = "Prise en main.md";
const WELCOME_DOC: &str = include_str!("prise_en_main.md");
/// Config par défaut au premier lancement — pure, sans I/O (testable).
/// `<home>/ObsiGate` est monté comme vault ET comme contexte initial ; le
/// dossier home reste une racine, nommée d'après son dernier segment
/// (fin du « bruno » codé en dur, #160).
fn default_first_run_config(home: &str) -> AppConfig {
let home_path = std::path::Path::new(home);
let vault_dir = home_path.join("ObsiGate");
let vault_path = vault_dir.to_string_lossy().to_string();
AppConfig {
vault_path: Some(vault_path.clone()),
vaults: vec![VaultConfig {
name: STARTER_VAULT_NAME.to_string(),
path: vault_path,
}],
dirs: vec![DirConfig {
name: home_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| home.to_string()),
path: home.to_string(),
}],
..AppConfig::default()
}
}
// ── Backend lifecycle ───────────────────────────────────────────────────────
fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
@@ -320,6 +354,18 @@ async fn pick_vault_folder(app: tauri::AppHandle) -> Result<String, String> {
}
}
/// Sélecteur de dossier générique, sans effet de bord (contrairement à
/// `pick_vault_folder` qui écrit `vault_path` + `wizard_done` pour le wizard).
/// Sert à l'ajout d'un dossier racine depuis la section Configuration (#159).
#[tauri::command]
async fn pick_folder(app: tauri::AppHandle) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
match app.dialog().file().blocking_pick_folder() {
Some(p) => Ok(p.to_string()),
None => Err("No folder selected".to_string()),
}
}
#[tauri::command]
fn get_wizard_state() -> bool {
load_config().wizard_done
@@ -343,6 +389,13 @@ fn get_system_theme(app: tauri::AppHandle) -> String {
}
}
/// La jump list des raccourcis suit les vaults : à rafraîchir après tout
/// ajout/retrait de vault (#159). Best-effort — `update_jumplist` logge et n'échoue jamais.
fn refresh_jumplist() {
let vaults = load_config().vaults;
jumplist::update_jumplist(&jumplist::build_vault_args(&vaults));
}
#[tauri::command]
fn add_vault(name: String, path: String) -> Result<(), String> {
let mut config = load_config();
@@ -350,6 +403,7 @@ fn add_vault(name: String, path: String) -> Result<(), String> {
config.vaults.retain(|v| v.name != name);
config.vaults.push(VaultConfig { name: name.clone(), path });
save_config(&config);
refresh_jumplist();
info!("Vault added: {} — restart backend to apply", name);
Ok(())
}
@@ -359,6 +413,7 @@ fn remove_vault(name: String) -> Result<(), String> {
let mut config = load_config();
config.vaults.retain(|v| v.name != name);
save_config(&config);
refresh_jumplist();
info!("Vault removed: {} — restart backend to apply", name);
Ok(())
}
@@ -513,7 +568,7 @@ fn main() {
// Load config for window state
let mut config = load_config();
// First run: create sensible defaults per platform
// First run: create sensible defaults per platform (#160)
if config.vaults.is_empty() {
info!("First run — creating default vault configuration");
#[cfg(target_os = "windows")]
@@ -521,25 +576,28 @@ fn main() {
#[cfg(not(target_os = "windows"))]
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/bruno".to_string());
let vault_dir = std::path::PathBuf::from(&home).join("voute_obsidian");
// Create the vault directory if it doesn't exist
if !vault_dir.exists() {
if let Err(e) = fs::create_dir_all(&vault_dir) {
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
} else {
info!("Created default vault dir: {}", vault_dir.display());
// Montage du répertoire de démarrage (vault + contexte initial) sans
// toucher aux autres champs (taille/position de fenêtre) de la config.
let defaults = default_first_run_config(&home);
config.dirs = defaults.dirs;
config.vaults = defaults.vaults;
config.vault_path = defaults.vault_path;
let vault_dir = std::path::PathBuf::from(config.vaults[0].path.clone());
if let Err(e) = fs::create_dir_all(&vault_dir) {
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
} else {
info!("Created default vault dir: {}", vault_dir.display());
}
// Document d'accueil : écrit une seule fois, jamais écrasé (#160).
let welcome = vault_dir.join(WELCOME_DOC_NAME);
if !welcome.exists() {
match fs::write(&welcome, WELCOME_DOC) {
Ok(()) => info!("Welcome document written: {}", welcome.display()),
Err(e) => error!("Failed to write {}: {}", welcome.display(), e),
}
}
config.dirs = vec![
DirConfig { name: "bruno".to_string(), path: home.clone() },
];
config.vaults = vec![
VaultConfig {
name: "Obsidian".to_string(),
path: vault_dir.to_string_lossy().to_string(),
},
];
config.vault_path = Some(home);
save_config(&config);
info!("Default vault config saved");
}
@@ -589,6 +647,7 @@ fn main() {
get_wizard_state,
complete_wizard,
pick_vault_folder,
pick_folder,
get_system_theme,
restart_backend,
check_backend_health,
@@ -602,6 +661,19 @@ fn main() {
list_dirs,
])
.setup(move |app| {
// BUG-107 / #89 — HTML5 drag & drop des fichiers : Tauri installe
// son propre IDropTarget par-dessus celui du WebView2 (wry :
// « find the WebView2 window and override! ») et aucun événement
// natif n'est écouté ici → les glisser-déposer depuis
// l'Explorateur n'atteignaient jamais la page. La fenêtre est donc
// créée manuellement (`create: false` dans tauri.conf.json) avec
// le handler désactivé : la page reçoit les drops comme sur le web.
let window_config = app.config().app.windows[0].clone();
WebviewWindowBuilder::from_config(app, &window_config)
.expect("fenêtre principale (tauri.conf.json)")
.disable_drag_drop_handler()
.build()?;
// Build native menu bar (File / Edit / Help)
let _ = build_native_menu(app);
@@ -1122,4 +1194,116 @@ mod tests {
let dbg = format!("{:?}", d);
assert!(dbg.contains("x"));
}
/// Guardrail ACL : toute commande Tauri invoquée par le frontend doit
/// figurer dans desktop/permissions/commands.toml. La page servie par le
/// backend (http://127.0.0.1) est une origine *remote* pour Tauri v2 :
/// sans cette entrée, la commande est rejetée « not allowed by ACL » et
/// desktop.js:invoke() avale l'erreur → bouton silencieusement mort
/// (bouton « Choisir mon dossier » du wizard, BUG-104).
#[test]
fn test_frontend_invokes_are_acl_allowed() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let perms = fs::read_to_string(dir.join("permissions/commands.toml"))
.expect("desktop/permissions/commands.toml manquant");
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
// Commandes déclarées dans le manifeste de permissions.
let allow_list = perms
.split("commands.allow")
.nth(1)
.and_then(|s| s.split(']').next())
.unwrap_or("");
let allowed: Vec<String> = allow_list
.split('"')
.skip(1)
.step_by(2)
.map(|s| s.to_string())
.collect();
assert!(!allowed.is_empty(), "commands.allow vide dans commands.toml");
// Chaque permission doit correspondre à une commande réellement
// enregistrée (faute de frappe → permission morte).
for cmd in &allowed {
assert!(
main_src.contains(&format!("fn {cmd}(")),
"permission allow pour la commande inconnue {cmd}"
);
}
// Toute commande invoke('…') du frontend doit être autorisée.
let js_dir = dir.join("../frontend/js");
let mut seen = 0usize;
for entry in fs::read_dir(&js_dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("js") { continue; }
let src = fs::read_to_string(&path).unwrap();
let mut rest = src.as_str();
while let Some(pos) = rest.find("invoke('") {
rest = &rest[pos + "invoke('".len()..];
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if !name.is_empty() {
seen += 1;
assert!(
allowed.contains(&name),
"commande {name} invoquée par {} absente de permissions/commands.toml",
path.file_name().unwrap().to_string_lossy()
);
}
rest = &rest[rest.find('\'').map(|i| i + 1).unwrap_or(rest.len())..];
}
}
assert!(seen > 0, "aucun invoke('…') trouvé dans frontend/js");
}
/// BUG-107 : la fenêtre est créée en code (`create: false`) avec le
/// handler de drag & drop de Tauri désactivé. Si l'un des deux manque,
/// wry réinstalle son IDropTarget par-dessus celui du WebView2 et les
/// glisser-déposer de fichiers depuis l'Explorateur n'atteignent plus la
/// page (#89 fonctionnait uniquement sur le web).
#[test]
fn test_window_created_without_tauri_drag_drop_handler() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let conf = fs::read_to_string(dir.join("tauri.conf.json")).unwrap();
assert!(
conf.contains("\"create\": false"),
"tauri.conf.json : la fenêtre doit être créée en code (create: false)"
);
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
assert!(
main_src.contains("disable_drag_drop_handler()"),
"main.rs : disable_drag_drop_handler() manquant — drag & drop bloqué"
);
}
/// #160 — premier lancement : `<home>/ObsiGate` monté comme vault et
/// comme contexte initial, racine home nommée d'après son chemin.
#[test]
fn test_default_first_run_config() {
#[cfg(target_os = "windows")]
let home = r"C:\Users\alice";
#[cfg(not(target_os = "windows"))]
let home = "/home/alice";
let c = default_first_run_config(home);
assert_eq!(c.vaults.len(), 1);
assert_eq!(c.vaults[0].name, STARTER_VAULT_NAME);
assert!(c.vaults[0].path.ends_with("ObsiGate"), "{}", c.vaults[0].path);
assert_eq!(c.vault_path.as_deref(), Some(c.vaults[0].path.as_str()));
assert_eq!(c.dirs.len(), 1);
assert_eq!(c.dirs[0].name, "alice", "nom de racine dérivé du chemin");
assert_eq!(c.dirs[0].path, home);
assert!(!c.wizard_done);
}
/// #160 — le document d'accueil est embarqué dans le binaire : non vide,
/// Markdown, et nom de fichier stable (écrit une seule fois sur le disque).
#[test]
fn test_welcome_doc_embedded() {
assert!(WELCOME_DOC.len() > 500, "document d'accueil trop court");
assert!(WELCOME_DOC.starts_with('#'), "attendu : titre Markdown");
assert!(WELCOME_DOC.contains("ObsiGate"));
assert!(WELCOME_DOC.contains("Ctrl+Espace"));
assert_eq!(WELCOME_DOC_NAME, "Prise en main.md");
}
}
+41
View File
@@ -0,0 +1,41 @@
# 🟢 Bienvenue sur ObsiGate — Prise en main
Ce dossier `%USERPROFILE%\ObsiGate` est votre **espace de départ** : il est
monté automatiquement comme vault **ObsiGate** au premier lancement. Vous
pouvez y écrire librement, puis brancher vos vrais vaults Obsidian quand vous
êtes prêt.
## ObsiGate en 30 secondes
ObsiGate est une porte d'entrée locale vers vos notes : index en mémoire,
recherche instantanée, lecture Markdown avec liens `[[wikilinks]]`, images,
PDF, tableurs Excel, Excalidraw, Mermaid et assistant IA — sans base de
données, sans modification de vos fichiers, tout reste sur votre machine.
## Premiers pas (application desktop)
1. **Ce document** s'affiche dans la visionneuse : c'est votre première note,
modifiez-la ou supprimez-la quand vous n'en avez plus besoin.
2. **Ajouter vos vaults** : Configuration → « 🖥️ Vaults & dossiers (Desktop) »
→ *Ajouter un vault* (sélecteur de dossier natif). Depuis la sidebar,
clic droit sur une racine → « Retirer de l'application » la désiste
(aucun fichier n'est supprimé).
3. **Naviguer** : la sidebar liste vos racines et leur arbre ; l'onglet
Navigation parcourt un dossier sous forme de vues répertoire.
4. **Rechercher** : `Ctrl+Espace` (palette de fichiers),
`Ctrl+Alt+Espace` (palette de commandes), `Ctrl+F` dans un document.
5. **Onglets** : `Ctrl+W` ferme l'onglet, `Ctrl+Tab` bascule ; le mode split
divise l'écran en panneaux.
6. **Guide complet** : bouton d'aide de l'en-tête — sommaire, raccourcis,
architecture, et téléchargement du guide en Markdown/PDF.
## Le saviez-vous ?
- Le **glisser-déposer** de fichiers depuis l'Explorateur est pris en charge :
déposez-les sur la fenêtre pour les importer.
- Le **clic droit** est partout : menus contextuels de fichier, dossier et
vault (avec renommage, création, suppression, vue graphique).
- Le **watcher** surveille vos vaults : une note modifiée dans Obsidian
apparaît immédiatement ici.
— *Ce fichier vous appartient.*
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.49.2",
"version": "2.53.3",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
@@ -13,6 +13,7 @@
"withGlobalTauri": true,
"windows": [
{
"create": false,
"title": "ObsiGate",
"width": 1200,
"height": 800,
+3
View File
@@ -235,6 +235,9 @@ Gestion :
| `/api/conflicts` · `/api/conflicts/resolve` | Conflits Syncthing | GET/POST |
| `/api/plugins` | Installer / activer / désactiver | GET/POST/DELETE |
| `/api/push/*` | Abonnement Web Push (VAPID) | GET/POST/DELETE |
| `/api/duplicates` · `/api/duplicates/merge` | Doublons : paires candidates, fusion (`confirm: true`) | GET/POST |
| `/api/notify/channels` · `/api/notify/test` | Notifications Discord/Telegram/SMTP/webhook (CRUD admin + test) | GET/POST/PATCH/DELETE |
| `/api/scheduler/tasks` · `/api/scheduler/tasks/{id}/run` | Tâches planifiées (CRUD + exécution manuelle) | GET/POST/PATCH/DELETE |
---
+13
View File
@@ -182,10 +182,23 @@ partagée par l'assistant in-app et le serveur MCP.
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
| Web / sources connectées | `web_search`, `fetch_url`, sources Gitea/GitHub… |
| Doublons (#166) | `find_duplicates` (lecture), `merge_duplicate_notes` (destructif) |
| Notifications (#168) | `notify_external` (Discord, Telegram, SMTP, webhook) |
| Tâches planifiées (#170) | `create/list/delete/run_scheduled_task*` |
Les mutations suivent un flux **two-step** : `propose_<tool>` renvoie un aperçu
et un **jeton signé à usage unique**, puis `apply_<tool>` exécute.
Exemples de demandes à l'assistant (mode agent) :
- « Trouve les notes en double dans ce vault » → `find_duplicates`, puis
« fusionne `brouillon.md` dans `rapport.md` » → `merge_duplicate_notes`
(backup automatique, approbation requise).
- « Préviens-moi sur Discord quand la tâche échoue » → `notify_external`
(canaux configurés via `POST /api/notify/channels`, déclencheurs au choix).
- « Crée une note de veille chaque matin à 8h » → `create_scheduled_task`
(`daily_time 08:00`, action `create_file` ou `append_to_file`).
---
## 7. Sécurité
+26
View File
@@ -23,6 +23,32 @@ commande**.
| Icône de barre des tâches (tray) | ❌ | ✅ |
| Auto-update | ❌ | ✅ (vérifie les releases Gitea) |
| Mode hors-ligne | Limité | Complet (backend local) |
| Gestion vaults & dossiers | Fichiers de config serveur | ✅ UI dédiée + menu contextuel |
### Gestion des vaults et dossiers (#159)
Deux surfaces, réservées à l'application desktop :
- **Menu contextuel** : clic droit sur un vault ou un dossier racine dans la
sidebar → « Retirer de l'application » (confirmation, déregistration de la
configuration locale — **aucun fichier n'est supprimé**), puis
redémarrage automatique du backend.
- **Configuration** : section « 🖥️ Vaults & dossiers (Desktop) » listant les
vaults et dossiers chargés, avec boutons « Ajouter un vault » (sélecteur de
dossier natif) et « Ajouter un dossier ».
### Premier lancement (#160)
Au tout premier démarrage (aucun vault configuré), l'application :
1. crée et monte **`%USERPROFILE%\ObsiGate`** comme vault de démarrage
(« ObsiGate ») et ouvre l'interface dessus ;
2. y écrit le document **`Prise en main.md`** — présentation, premières
étapes, raccourcis (`Ctrl+Espace`, `Ctrl+Alt+Espace`, `Ctrl+F`,
`Ctrl+W`/`Ctrl+Tab`) et accès au guide complet (bouton d'aide de l'en-tête).
Le fichier n'est jamais réécrit : modifiez-le ou supprimez-le librement ;
3. ajoute votre dossier personnel comme seconde racine (nommée d'après son
chemin).
---
+2 -1
View File
@@ -253,7 +253,8 @@ ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
contenu.
contenu. Chaque entrée porte une **icône** ; le menu se referme dès qu'il perd
le focus (clic ailleurs, `Échap`) et se parcourt au clavier (`↑`/`↓`).
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
+10
View File
@@ -211,6 +211,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-101* | Raccourcis d'onglets déclarés deux fois : un seul `Ctrl+W` fermait deux onglets (le second fermait celui ré-activé par la fermeture précédente) et `Ctrl+Tab` sautait un onglet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (bloc « Keyboard shortcuts for tabs » dupliqué en fin de fichier) | Ouvrir deux onglets puis `Ctrl+W` une fois | Suppression du second bloc `document.addEventListener("keydown", …)` dupliqué (fin de `ui.js`) — un seul listener reste | Exposé par #158 : le second onglet n'était plus jamais vide auparavant (la 2ᵉ fermeture tombait sur `_activeTabId === null` et devenait sans effet). Vérifié : repro Playwright (`closes: ["fichier", "nav"]` avant → `["fichier"]` après) + suite E2E |
| *BUG-102* | Menu contextuel qui se referme ~10 ms après son ouverture : la reflow des icônes (remplacement `<i>` → `<svg>` par lucide) déclenche un événement `scroll` capturé par `ContextMenuManager` qui referme le menu | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/ui.js` (`ContextMenuManager.init/show`) | Clic droit sur un fichier de la page de navigation (ou de l'arbre) juste après un rendu d'icônes | `show()` mémorise `_shownAt` ; le listener `scroll` (capture) ignore un déclenchement dans les 250 ms suivant l'ouverture | Exposé par #158 A5 (test E2E `nav-tab.spec.js` : menu résolu « hidden » alors que 6 items étaient construits). Vérifié : instrumentation Playwright (display block conservé après le scroll) + suite E2E |
| *BUG-103* | Mobile : le bas du sidebar de navigation est masqué par la barre d'outils du bas (`z-index` 200 < 900) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` (règle `.sidebar` du bloc `@media (max-width: 768px)`) | Vue mobile (≤768 px), sidebar ouvert depuis « Explorateur » | Passé à `z-index: 950` (au-dessus de `.mobile-toolbar`, 900) | Exposé après #158 (retour utilisateur). Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`) |
| *BUG-104* | Desktop : toutes les commandes Tauri invoquées depuis la page backend (`http://127.0.0.1`) rejetées « not allowed by ACL » — le bouton « Choisir mon dossier » du wizard ne fait rien | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | Installeur 2.49.2 : clic sur « Choisir mon dossier » → aucune fenêtre ; console `[desktop] invoke(pick_vault_folder) failed: Command … not allowed by ACL` (capturée via CDP, WebView2 `--remote-debugging-port`) | Manifeste de permissions applicatives créé (`allow-app-commands`, 19 commandes) et référencé par la capability : Tauri v2 ACL gate toute commande invoquée depuis une origine *remote*, y compris celles de l'`invoke_handler` | `cargo test` 25 passed (nouveau garde-fou `test_frontend_invokes_are_acl_allowed`) + vérif live CDP : `get_version`→2.49.2, clic → fenêtre native « Select Folder », parcours complet ajout de vault terminé |
| *BUG-105* | Desktop : crash 2-15 s après l'ouverture de la fenêtre (`APPCRASH c0000374`, heap corruption détectée dans ntdll) — absent de v2.0.0, apparu en 2.49.2 | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/src/jumplist.rs` (`set_link_title`) | Installeur 2.49.2 : fenêtre visible quelques secondes puis fermeture (3/3), Event Log Windows `APPCRASH obsigate-desktop.exe … c0000374`, rien dans stderr | `set_link_title` (#77) construisait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust et le confiait au property store du shell, qui le libère avec l'allocateur Windows → corruption du tas ; fonction supprimée (`SetDescription` fournit déjà le libellé) | Bisect 3 états (jumplist off = stable, titre off = stable, d'origine = crash) ; run 9 min sans crash + sortie propre `EXIT=0`, zéro événement WER (avant : 3 crashes ≤20 s) |
| *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 |
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -318,6 +323,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-02 | BUG-101 | Correction | `frontend/js/ui.js` | **BUG-101 — le bloc « Keyboard shortcuts for tabs » existait en double dans `ui.js`** (fin de fichier) : un seul `Ctrl+W` déclenchait **deux** `close()`, le second refermant l'onglet ré-activé par le premier — observé avec #158 quand un onglet navigation survit à la fermeture du fichier ; `Ctrl+Tab` sautait également un onglet. Bloc dupliqué supprimé (un seul listener). Vérifié : repro Playwright (`close()` appelé 2× puis 1×) et suite E2E. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-102 | Correction | `frontend/js/ui.js` | **BUG-102 — menu contextuel refermé par sa propre ouverture** : la reflow des icônes lucide à l'affichage déclenche un `scroll` capturé qui refermait le menu ~10 ms après son apparition (constaté sur les fichiers de la page de navigation, #158 A5). `show()` mémorise `_shownAt` et le listener `scroll` ignore un déclenchement dans les 250 ms. Vérifié : instrumentation Playwright + suite E2E (`nav-tab.spec.js`). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-103 | Correction | `frontend/style.css` | **BUG-103 — sidebar mobile sous la barre d'outils du bas** : en mobile le `.sidebar` (`z-index: 200`) passait sous `.mobile-toolbar` (`z-index: 900`, 64 px de haut), masquant les dernières entrées de l'arbre ; passé à `950`. Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`). | 🟢 corrigé |
| 2026-10-02 | BUG-104 | Correction | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | **BUG-104 — commandes Tauri rejetées par l'ACL depuis la page backend** : la fenêtre redirige vers `http://127.0.0.1:<port>`, origine *remote* pour Tauri v2, et aucune commande applicative n'était déclarée → `Command … not allowed by ACL` sur `pick_vault_folder`, `get_wizard_state`, etc. ; les erreurs sont avalées par `desktop.js:invoke()` → bouton du wizard silencieusement inopérant depuis #77. Correctif : manifeste `desktop/permissions/commands.toml` (`allow-app-commands`, 19 commandes) référencé par la capability + `rerun-if-changed=permissions` dans `build.rs`. Vérifié : `cargo test` 25 passed (nouveau garde-fou ACL), CDP live : `get_version` OK, clic → fenêtre native « Select Folder », ajout de vault complété de bout en bout. | 🟢 corrigé |
| 2026-10-02 | BUG-105 | Correction | `desktop/src/jumplist.rs` | **BUG-105 — crash heap (c0000374) à l'ouverture du desktop** : `set_link_title` (#77) passait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust au property store du shell, qui le libère avec l'allocateur Windows → `STATUS_HEAP_CORRUPTION` détectée dans ntdll ≤20 s après le lancement (3/3, Event Log WER, même empreinte de pile). Fonction supprimée : le libellé des entrées vient de `SetDescription`. Vérifié : bisect sur 3 états de build, run 9 min `EXIT=0`, zéro événement WER. | 🟢 corrigé |
| 2026-10-02 | BUG-106 | Correction | `backend/main.py`, `tests/test_csp_nonce.py` | **BUG-106 — page blanche sur /docs** : la CSP `#87 T5c` (`script-src` sans `unsafe-inline`, nonce frais par réponse) s'applique aussi aux pages générées par FastAPI (`/docs`, `/redoc`) dont le balisage n'est jamais passé par `inject_csp_nonce` → le navigateur refusait le script inline `SwaggerUIBundle(…)`, la div restait vide. `SecurityHeadersMiddleware` injecte désormais le nonce dans ce HTML avec le helper existant ; le corps est décompressé/recompressé car `SSESafeGZipMiddleware` est plus proche de la route (les réponses arrivent gunzippées). `/redoc` n'a aucun script inline (bundle jsdelivr seul, couvert par `script-src`) — vérifié tel quel. Vérifié : `test_docs_inline_script_nonced`, `test_redoc_served_with_csp`, `test_docs_external_bundle_not_nonced` + 45 passed sur les 3 suites sécurité, ruff/mypy 0. | 🟢 corrigé |
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
| 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé |
---
+221 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.49.2 | **Dernière mise à jour :** 2026-10-02
> **Version :** 2.53.3 | **Dernière mise à jour :** 2026-10-07
> **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)**
@@ -40,6 +40,26 @@
- [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
### 159. Gestion desktop des vaults & dossiers — retrait par menu contextuel + ajout en configuration
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** commandes Rust existantes (`add/remove/list_vaults|dirs`) + `ContextMenuManager`
- **Statut :** ✅ livré le 2026-10-02 (v2.50.0) — ouvert le 2026-10-02 (prérequis : BUG-104/BUG-105 livrés)
- **Reste à faire :**
- [x] Retrait d'un vault **ou dossier racine** par menu contextuel (entrée « Retirer de l'application », desktop uniquement, confirmation, déregistration seule)
- [x] Section Configuration : liste des vaults/dossiers desktop + ajout (bouton vault existant `pickAndAddVault` + nouveau sélecteur `pick_folder` sans effet de bord pour les dossiers)
- [x] Rafraîchissement de la jump list après chaque ajout/retrait
- [x] i18n FR/EN, tests (garde-fou ACL + unit), E2E, fiche features, release
### 160. Premier lancement professionnel + section Configuration harmonisée
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** config Rust + CSS classes existantes
- **Statut :** ✅ livré le 2026-10-02 (v2.51.0) — ouvert le 2026-10-02
- **Reste à faire :**
- [x] Premier lancement : monter `%USERPROFILE%\ObsiGate` comme vault par défaut (remplace `voute_obsidian`), `vault_path` dessus, nom de dossier racine home dérivé du chemin (fin du hardcode « bruno »)
- [x] Document `Prise en main.md` (contenu embarqué `include_str!`) écrit dans ce dossier si absent — jamais d'écrasement
- [x] Refonte visuelle de la section « 🖥️ Vaults & dossiers (Desktop) » : classes CSS (motif `webauthn-key-item`), boutons `.config-btn-sm`, zéro style inline, override mobile 44px
- [x] Tests Rust (config par défaut + doc embarqué), suites frontend, E2E, docs, release
---
## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2)
@@ -137,6 +157,57 @@
---
## 🔵 En cours — Polish menus tableur
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
- **Effort :** 1-2 jours | **Impact :** 🟡
- **Statut :** 🔵 en cours depuis 2026-10-04
- **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md)
- **Description :** la grille (#155), les menus Structure/Mise en forme/Export
(#156-A8/A11/A14) et les boutons du ruban (#154) sont textuels et restent
ouverts quand le focus quitte le menu. Ajouter des icônes Lucide
représentatives, fermer tout menu à la perte de focus (clic extérieur,
`focusout`, `Échap`, défilement), naviguer au clavier dans le menu grille, et
garantir des cibles tactiles ≥ 44 px + menus dans le viewport en mobile.
- **Sous-tâches :**
- [x] **A1** Icônes des entrées du menu contextuel de la grille (+ `item.icon`)
- [x] **A2** Icônes des menus Structure / Mise en forme / Export + boutons
Enregistrer / « + » onglet
- [x] **A3** Fermeture à la perte de focus (helper partageable : extérieur,
`focusout`, `Échap`, scroll/resize) + navigation clavier du menu grille
- [x] **A4** Polish mobile (44 px tactile, menus bornés au viewport) + tests
JSDOM + E2E `xlsx-viewer.spec.js`
---
## ✅ Terminé — Assistant IA
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
- **Effort :** 0,5 jour | **Impact :** 🟢
- **Statut :** ✅ **livré le 2026-10-06** — ouvert le 2026-10-06
- **Description :** le bouton « mode agent » du panneau (toggle persisté en
localStorage, défaut OFF) n'était plus protecteur : Deep Research et les
quick actions `agent: true` basculaient déjà le mode en silence. Retiré au
profit d'un agent toujours actif : toute requête texte part sur
`/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les mutations
gardent la confirmation two-step) ; les images seules restent sur
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant textuelle).
- **Sous-tâches :**
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
- [x] **A2** Optimisations requêtes AI : `/agent` résout le provider comme
`/chat` (avant : `req.provider` brut transmis au provider adapter →
moteur réel ≠ étiquette SSE) ; schémas d'outils mis en cache (~27 ms de
pydantic économisés par requête agent/MCP)
- [x] **A3** Tests : `ai.test.mjs` adapté (100/100) ; non-régression pytest
(`test_bookslm.py` provider résolu, `test_tools.py` cache sûr) ;
ruff/mypy 0 erreur ; build + instance Docker locale à jour
---
## ✅ Terminé — Navigation
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
@@ -226,6 +297,12 @@
- **Effort :** 6-8 jours | **Impact :** 🟢
- **Décision 2026-09-26 : reporté (P4)** — axe prioritaire = dette & sécurité (#85/#87) ; #73 hors chemin critique. Si réactivé : partir d'un MVP export/hash/LWW adossé à #59 (PWA offline) + #62 (collab Yjs/CRDT) plutôt qu'un protocole parallèle.
- **Périmètre élargi le 2026-10-04 (liste v1.2 Ph.3) :** la **synchronisation des favoris**
(répertoires compris, cf. #161) et la **synchronisation des préférences utilisateur** (thèmes
#65/#174, langue, historique de recherche et de modifications, recherches sauvegardées #162) sont
**fusionnées dans cet item** plutôt que portées par des items séparés — même API de sync, même
résolution de conflits. Store local actuel : JSON par utilisateur dans `data/` (pas de table
`user_preferences` distincte ; un addendum de schéma suffirait si #73 est réactivé).
- **Description :** Synchronisation des vaults entre plusieurs instances d'ObsiGate via un protocole de synchronisation décentralisé ou compatible Obsidian Sync. Alternative self-hosted à Obsidian Sync.
- **Sous-tâches :**
- [ ] Protocole : évaluation CRDT vs OT vs diff/patch pour fichiers markdown
@@ -236,6 +313,9 @@
- [ ] Conflits : UI de résolution manuelle (diff côte à côte entre version locale et distante)
- [ ] Chiffrement : optionnel, chiffrement AES-256-GCM avant transmission
- [ ] Pairing : échange de clé publique + code QR pour appairage des appareils
- [ ] Périmètre v1.2 : sync des **favoris** (#161) et des **préférences** (thème, langue,
historique, recherches sauvegardées) avec résolution de conflits (priorité à la dernière
modification) + indicateur « préférences synchronisées »
---
@@ -259,6 +339,130 @@
---
## ⚪ Backlog — Améliorations produit & évolutions agent IA (liste v1.2) — P1/P2
> **Ouvert le 2026-10-04** à partir de la liste « Roadmap ObsiGate – Développement des
> Fonctionnalités » v1.2 (phases 1-3 + suggestions agent IA numérotées 161-180 dans la liste
> source). Évaluation croisée avec le code réel (2026-10-04) : **7 propositions sont déjà
> livrées** et n'ouvrent donc **pas** d'ID ; seul le périmètre manquant reçoit un ID ObsiGate
> stable `#161` → `#178`. Les numéros de la liste source ne sont **pas** repris tels quels
> (règle du dépôt : un ID n'est jamais attribué à du déjà-fait ni réutilisé).
> Chaque item doit passer par [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md) (DoD) à son ouverture.
### Table de correspondance (liste v1.2 → ObsiGate)
| Proposition v1.2 | Verdict 2026-10-04 (vérifié dans le code) | ID ObsiGate |
|---|---|---|
| Ph.1 — Sauvegarde des recherches fréquentes | Sauvegarde **déjà livrée** (`backend/saved_searches.py`, store `data/{user}_saved_searches.json`, bouton « Sauver » #158) ; fréquence & rappels manquants | ⚪ #162 |
| Ph.1 — Liens de répertoires aux favoris | Marque-page existant sur **fichiers** uniquement (dashboard) ; répertoires absents | ⚪ #161 |
| Ph.1 — Recherche dans la page | **Déjà livrée** en vue lecture (`FindInPageManager`, Ctrl+F + navigation + regex, tableur via #153-A13) ; reste l'intégration **Forge** | ⚪ #163 |
| Ph.2 — Auto-complétion intelligente | **Déjà livrée** (`frontend/js/autocomplete.js` : Mermaid, code fence, frontmatter, wikilinks, table — partagé Forge/CodeMirror) | ✅ |
| Ph.2 — Suggestions contextuelles (historique & préférences) | Non livré | ⚪ #164 |
| Ph.3 — Synchronisation favoris + préférences multiplateforme | Fusionné dans le périmètre de **#73** (reporté P4, décision 2026-09-26) | ⚪ #73 |
| 161 — Synchronisation Git automatique des vaults | Non livré (les outils `git_*` #92 ciblent Gitea/GitHub pour l'IA, pas la sync de vault) | ⚪ #165 |
| 162 — Génération de résumés automatiques | **Déjà livrée** (`POST /api/ai/summarize`, actions instantanées #106, skills `backend/skills.py`) | ✅ |
| 163 — Détection & fusion de doublons | Non livré (brique embeddings/TF-IDF #70 disponible) | ⚪ #166 |
| 164 — Traduction automatique | **Déjà livrée** (toolbar IA « Traduire », #106) | ✅ |
| 165 — Analyse de ton/style | **Déjà livrée** (toolbar IA « Ton », #106) | ✅ |
| 166 — APIs tierces (Google Drive, Notion) | Partiel : sources connectées **Gitea/GitHub** livrées (#103, `backend/tools/connected.py`) ; Drive/Notion manquants | ⚪ #167 |
| 167 — Notifications externes (Slack, Discord, Email) | Partiel : webhooks #9 + Push API #67 livrés ; canaux Slack/Discord/SMTP et choix des déclencheurs à câbler | ⚪ #168 |
| 168 — Édition collaborative temps réel | **Déjà livrée** (#62 — Yjs/CRDT, WebSocket, awareness, v2.3.0) | ✅ |
| 169 — Scripts personnalisés sandboxés | **Déjà livrée** (#61 — plugins en sandbox Web Worker, hooks, 9 endpoints, v2.2.0) | ✅ |
| 170 — Planification de tâches automatiques (cron) | Non livré | ⚪ #170 |
| 171 — Audit des accès | Partiel : `backend/audit.py` + `/api/admin/audit` + dashboard admin #71 existent ; historique **par fichier**, filtres avancés et export CSV manquants | ⚪ #171 |
| 172 — Chiffrement/déchiffrement de fichiers | Non livré (AES-GCM interne réservé aux tokens) | ⚪ #172 |
| 173 — Vues personnalisées (Kanban, Calendrier) | Non livré | ⚪ #173 |
| 174 — Optimisation mobile | Base **largement livrée** (#69 éditeur mobile, #83 ruban mobile, #114 config responsive 44 px) ; reste un audit du solde | ⚪ #178 |
| 175 — Chat intégré | Non livré (transport WebSocket/rooms de #62 réutilisable) | ⚪ #169 |
| 176 — Thèmes dynamiques (règles heure/date) | Base livrée (#65 thèmes + import/export) ; règles automatiques manquantes | ⚪ #174 |
| 177 — Export formats propriétaires (OneNote, Evernote) | Base livrée (#66 HTML/MD bundle/ePub + PDF #74) ; formats tiers manquants | ⚪ #175 |
| 178 — Webhooks pour événements | **Déjà livrée** (#9 — `backend/webhooks.py`, événements + secrets + validation d'URL SSRF-safe) | ✅ |
| 179 — Gestion fine des permissions | Non livré (ACLs par vault/dossier racine seulement) | ⚪ #176 |
| 180 — Historique des révisions visuel | Partiel : gestion des backups **avec diff** livrée (#40-46, outil IA `diff_backup`) ; UI côte à côte manquante | ⚪ #177 |
---
### 161. Favoris — répertoires et liens de vault (menu contextuel)
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
- **Description :** le marque-page actuel ne couvre que les fichiers ; ajouter les **répertoires**
aux favoris (proposition « liens de répertoires », liste v1.2 Ph.1).
- **Sous-tâches :**
- [ ] Étendre le modèle de favoris aux répertoires (vérification d'accès backend via
`_resolve_safe_path()`, permissions)
- [ ] Entrée « Ajouter aux favoris » dans le menu contextuel de l'arbre **et** de la page de
navigation (#158-A5)
- [ ] Section « Favoris » dans la sidebar (répertoires + fichiers)
- [ ] i18n FR/EN, tests (unit + E2E si UI), fiche `docs/features/`
### 162. Recherches fréquentes — fréquence d'utilisation & rappels
- **Effort :** 1-2 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
- **Cadrage :** la **sauvegarde** des recherches existe déjà (`backend/saved_searches.py`, bouton
« Sauver » de #158). Cet item n'ouvre que l'incrément manquant de la liste v1.2 Ph.1.
- **Sous-tâches :**
- [ ] Compteur d'utilisation par recherche sauvegardée (horodatage + fréquence)
- [ ] Section dédiée « Recherches fréquentes » (page de résultats + sidebar) et épinglage
- [ ] Rappel optionnel (« Vous avez souvent cherché X ») via notifications #67, interruptible
en Configuration
- [ ] i18n FR/EN + tests
### 163. Recherche dans la page — éditeur Forge
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
- **Cadrage :** `FindInPageManager` (Ctrl+F, surbrillance, navigation ↑/↓, casse / mot entier /
regex) est livré pour la vue lecture ; le tableur a sa propre recherche (#153-A13). Reste à
raccorder la recherche à l'éditeur **Forge** et à l'exposer en commande `/`.
- **Sous-tâches :**
- [ ] Raccorder `FindInPageManager` à Forge (textarea **et** CodeMirror) : surbrillance dans le
contenu édité, navigation avec boucle, conservation du curseur
- [ ] Commande `/recherche-dans-page` (mécanisme de commandes #81)
- [ ] Non-régression des raccourcis existants (Ctrl+F vs recherche globale, Échap) ; i18n ;
tests JSDOM + E2E
### 164. Suggestions contextuelles dans l'éditeur (historique & préférences)
- **Effort :** 3-5 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
- **Cadrage :** l'auto-complétion contextuelle (Mermaid, code, Markdown, wikilinks, frontmatter)
est **déjà livrée** (`frontend/js/autocomplete.js`) — la proposition v1.2 Ph.2
« Auto-complétion intelligente » est couverte. Cet item n'ouvre que les suggestions apprises
du contexte utilisateur.
- **Sous-tâches :**
- [ ] Mesure locale des snippets les plus utilisés par l'utilisateur (fréquence, sans cloud)
- [ ] Suggestions basées sur l'historique de modification (termes récurrents, patterns du type
« souvent inséré après … ») — option locale, IA légère si disponible
- [ ] Bascule on/off en Configuration ; intégration à la complétion existante
- [ ] i18n FR/EN + tests
---
### ⚪ Évolutions agent IA (liste v1.2 « Phase 4 », IDs ObsiGate #165 → #178)
| ID | Fonctionnalité | Effort | Impact | Dépendances / socle à étendre |
|---|---|---|---|---|
| 165 | Synchronisation automatique des vaults avec Git (add/commit/push/pull, résolution de conflits, auth SSH/token, option planification X heures) | 4-6 j | 🟡 | subprocess/`gitpython` ; UI dans Configuration (#160) ; distinct des outils `git_*` #92 (IA, Gitea/GitHub) |
| 166 | Détection & fusion de doublons (score de similarité, liste des paires potentielles, outil de fusion) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md)) | 3-4 j | 🟢 | embeddings/TF-IDF #70 ; **jamais de fusion sans confirmation explicite** (+ backup préalable) |
| 167 | Connexions tierces — Google Drive & Notion (import/export, OAuth2, respect des quotas) | 5-7 j | 🟡 | page « sources connectées » #103 ; rate-limit existant |
| 168 | Notifications externes — canaux Slack/Discord (webhooks) et Email (SMTP) + choix des déclencheurs — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; périmètre : Discord, Telegram, SMTP, webhook générique — Slack via webhook générique) | 3-4 j | 🟢 | `backend/webhooks.py` #9 ; Push API #67 |
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) | 3-4 j | 🟢 | transport WebSocket/rooms de la collab #62 |
| 170 | Planification de tâches automatiques (type cron ; actions = outils existants `create_file`/`append_to_file` ; notifications d'échec) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; tick asyncio 60 s, API + outils, sans UI dédiée) | 3-4 j | 🟢 | scheduler backend (APScheduler ou asyncio) ; UI de création de tâches |
| 171 | Audit des accès — extension : historique lecture/édition par fichier, filtres (utilisateur/fichier/date), export CSV | 2-3 j | 🟡 | `backend/audit.py` + `/api/admin/audit` (#71) |
| 172 | Chiffrement/déchiffrement de fichiers sensibles (AES-256-GCM, gestion de clés, confirmation) | 3-4 j | 🟡 | **backup automatique préalable** ; clés hors dépôt (jamais de secret committé) |
| 173 | Vues personnalisées Kanban & Calendrier (glisser-déposer, colonnes/date par tags & métadonnées) | 5-6 j | 🟢 | facettes/tags #157-#158 ; store JSON par utilisateur (pattern `data/*.json`) |
| 174 | Thèmes dynamiques (règles heure/date, ex. mode sombre 18h-8h, règles personnalisables) | 2-3 j | 🟢 | thèmes #65 ; préférence persistée (candidate à la sync #73) |
| 175 | Export vers formats propriétaires (OneNote, Evernote) avec conservation des métadonnées (tags, liens, images) | 3-4 j | 🟢 | `export.py` #66 (HTML/MD bundle/ePub) + PDF #74 |
| 176 | Permissions fines par fichier/dossier (utilisateur/groupe + actions, héritage, journal des changements) | 4-5 j | 🟡 | ACLs vaults/dossiers existantes ; passerelle obligatoire par `_resolve_safe_path()` |
| 177 | Historique des révisions visuel (diff côte à côte, surlignage ajouts/suppressions, navigation Précédent/Suivant) | 3-4 j | 🟢 | backups + diff #40-46 (`diff_backup`) ; `difflib` backend |
| 178 | Optimisation mobile — solde (audit cibles tactiles ≥ 44 px, lazy-loading images/médias, raccourcis clavier virtuel) | 4-5 j | 🟡 | base livrée #69/#83/#114 ; lazy-loading déjà partiel (#153-A9) |
- **Critères d'acceptation transverses (liste v1.2 harmonisée avec la DoD du dépôt) :** test
desktop **et** mobile ; documentation utilisateur FR/EN dans `docs/GUIDES/` (l'appel
« Guide_MCP_ObsiGate.md » de la liste source est ramené à la cartographie documentaire du
dépôt) ; **backup automatique avant toute modification destructive** ; i18n FR/EN systématique ;
passage de l'ID à « en cours » avant codage (AGENTS.md, « Avant de commencer »).
---
## ✅ Complété — index
> Détail complet dans [docs/archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) et
@@ -271,6 +475,8 @@
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
| 156 | Tableur — Complétude éditeur : presse-papiers/clavier (A5-A7), mise en forme (A8), décision calcul (A9), undo unifié (A10), export + recherche multi-feuilles (A11), concurrence optimiste (A12), cache méta (A13), outils IA `.xlsm`/`.csv` (A14) + BUG-096 → BUG-099 | 2.45.0 | [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md) |
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
@@ -340,6 +546,9 @@
| 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) |
| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) + panneau repliable | 2.46.0→2.47.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 158 | Navigation — clic répertoire → **onglet de navigation** (sous-répertoires, facettes Vaults/Tags/Extensions, tri Pertinence/Date, Sauver), **onglet Accueil**, menus contextuels + retour Home complet (**BUG-100** → **BUG-102**) | 2.48.0→2.49.0 | [features/navigation-tab-158.md](./features/navigation-tab-158.md) |
| 166 | Assistant IA — détection & fusion de doublons (score déterministe, paires candidates, fusion backup + confirmation) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| 168 | Assistant IA — notifications externes Discord / Telegram / SMTP / webhook (déclencheurs, secrets hors config, SSRF-safe) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| 170 | Assistant IA — tâches planifiées type cron (create/append/notify, interval/daily/once, tick 60 s, échec notifié) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
---
@@ -349,7 +558,9 @@
|---|---|---|
| ✅ 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é |
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique ; périmètre élargi 2026-10-04 (favoris #161 + préférences) | 6-8 jours si réactivé |
| ⚪ P1/P2 nouvelles (liste v1.2) | #161 favoris répertoires · #162 recherches fréquentes · #163 recherche Forge · #164 suggestions contextuelles | ~5-9 jours |
| ⚪ Évolutions agent IA (#165 → #178) | Git sync, doublons, Drive/Notion, notifications, chat, cron, audit, chiffrement, Kanban, thèmes dynamiques, OneNote/Evernote, permissions, diff visuel, mobile | ~45-63 jours si tout activé (à prioriser par tranches) |
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
@@ -368,6 +579,14 @@
- **Clôture 2026-09-30 :** #156 **livré (P0 → P3)** — **A8** mise en forme en écriture (bouton **Mise en forme** : gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — via `PUT …/xlsx/style`), **A9** décision « pas de moteur de formule, annoncée dans l'UI », **A10** undo/redo unifié conservé au re-rendu, **A11** export de la sélection / Markdown / HTML / impression + recherche sur toutes les feuilles, **A12** concurrence optimiste (`If-Match`, **409** réparable), **A13** cache des métadonnées par `mtime`, **A14** outils IA `.xlsm`/`.csv` + `search_workbook`/`analyze_range`/`edit_xlsx_structure` — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
- **Ajout 2026-09-29 :** #156 **P1 livré** — **A5** presse-papiers de plage (copier/couper/coller un bloc, presse-papiers interne + système, entrées du menu contextuel, remplissage multi-cellules), **A6** clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) et **A7** zone Nom éditable + aide à la saisie ; restent P2 (mise en forme, calcul, undo unifié) et P3 (sortie, concurrence optimiste, performances, outils IA).
- **Ajout 2026-09-29 :** #156 ouvert — audit de complétude de l'éditeur Excel : **4 défauts recensés** (BUG-096 enregistrement `.csv`, BUG-097 lazy-load `.xlsm`, BUG-098 délimiteur CSV, BUG-099 sonde de perte), **corrigés le jour même (P0 ✅)** avec tests de non-régression ; restent P1-P3 (presse-papiers, clavier, mise en forme, calcul, export, concurrence optimiste) puis presse-papiers de plage, clavier complet, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
- **Ajout 2026-10-04 :** intégration de la liste « Roadmap – Développement des Fonctionnalités »
v1.2 (phases 1-3 + suggestions agent IA). Évaluation croisée avec le code : **7 propositions déjà
livrées** (auto-complétion `autocomplete.js`, résumés/traduction/ton #106, édition collaborative
#62, scripts/plugins #61, webhooks #9, recherche dans la page en vue lecture) → pas d'ID ;
**périmètre manquant** ouvert en backlog **#161 → #178** avec **table de correspondance** en tête
de la nouvelle section ; sync favoris/préférences **fusionnée dans #73** (reporté P4). Les numéros
161-180 de la liste source ne sont pas repris tels quels (règle dépôt : ID jamais attribué au
déjà-fait) ; la correspondance figure item par item.
- **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.
+87
View File
@@ -0,0 +1,87 @@
// ObsiGate AI Agent — flux de traitement d'une demande (b89af91)
digraph ObsiGateAgent {
rankdir=TB;
splines=polyline;
nodesep=0.35; ranksep=0.45;
graph [fontname="DejaVu Sans", fontsize=13, bgcolor="white", pad=0.3];
node [fontname="DejaVu Sans", fontsize=12, style=filled, shape=box, color="#333333", margin="0.16,0.09"];
edge [fontname="DejaVu Sans", fontsize=11, color="#555555", arrowsize=0.8];
// ── Entrée ──
user [label="Demande utilisateur\n(frontend bookslm.js)", fillcolor="#dbeafe"];
deep [label="Chip « Deep Research »\n= agent mode + prompt injecté :\n« décomposer les étapes, chercher\ndans le vault, croiser,\nsynthétiser avec sources »",
shape=note, fillcolor="#fef3c7"];
api [label="POST /api/bookslm/agent\n→ réponse SSE (thought · tool ·\nconfirmation · message · done)", fillcolor="#e0f2fe"];
prompt [label="Construction du system prompt\nmode directory / documents / general\ncontexte vault pré-chargé (BooksLM :\n≤200 fichiers, ≤200k chars, cache 5 min)\nprotocole agent = function calling natif", fillcolor="#f1f5f9"];
user -> api; deep -> user [style=dashed, label="active", fontsize=10];
api -> prompt;
// ── Boucle agent ──
subgraph cluster_loop {
label="BOUCLE AGENT — backend/agent/loop.py · run_agent()";
style=rounded; color="#e11d48"; penwidth=2; fontsize=13; fontcolor="#e11d48";
llm [label="Appel LLM — chat_completion()\nfournisseur OpenAI-compatible / Gemini\ntemp=0.3 · max_tokens 8192\nschemas des 50 outils exposés\n(si modèle sans tools → retombe chat simple)", fillcolor="#fee2e2"];
decide [label="Le LLM demande des\noutils (tool_calls) ?", shape=diamond, fillcolor="#fff7ed"];
thought [label="note de raisonnement\nintermédiaire → step visible « pensée »", fillcolor="#fef9c3"];
tools [label="Exécution de chaque tool call\nvia call_tool() (voir pipeline)", fillcolor="#dcfce7"];
back [label="résultat (JSON, tronqué ≤100k chars)\najouté à la conversation →\nitération suivante", shape=plaintext];
llm -> decide;
decide -> thought [label="oui", color="#e11d48"];
thought -> tools;
tools -> llm [label="itération ≤ 10\nquota tools ≤ 25\n(BOOKSLM_MAX_TOOL_CALLS)", color="#e11d48"];
}
prompt -> llm;
// ── Pipeline call_tool ──
subgraph cluster_pipe {
label="PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là";
style=rounded; color="#7c3aed"; fontsize=13; fontcolor="#7c3aed";
p1 [label="1 · Schéma Pydantic\n+ validation arguments", fillcolor="#ede9fe"];
p2 [label="2 · Rate limit\npar identité + outil", fillcolor="#ede9fe"];
p3 [label="3 · Permission vault\n(+ déstructif si risque\nDANGEROUS)", fillcolor="#ede9fe"];
p4 [label="4 · Risque READ ?\n→ exécution directe", shape=diamond, fillcolor="#f5f3ff"];
p5 [label="PAUSE CONFIRMATION\noutil WRITE/DANGEROUS → event SSE\n« confirmation » avec le plan complet\nbatché (BUG-075) : une seule\napprobation applique toutes les mutations\n(resume via confirm / confirm_all)", fillcolor="#fde68a"];
p6 [label="5 · Handler exécuté\n6 · Audit JSON-lines\n7 · Redaction secrets (redact_payload)", fillcolor="#dcfce7"];
p1 -> p2 -> p3 -> p4;
p4 -> p6 [label="read"];
p4 -> p5 [label="mutation", color="#d97706"];
p5 -> p6 [label="approuvé", style=dashed];
}
tools -> p1;
p6 -> back [label="tool result", style=invis];
p6 -> tools [style=invis];
back -> p1 [style=invis];
// ── Sorties ──
done [label="Réponse finale\nréponse sans tool_calls\n→ SSE « message »\n(Markdown + sources citées)", fillcolor="#bbf7d0"];
final [label="Budget épuisé (10 itérations\nou 25 appels) → _finalize_answer :\ndernier appel SANS outils pour forcer\nune synthèse, sinon liste des sources\ndéterministe (jamais de réponse vide)", fillcolor="#fed7aa"];
decide -> done [label="non", color="#059669", penwidth=2];
decide -> final [label="budget atteint", color="#ea580c"];
answer [label="Affichage UI : bloc\n« N étapes » Notion-style\n(pensées + outils dépliables)", fillcolor="#dbeafe"];
done -> answer; final -> answer;
// ── Boîte à outils ──
subgraph cluster_tools {
label="50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS";
style=rounded; color="#0369a1"; fontsize=13; fontcolor="#0369a1";
node [fillcolor="#f0f9ff", fontsize=10];
fam_vault [label="VAULT · service.py (22)\nlecture : list_vaults, list_directory,\nlist_all_files, read_file, read_file_raw,\nsearch_fulltext, search_advanced,\nsearch_paths, get_graph, get_backlinks,\nlist_tags, suggest_tags, list_recent,\nlist_backups, diff_backup\nécriture : create_file, create_directory,\nappend_to_file, edit_file, restore_backup\nDANGEROUS : delete_file, delete_directory,\nmove_path, rename_file, rename_directory,\nreplace_in_files"];
fam_web [label="WEB · web.py + crawler.py\nweb_search · fetch_url\ncrawl_site (≤20 pages, même hôte)"];
fam_doc [label="DOCUMENTS · documents.py\ncreate_pdf · create_docx\ncreate_xlsx · create_csv"];
fam_xlsx [label="TABLEURS · spreadsheets.py\nlist_xlsx_sheets · xlsx_to_markdown\nsearch_workbook · analyze_range\nupdate_xlsx_cells · append_xlsx_rows\nedit_xlsx_structure"];
fam_dup [label="DOUBLONS · duplicates.py\nfind_duplicates\nmerge_duplicate_notes (DANG.)"];
fam_git [label="GIT CONNECTÉ · connected.py\ngit_list_repos · git_get_file\ngit_search_issues (Gitea/GitHub)"];
fam_sched [label="SCHEDULER · scheduled.py\nlist/create/delete/run_scheduled_task"];
fam_notify [label="NOTIFICATIONS · notify.py\nnotify_external (Discord,\nTelegram, SMTP, webhook)"];
}
p6 -> cluster_tools [style=invis];
fam_vault -> fam_web [style=invis]; fam_web -> fam_doc [style=invis];
fam_doc -> fam_xlsx [style=invis]; fam_xlsx -> fam_dup [style=invis];
fam_dup -> fam_git [style=invis]; fam_git -> fam_sched [style=invis];
fam_sched -> fam_notify [style=invis];
}
+394
View File
@@ -0,0 +1,394 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.42.4 (0)
-->
<!-- Title: ObsiGateAgent Pages: 1 -->
<svg width="2130pt" height="1034pt"
viewBox="0.00 0.00 2130.20 1033.70" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(21.6 1012.1)">
<title>ObsiGateAgent</title>
<polygon fill="white" stroke="transparent" points="-21.6,21.6 -21.6,-1012.1 2108.6,-1012.1 2108.6,21.6 -21.6,21.6"/>
<g id="clust1" class="cluster">
<title>cluster_loop</title>
<path fill="white" stroke="#e11d48" stroke-width="2" d="M493,-11.5C493,-11.5 1052,-11.5 1052,-11.5 1058,-11.5 1064,-17.5 1064,-23.5 1064,-23.5 1064,-471 1064,-471 1064,-477 1058,-483 1052,-483 1052,-483 493,-483 493,-483 487,-483 481,-477 481,-471 481,-471 481,-23.5 481,-23.5 481,-17.5 487,-11.5 493,-11.5"/>
<text text-anchor="middle" x="772.5" y="-468.6" font-family="DejaVu Sans" font-size="13.00" fill="#e11d48">BOUCLE AGENT — backend/agent/loop.py · run_agent()</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_pipe</title>
<path fill="white" stroke="#7c3aed" d="M1084,-102C1084,-102 1580,-102 1580,-102 1586,-102 1592,-108 1592,-114 1592,-114 1592,-761.5 1592,-761.5 1592,-767.5 1586,-773.5 1580,-773.5 1580,-773.5 1084,-773.5 1084,-773.5 1078,-773.5 1072,-767.5 1072,-761.5 1072,-761.5 1072,-114 1072,-114 1072,-108 1078,-102 1084,-102"/>
<text text-anchor="middle" x="1332" y="-759.1" font-family="DejaVu Sans" font-size="13.00" fill="#7c3aed">PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_tools</title>
<path fill="white" stroke="#0369a1" d="M1612,-8C1612,-8 2067,-8 2067,-8 2073,-8 2079,-14 2079,-20 2079,-20 2079,-970.5 2079,-970.5 2079,-976.5 2073,-982.5 2067,-982.5 2067,-982.5 1612,-982.5 1612,-982.5 1606,-982.5 1600,-976.5 1600,-970.5 1600,-970.5 1600,-20 1600,-20 1600,-14 1606,-8 1612,-8"/>
<text text-anchor="middle" x="1839.5" y="-968.1" font-family="DejaVu Sans" font-size="13.00" fill="#0369a1">50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS</text>
</g>
<!-- user -->
<g id="node1" class="node">
<title>user</title>
<polygon fill="#dbeafe" stroke="#333333" points="712.5,-743.5 561.5,-743.5 561.5,-705.5 712.5,-705.5 712.5,-743.5"/>
<text text-anchor="middle" x="637" y="-727.9" font-family="DejaVu Sans" font-size="12.00">Demande utilisateur</text>
<text text-anchor="middle" x="637" y="-714.9" font-family="DejaVu Sans" font-size="12.00">(frontend bookslm.js)</text>
</g>
<!-- api -->
<g id="node3" class="node">
<title>api</title>
<polygon fill="#e0f2fe" stroke="#333333" points="742.5,-669 531.5,-669 531.5,-618 742.5,-618 742.5,-669"/>
<text text-anchor="middle" x="637" y="-653.4" font-family="DejaVu Sans" font-size="12.00">POST /api/bookslm/agent</text>
<text text-anchor="middle" x="637" y="-640.4" font-family="DejaVu Sans" font-size="12.00">→ réponse SSE (thought · tool ·</text>
<text text-anchor="middle" x="637" y="-627.4" font-family="DejaVu Sans" font-size="12.00">confirmation · message · done)</text>
</g>
<!-- user&#45;&gt;api -->
<g id="edge1" class="edge">
<title>user&#45;&gt;api</title>
<path fill="none" stroke="#555555" d="M637,-705.18C637,-696.86 637,-686.76 637,-677.2"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-677.01 637,-669.01 634.2,-677.01 639.8,-677.01"/>
</g>
<!-- deep -->
<g id="node2" class="node">
<title>deep</title>
<polygon fill="#fef3c7" stroke="#333333" points="749,-919 519,-919 519,-842 755,-842 755,-913 749,-919"/>
<polyline fill="none" stroke="#333333" points="749,-919 749,-913 "/>
<polyline fill="none" stroke="#333333" points="755,-913 749,-913 "/>
<text text-anchor="middle" x="637" y="-903.4" font-family="DejaVu Sans" font-size="12.00">Chip « Deep Research »</text>
<text text-anchor="middle" x="637" y="-890.4" font-family="DejaVu Sans" font-size="12.00">= agent mode + prompt injecté :</text>
<text text-anchor="middle" x="637" y="-877.4" font-family="DejaVu Sans" font-size="12.00">« décomposer les étapes, chercher</text>
<text text-anchor="middle" x="637" y="-864.4" font-family="DejaVu Sans" font-size="12.00">dans le vault, croiser,</text>
<text text-anchor="middle" x="637" y="-851.4" font-family="DejaVu Sans" font-size="12.00">synthétiser avec sources »</text>
</g>
<!-- deep&#45;&gt;user -->
<g id="edge2" class="edge">
<title>deep&#45;&gt;user</title>
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M637,-841.83C637,-813.95 637,-776.67 637,-751.91"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-751.71 637,-743.71 634.2,-751.71 639.8,-751.71"/>
<text text-anchor="middle" x="652.5" y="-784.5" font-family="DejaVu Sans" font-size="10.00">active</text>
</g>
<!-- prompt -->
<g id="node4" class="node">
<title>prompt</title>
<polygon fill="#f1f5f9" stroke="#333333" points="773.5,-585 500.5,-585 500.5,-508 773.5,-508 773.5,-585"/>
<text text-anchor="middle" x="637" y="-569.4" font-family="DejaVu Sans" font-size="12.00">Construction du system prompt</text>
<text text-anchor="middle" x="637" y="-556.4" font-family="DejaVu Sans" font-size="12.00">mode directory / documents / general</text>
<text text-anchor="middle" x="637" y="-543.4" font-family="DejaVu Sans" font-size="12.00">contexte vault pré&#45;chargé (BooksLM :</text>
<text text-anchor="middle" x="637" y="-530.4" font-family="DejaVu Sans" font-size="12.00">≤200 fichiers, ≤200k chars, cache 5 min)</text>
<text text-anchor="middle" x="637" y="-517.4" font-family="DejaVu Sans" font-size="12.00">protocole agent = function calling natif</text>
</g>
<!-- api&#45;&gt;prompt -->
<g id="edge3" class="edge">
<title>api&#45;&gt;prompt</title>
<path fill="none" stroke="#555555" d="M637,-617.54C637,-610.12 637,-601.74 637,-593.43"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-593.17 637,-585.17 634.2,-593.17 639.8,-593.17"/>
</g>
<!-- llm -->
<g id="node5" class="node">
<title>llm</title>
<polygon fill="#fee2e2" stroke="#333333" points="784.5,-453 489.5,-453 489.5,-376 784.5,-376 784.5,-453"/>
<text text-anchor="middle" x="637" y="-437.4" font-family="DejaVu Sans" font-size="12.00">Appel LLM — chat_completion()</text>
<text text-anchor="middle" x="637" y="-424.4" font-family="DejaVu Sans" font-size="12.00">fournisseur OpenAI&#45;compatible / Gemini</text>
<text text-anchor="middle" x="637" y="-411.4" font-family="DejaVu Sans" font-size="12.00">temp=0.3 · max_tokens 8192</text>
<text text-anchor="middle" x="637" y="-398.4" font-family="DejaVu Sans" font-size="12.00">schemas des 50 outils exposés</text>
<text text-anchor="middle" x="637" y="-385.4" font-family="DejaVu Sans" font-size="12.00">(si modèle sans tools → retombe chat simple)</text>
</g>
<!-- prompt&#45;&gt;llm -->
<g id="edge8" class="edge">
<title>prompt&#45;&gt;llm</title>
<path fill="none" stroke="#555555" d="M637,-507.9C637,-493.35 637,-476.55 637,-461.3"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-461.02 637,-453.02 634.2,-461.02 639.8,-461.02"/>
</g>
<!-- decide -->
<g id="node6" class="node">
<title>decide</title>
<polygon fill="#fff7ed" stroke="#333333" points="641,-325 489,-287 641,-249 793,-287 641,-325"/>
<text text-anchor="middle" x="641" y="-290.4" font-family="DejaVu Sans" font-size="12.00">Le LLM demande des</text>
<text text-anchor="middle" x="641" y="-277.4" font-family="DejaVu Sans" font-size="12.00">outils (tool_calls) ?</text>
</g>
<!-- llm&#45;&gt;decide -->
<g id="edge4" class="edge">
<title>llm&#45;&gt;decide</title>
<path fill="none" stroke="#555555" d="M638.2,-375.85C638.63,-362.33 639.12,-346.95 639.57,-332.87"/>
<polygon fill="#555555" stroke="#555555" points="642.37,-332.85 639.83,-324.76 636.77,-332.67 642.37,-332.85"/>
</g>
<!-- thought -->
<g id="node7" class="node">
<title>thought</title>
<polygon fill="#fef9c3" stroke="#333333" points="786.5,-154.5 529.5,-154.5 529.5,-116.5 786.5,-116.5 786.5,-154.5"/>
<text text-anchor="middle" x="658" y="-138.9" font-family="DejaVu Sans" font-size="12.00">note de raisonnement</text>
<text text-anchor="middle" x="658" y="-125.9" font-family="DejaVu Sans" font-size="12.00">intermédiaire → step visible « pensée »</text>
</g>
<!-- decide&#45;&gt;thought -->
<g id="edge5" class="edge">
<title>decide&#45;&gt;thought</title>
<path fill="none" stroke="#e11d48" d="M645.12,-249.81C648.17,-222.91 652.28,-186.83 655.02,-162.68"/>
<polygon fill="#e11d48" stroke="#e11d48" points="657.81,-162.94 655.93,-154.67 652.25,-162.31 657.81,-162.94"/>
<text text-anchor="middle" x="659.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">oui</text>
</g>
<!-- done -->
<g id="node16" class="node">
<title>done</title>
<polygon fill="#bbf7d0" stroke="#333333" points="473,-167.5 275,-167.5 275,-103.5 473,-103.5 473,-167.5"/>
<text text-anchor="middle" x="374" y="-151.9" font-family="DejaVu Sans" font-size="12.00">Réponse finale</text>
<text text-anchor="middle" x="374" y="-138.9" font-family="DejaVu Sans" font-size="12.00">réponse sans tool_calls</text>
<text text-anchor="middle" x="374" y="-125.9" font-family="DejaVu Sans" font-size="12.00">→ SSE « message »</text>
<text text-anchor="middle" x="374" y="-112.9" font-family="DejaVu Sans" font-size="12.00">(Markdown + sources citées)</text>
</g>
<!-- decide&#45;&gt;done -->
<g id="edge19" class="edge">
<title>decide&#45;&gt;done</title>
<path fill="none" stroke="#059669" stroke-width="2" d="M595.1,-260.3C551.33,-235.79 485.07,-198.69 436.49,-171.49"/>
<polygon fill="#059669" stroke="#059669" stroke-width="2" points="437.81,-169.02 429.46,-167.55 435.07,-173.9 437.81,-169.02"/>
<text text-anchor="middle" x="541.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">non</text>
</g>
<!-- final -->
<g id="node17" class="node">
<title>final</title>
<polygon fill="#fed7aa" stroke="#333333" points="250,-174 0,-174 0,-97 250,-97 250,-174"/>
<text text-anchor="middle" x="125" y="-158.4" font-family="DejaVu Sans" font-size="12.00">Budget épuisé (10 itérations</text>
<text text-anchor="middle" x="125" y="-145.4" font-family="DejaVu Sans" font-size="12.00">ou 25 appels) → _finalize_answer :</text>
<text text-anchor="middle" x="125" y="-132.4" font-family="DejaVu Sans" font-size="12.00">dernier appel SANS outils pour forcer</text>
<text text-anchor="middle" x="125" y="-119.4" font-family="DejaVu Sans" font-size="12.00">une synthèse, sinon liste des sources</text>
<text text-anchor="middle" x="125" y="-106.4" font-family="DejaVu Sans" font-size="12.00">déterministe (jamais de réponse vide)</text>
</g>
<!-- decide&#45;&gt;final -->
<g id="edge20" class="edge">
<title>decide&#45;&gt;final</title>
<path fill="none" stroke="#ea580c" d="M565.01,-267.84C491.13,-250.18 390,-226 390,-226 390,-226 314.36,-200.46 243.91,-176.66"/>
<polygon fill="#ea580c" stroke="#ea580c" points="244.71,-173.97 236.23,-174.07 242.92,-179.28 244.71,-173.97"/>
<text text-anchor="middle" x="429.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">budget atteint</text>
</g>
<!-- tools -->
<g id="node8" class="node">
<title>tools</title>
<polygon fill="#dcfce7" stroke="#333333" points="987,-57.5 787,-57.5 787,-19.5 987,-19.5 987,-57.5"/>
<text text-anchor="middle" x="887" y="-41.9" font-family="DejaVu Sans" font-size="12.00">Exécution de chaque tool call</text>
<text text-anchor="middle" x="887" y="-28.9" font-family="DejaVu Sans" font-size="12.00">via call_tool() (voir pipeline)</text>
</g>
<!-- thought&#45;&gt;tools -->
<g id="edge6" class="edge">
<title>thought&#45;&gt;tools</title>
<path fill="none" stroke="#555555" d="M701.61,-116.41C739.89,-100.53 795.6,-77.42 835.95,-60.68"/>
<polygon fill="#555555" stroke="#555555" points="837.23,-63.18 843.55,-57.53 835.09,-58 837.23,-63.18"/>
</g>
<!-- tools&#45;&gt;llm -->
<g id="edge7" class="edge">
<title>tools&#45;&gt;llm</title>
<path fill="none" stroke="#e11d48" d="M881.93,-57.8C864.2,-121.83 806,-332 806,-332 806,-332 764.31,-352.1 722.35,-372.34"/>
<polygon fill="#e11d48" stroke="#e11d48" points="721.06,-369.85 715.07,-375.85 723.49,-374.9 721.06,-369.85"/>
<text text-anchor="middle" x="930" y="-217.2" font-family="DejaVu Sans" font-size="11.00">itération ≤ 10</text>
<text text-anchor="middle" x="930" y="-205.2" font-family="DejaVu Sans" font-size="11.00">quota tools ≤ 25</text>
<text text-anchor="middle" x="930" y="-193.2" font-family="DejaVu Sans" font-size="11.00">(BOOKSLM_MAX_TOOL_CALLS)</text>
</g>
<!-- p1 -->
<g id="node10" class="node">
<title>p1</title>
<polygon fill="#ede9fe" stroke="#333333" points="1244,-743.5 1080,-743.5 1080,-705.5 1244,-705.5 1244,-743.5"/>
<text text-anchor="middle" x="1162" y="-727.9" font-family="DejaVu Sans" font-size="12.00">1 · Schéma Pydantic</text>
<text text-anchor="middle" x="1162" y="-714.9" font-family="DejaVu Sans" font-size="12.00">+ validation arguments</text>
</g>
<!-- tools&#45;&gt;p1 -->
<g id="edge15" class="edge">
<title>tools&#45;&gt;p1</title>
<path fill="none" stroke="#555555" d="M927.18,-57.59C988.4,-85.16 1098,-134.5 1098,-134.5 1098,-644.5 1098,-644.5 1098,-644.5 1098,-644.5 1123.27,-675.7 1142.06,-698.88"/>
<polygon fill="#555555" stroke="#555555" points="1139.94,-700.72 1147.16,-705.18 1144.3,-697.2 1139.94,-700.72"/>
</g>
<!-- back -->
<g id="node9" class="node">
<title>back</title>
<polygon fill="#333333" stroke="transparent" points="1056,-440 810,-440 810,-389 1056,-389 1056,-440"/>
<text text-anchor="middle" x="933" y="-424.4" font-family="DejaVu Sans" font-size="12.00">résultat (JSON, tronqué ≤100k chars)</text>
<text text-anchor="middle" x="933" y="-411.4" font-family="DejaVu Sans" font-size="12.00">ajouté à la conversation →</text>
<text text-anchor="middle" x="933" y="-398.4" font-family="DejaVu Sans" font-size="12.00">itération suivante</text>
</g>
<!-- back&#45;&gt;p1 -->
<!-- p2 -->
<g id="node11" class="node">
<title>p2</title>
<polygon fill="#ede9fe" stroke="#333333" points="1282,-662.5 1146,-662.5 1146,-624.5 1282,-624.5 1282,-662.5"/>
<text text-anchor="middle" x="1214" y="-646.9" font-family="DejaVu Sans" font-size="12.00">2 · Rate limit</text>
<text text-anchor="middle" x="1214" y="-633.9" font-family="DejaVu Sans" font-size="12.00">par identité + outil</text>
</g>
<!-- p1&#45;&gt;p2 -->
<g id="edge9" class="edge">
<title>p1&#45;&gt;p2</title>
<path fill="none" stroke="#555555" d="M1174.06,-705.18C1181.02,-694.61 1189.86,-681.17 1197.46,-669.62"/>
<polygon fill="#555555" stroke="#555555" points="1199.95,-670.95 1202,-662.72 1195.27,-667.87 1199.95,-670.95"/>
</g>
<!-- p3 -->
<g id="node12" class="node">
<title>p3</title>
<polygon fill="#ede9fe" stroke="#333333" points="1321.5,-572 1168.5,-572 1168.5,-521 1321.5,-521 1321.5,-572"/>
<text text-anchor="middle" x="1245" y="-556.4" font-family="DejaVu Sans" font-size="12.00">3 · Permission vault</text>
<text text-anchor="middle" x="1245" y="-543.4" font-family="DejaVu Sans" font-size="12.00">(+ déstructif si risque</text>
<text text-anchor="middle" x="1245" y="-530.4" font-family="DejaVu Sans" font-size="12.00">DANGEROUS)</text>
</g>
<!-- p2&#45;&gt;p3 -->
<g id="edge10" class="edge">
<title>p2&#45;&gt;p3</title>
<path fill="none" stroke="#555555" d="M1219.98,-624.18C1224.04,-611.72 1229.54,-594.88 1234.35,-580.15"/>
<polygon fill="#555555" stroke="#555555" points="1237.11,-580.7 1236.93,-572.23 1231.79,-578.96 1237.11,-580.7"/>
</g>
<!-- p4 -->
<g id="node13" class="node">
<title>p4</title>
<polygon fill="#f5f3ff" stroke="#333333" points="1278,-452.5 1136,-414.5 1278,-376.5 1420,-414.5 1278,-452.5"/>
<text text-anchor="middle" x="1278" y="-417.9" font-family="DejaVu Sans" font-size="12.00">4 · Risque READ ?</text>
<text text-anchor="middle" x="1278" y="-404.9" font-family="DejaVu Sans" font-size="12.00">→ exécution directe</text>
</g>
<!-- p3&#45;&gt;p4 -->
<g id="edge11" class="edge">
<title>p3&#45;&gt;p4</title>
<path fill="none" stroke="#555555" d="M1251.28,-520.74C1255.74,-503.18 1261.84,-479.16 1267.08,-458.5"/>
<polygon fill="#555555" stroke="#555555" points="1269.87,-458.92 1269.12,-450.48 1264.44,-457.54 1269.87,-458.92"/>
</g>
<!-- p5 -->
<g id="node14" class="node">
<title>p5</title>
<polygon fill="#fde68a" stroke="#333333" points="1413.5,-332 1136.5,-332 1136.5,-242 1413.5,-242 1413.5,-332"/>
<text text-anchor="middle" x="1275" y="-316.4" font-family="DejaVu Sans" font-size="12.00">PAUSE CONFIRMATION</text>
<text text-anchor="middle" x="1275" y="-303.4" font-family="DejaVu Sans" font-size="12.00">outil WRITE/DANGEROUS → event SSE</text>
<text text-anchor="middle" x="1275" y="-290.4" font-family="DejaVu Sans" font-size="12.00">« confirmation » avec le plan complet</text>
<text text-anchor="middle" x="1275" y="-277.4" font-family="DejaVu Sans" font-size="12.00">batché (BUG&#45;075) : une seule</text>
<text text-anchor="middle" x="1275" y="-264.4" font-family="DejaVu Sans" font-size="12.00">approbation applique toutes les mutations</text>
<text text-anchor="middle" x="1275" y="-251.4" font-family="DejaVu Sans" font-size="12.00">(resume via confirm / confirm_all)</text>
</g>
<!-- p4&#45;&gt;p5 -->
<g id="edge13" class="edge">
<title>p4&#45;&gt;p5</title>
<path fill="none" stroke="#d97706" d="M1277.12,-376.52C1276.85,-365.25 1276.54,-352.62 1276.26,-340.52"/>
<polygon fill="#d97706" stroke="#d97706" points="1279.05,-340.18 1276.06,-332.25 1273.45,-340.31 1279.05,-340.18"/>
<text text-anchor="middle" x="1301" y="-351.2" font-family="DejaVu Sans" font-size="11.00">mutation</text>
</g>
<!-- p6 -->
<g id="node15" class="node">
<title>p6</title>
<polygon fill="#dcfce7" stroke="#333333" points="1395.5,-161 1142.5,-161 1142.5,-110 1395.5,-110 1395.5,-161"/>
<text text-anchor="middle" x="1269" y="-145.4" font-family="DejaVu Sans" font-size="12.00">5 · Handler exécuté</text>
<text text-anchor="middle" x="1269" y="-132.4" font-family="DejaVu Sans" font-size="12.00">6 · Audit JSON&#45;lines</text>
<text text-anchor="middle" x="1269" y="-119.4" font-family="DejaVu Sans" font-size="12.00">7 · Redaction secrets (redact_payload)</text>
</g>
<!-- p4&#45;&gt;p6 -->
<g id="edge12" class="edge">
<title>p4&#45;&gt;p6</title>
<path fill="none" stroke="#555555" d="M1323.61,-388.38C1367.1,-364.44 1426,-332 1426,-332 1426,-332 1426,-242 1426,-242 1426,-242 1358.97,-196.96 1312.51,-165.73"/>
<polygon fill="#555555" stroke="#555555" points="1314.01,-163.37 1305.81,-161.24 1310.89,-168.02 1314.01,-163.37"/>
<text text-anchor="middle" x="1438.5" y="-284.2" font-family="DejaVu Sans" font-size="11.00">read</text>
</g>
<!-- p5&#45;&gt;p6 -->
<g id="edge14" class="edge">
<title>p5&#45;&gt;p6</title>
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M1273.23,-241.95C1272.3,-218.69 1271.17,-190.62 1270.32,-169.29"/>
<polygon fill="#555555" stroke="#555555" points="1273.11,-169.06 1269.99,-161.18 1267.51,-169.28 1273.11,-169.06"/>
<text text-anchor="middle" x="1298" y="-205.2" font-family="DejaVu Sans" font-size="11.00">approuvé</text>
</g>
<!-- p6&#45;&gt;tools -->
<!-- p6&#45;&gt;back -->
<!-- cluster_tools -->
<g id="node27" class="node">
<title>cluster_tools</title>
<polygon fill="#333333" stroke="#333333" points="1319,-56.5 1219,-56.5 1219,-20.5 1319,-20.5 1319,-56.5"/>
<text text-anchor="middle" x="1269" y="-35.4" font-family="DejaVu Sans" font-size="12.00">cluster_tools</text>
</g>
<!-- p6&#45;&gt;cluster_tools -->
<!-- answer -->
<g id="node18" class="node">
<title>answer</title>
<polygon fill="#dbeafe" stroke="#333333" points="347,-64 151,-64 151,-13 347,-13 347,-64"/>
<text text-anchor="middle" x="249" y="-48.4" font-family="DejaVu Sans" font-size="12.00">Affichage UI : bloc</text>
<text text-anchor="middle" x="249" y="-35.4" font-family="DejaVu Sans" font-size="12.00">« N étapes » Notion&#45;style</text>
<text text-anchor="middle" x="249" y="-22.4" font-family="DejaVu Sans" font-size="12.00">(pensées + outils dépliables)</text>
</g>
<!-- done&#45;&gt;answer -->
<g id="edge21" class="edge">
<title>done&#45;&gt;answer</title>
<path fill="none" stroke="#555555" d="M333.07,-103.39C318.5,-92.32 302.14,-79.89 287.76,-68.96"/>
<polygon fill="#555555" stroke="#555555" points="289.37,-66.66 281.3,-64.05 285.98,-71.12 289.37,-66.66"/>
</g>
<!-- final&#45;&gt;answer -->
<g id="edge22" class="edge">
<title>final&#45;&gt;answer</title>
<path fill="none" stroke="#555555" d="M174.01,-96.95C186.01,-87.76 198.68,-78.05 210.13,-69.28"/>
<polygon fill="#555555" stroke="#555555" points="212.07,-71.32 216.71,-64.24 208.66,-66.88 212.07,-71.32"/>
</g>
<!-- fam_vault -->
<g id="node19" class="node">
<title>fam_vault</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1958,-952.5 1720,-952.5 1720,-808.5 1958,-808.5 1958,-952.5"/>
<text text-anchor="middle" x="1839" y="-938.5" font-family="DejaVu Sans" font-size="10.00">VAULT · service.py (22)</text>
<text text-anchor="middle" x="1839" y="-927.5" font-family="DejaVu Sans" font-size="10.00">lecture : list_vaults, list_directory,</text>
<text text-anchor="middle" x="1839" y="-916.5" font-family="DejaVu Sans" font-size="10.00">list_all_files, read_file, read_file_raw,</text>
<text text-anchor="middle" x="1839" y="-905.5" font-family="DejaVu Sans" font-size="10.00">search_fulltext, search_advanced,</text>
<text text-anchor="middle" x="1839" y="-894.5" font-family="DejaVu Sans" font-size="10.00">search_paths, get_graph, get_backlinks,</text>
<text text-anchor="middle" x="1839" y="-883.5" font-family="DejaVu Sans" font-size="10.00">list_tags, suggest_tags, list_recent,</text>
<text text-anchor="middle" x="1839" y="-872.5" font-family="DejaVu Sans" font-size="10.00">list_backups, diff_backup</text>
<text text-anchor="middle" x="1839" y="-861.5" font-family="DejaVu Sans" font-size="10.00">écriture : create_file, create_directory,</text>
<text text-anchor="middle" x="1839" y="-850.5" font-family="DejaVu Sans" font-size="10.00">append_to_file, edit_file, restore_backup</text>
<text text-anchor="middle" x="1839" y="-839.5" font-family="DejaVu Sans" font-size="10.00">DANGEROUS : delete_file, delete_directory,</text>
<text text-anchor="middle" x="1839" y="-828.5" font-family="DejaVu Sans" font-size="10.00">move_path, rename_file, rename_directory,</text>
<text text-anchor="middle" x="1839" y="-817.5" font-family="DejaVu Sans" font-size="10.00">replace_in_files</text>
</g>
<!-- fam_web -->
<g id="node20" class="node">
<title>fam_web</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1938.5,-747 1739.5,-747 1739.5,-702 1938.5,-702 1938.5,-747"/>
<text text-anchor="middle" x="1839" y="-733" font-family="DejaVu Sans" font-size="10.00">WEB · web.py + crawler.py</text>
<text text-anchor="middle" x="1839" y="-722" font-family="DejaVu Sans" font-size="10.00">web_search · fetch_url</text>
<text text-anchor="middle" x="1839" y="-711" font-family="DejaVu Sans" font-size="10.00">crawl_site (≤20 pages, même hôte)</text>
</g>
<!-- fam_vault&#45;&gt;fam_web -->
<!-- fam_doc -->
<g id="node21" class="node">
<title>fam_doc</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1922,-666 1756,-666 1756,-621 1922,-621 1922,-666"/>
<text text-anchor="middle" x="1839" y="-652" font-family="DejaVu Sans" font-size="10.00">DOCUMENTS · documents.py</text>
<text text-anchor="middle" x="1839" y="-641" font-family="DejaVu Sans" font-size="10.00">create_pdf · create_docx</text>
<text text-anchor="middle" x="1839" y="-630" font-family="DejaVu Sans" font-size="10.00">create_xlsx · create_csv</text>
</g>
<!-- fam_web&#45;&gt;fam_doc -->
<!-- fam_xlsx -->
<g id="node22" class="node">
<title>fam_xlsx</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1944.5,-580 1733.5,-580 1733.5,-513 1944.5,-513 1944.5,-580"/>
<text text-anchor="middle" x="1839" y="-566" font-family="DejaVu Sans" font-size="10.00">TABLEURS · spreadsheets.py</text>
<text text-anchor="middle" x="1839" y="-555" font-family="DejaVu Sans" font-size="10.00">list_xlsx_sheets · xlsx_to_markdown</text>
<text text-anchor="middle" x="1839" y="-544" font-family="DejaVu Sans" font-size="10.00">search_workbook · analyze_range</text>
<text text-anchor="middle" x="1839" y="-533" font-family="DejaVu Sans" font-size="10.00">update_xlsx_cells · append_xlsx_rows</text>
<text text-anchor="middle" x="1839" y="-522" font-family="DejaVu Sans" font-size="10.00">edit_xlsx_structure</text>
</g>
<!-- fam_doc&#45;&gt;fam_xlsx -->
<!-- fam_dup -->
<g id="node23" class="node">
<title>fam_dup</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1929.5,-437 1748.5,-437 1748.5,-392 1929.5,-392 1929.5,-437"/>
<text text-anchor="middle" x="1839" y="-423" font-family="DejaVu Sans" font-size="10.00">DOUBLONS · duplicates.py</text>
<text text-anchor="middle" x="1839" y="-412" font-family="DejaVu Sans" font-size="10.00">find_duplicates</text>
<text text-anchor="middle" x="1839" y="-401" font-family="DejaVu Sans" font-size="10.00">merge_duplicate_notes (DANG.)</text>
</g>
<!-- fam_xlsx&#45;&gt;fam_dup -->
<!-- fam_git -->
<g id="node24" class="node">
<title>fam_git</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1931,-309.5 1747,-309.5 1747,-264.5 1931,-264.5 1931,-309.5"/>
<text text-anchor="middle" x="1839" y="-295.5" font-family="DejaVu Sans" font-size="10.00">GIT CONNECTÉ · connected.py</text>
<text text-anchor="middle" x="1839" y="-284.5" font-family="DejaVu Sans" font-size="10.00">git_list_repos · git_get_file</text>
<text text-anchor="middle" x="1839" y="-273.5" font-family="DejaVu Sans" font-size="10.00">git_search_issues (Gitea/GitHub)</text>
</g>
<!-- fam_dup&#45;&gt;fam_git -->
<!-- fam_sched -->
<g id="node25" class="node">
<title>fam_sched</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1942.5,-153.5 1735.5,-153.5 1735.5,-117.5 1942.5,-117.5 1942.5,-153.5"/>
<text text-anchor="middle" x="1839" y="-138.5" font-family="DejaVu Sans" font-size="10.00">SCHEDULER · scheduled.py</text>
<text text-anchor="middle" x="1839" y="-127.5" font-family="DejaVu Sans" font-size="10.00">list/create/delete/run_scheduled_task</text>
</g>
<!-- fam_git&#45;&gt;fam_sched -->
<!-- fam_notify -->
<g id="node26" class="node">
<title>fam_notify</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1917.5,-61 1760.5,-61 1760.5,-16 1917.5,-16 1917.5,-61"/>
<text text-anchor="middle" x="1839" y="-47" font-family="DejaVu Sans" font-size="10.00">NOTIFICATIONS · notify.py</text>
<text text-anchor="middle" x="1839" y="-36" font-family="DejaVu Sans" font-size="10.00">notify_external (Discord,</text>
<text text-anchor="middle" x="1839" y="-25" font-family="DejaVu Sans" font-size="10.00">Telegram, SMTP, webhook)</text>
</g>
<!-- fam_sched&#45;&gt;fam_notify -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 26 KiB

+98
View File
@@ -0,0 +1,98 @@
# #166 · #168 · #170 — Agent IA phase 4 : doublons, notifications externes, tâches planifiées
> **Statut :** ✅ livré — **Effort :** ~5 jours | **Impacts :** 🟢
> **Références :** [Roadmap](../ROADMAP.md#--évolutions-agent-ia-liste-v12--phase-4--ids-obsigate-165--178) · [Changelog](../../CHANGELOG.md)
> **Guides :** [Assistant IA & Forge](../GUIDES/ASSISTANT_IA_FORGE.md) · [API REST](../GUIDES/API_REST.md) · [MCP](../GUIDES/MCP.md)
## 1. Périmètre
Trois items de la liste v1.2 « Phase 4 » livrés ensemble car ils partagent le
même socle (registre `@tool`, stores JSON verrouillés, notifications) :
| ID | Fonctionnalité | Entrées |
|---|---|---|
| #166 | Détection & fusion de doublons | `backend/services/duplicates.py`, `backend/tools/duplicates.py`, `backend/routers/duplicates.py` |
| #168 | Notifications externes Discord / Telegram / SMTP / webhook | `backend/notify.py`, `backend/tools/notify.py`, `backend/routers/notify.py` |
| #170 | Tâches planifiées type cron | `backend/scheduler.py`, `backend/tools/scheduled.py`, `backend/routers/scheduler.py` |
Règle transverse respectée : **tout nouvel outil = `@tool` + libellé
`labels.py` + clés i18n `ai.step.*` FR/EN + tests** (cf. `ai-tools-roadmap.md` §2).
## 2. #166 — Doublons
- **Score déterministe stdlib** (`similarity_score`) : Jaccard sur tokens
(frontmatter exclu, accents conservés) à 70 % + similarité du titre/first-line
(`difflib`) à 30 %. Pas de dépendance embeddings — l'index sémantique #70
reste un raffinement optionnel, pas un prérequis.
- **Scan borné** : 500 fichiers `.md` max, 200 Ko/fichier, pré-filtre Jaccard
avant le score complet, `truncated` exposé quand le plafond est atteint.
- **Fusion jamais sans filet** : backup des 2 fichiers avant écriture,
outil `merge_duplicate_notes` en `DANGEROUS` (carte « Tout approuver »),
route `POST /api/duplicates/merge` exige `{confirm: true}`, stratégies
`append` (défaut, avec marqueur d'origine) / `prefer_target` / `prefer_source`.
- **Outils** : `find_duplicates` (READ), `merge_duplicate_notes` (DANGEROUS).
## 3. #168 — Notifications externes
Canaux `discord` (webhook `discord.com`), `telegram` (Bot API + `chat_id`),
`smtp` (stdlib, STARTTLS + login) et `webhook` générique (JSON
`{event, title, message, timestamp, source}`).
- **Secrets** : jamais dans `notify_channels.json` — store `notify_secrets.json`
(0600) ou `OBSIGATE_NOTIFY_SECRET_<ID>` (même motif que #9 / BUG-026) ;
l'API n'expose que `***` + `has_secret`. Le token Telegram peut aussi venir
de `OBSIGATE_TELEGRAM_BOT_TOKEN`.
- **SSRF** : `validate_webhook_url` / `is_safe_target` réutilisés pour les
webhooks génériques et Telegram ; Discord valide son préfixe d'URL.
- **Déclencheurs** : `manual`, `schedule_failure`, `schedule_success`,
`duplicate_found` — choisis par canal. `broadcast()` n'échoue jamais en bloc
(résultat par canal, `last_error` persisté).
- **CRUD admin** (`/api/notify/channels`), test d'envoi authentifié
(`POST /api/notify/test`), outil `notify_external` (WRITE → confirmation).
## 4. #170 — Tâches planifiées
Store `data/scheduled_tasks.json` (RLock, écriture atomique tmp+replace).
Actions = outils existants, aucun nouveau chemin d'écriture :
- `create_file` / `append_to_file` → `backend.services.mutations` ;
- `notify` → `backend.notify.broadcast`.
Planifications `interval_hours` (≥ 0,25), `daily_time` (`HH:MM`) et `once_at`
(ISO-8601, one-shot désactivé après exécution). `tick()` exécute les tâches
dues, enregistre `last_status`/`last_error`/`run_count`/`next_run_at` et émet
`schedule_failure` via #168 (sauf quand l'action elle-même est `notify` —
anti-récursion). Boucle de fond dans le lifespan de `main.py` (tick 60 s via
`asyncio.to_thread`, désactivable par `OBSIGATE_SCHEDULER=0`).
Outils : `create_scheduled_task` / `list_scheduled_tasks` /
`delete_scheduled_task` / `run_scheduled_task_now` (WRITE sauf list).
La création vérifie l'accès au vault **et** son existence (404 sinon).
## 5. API REST (toutes avec `response_model`)
| Route | Rôle |
|---|---|
| `GET /api/duplicates?vault&threshold&limit&subdir` | paires candidates |
| `POST /api/duplicates/merge` | fusion (`confirm: true` obligatoire) |
| `GET/POST /api/notify/channels`, `PATCH/DELETE /api/notify/channels/{id}` | CRUD admin |
| `POST /api/notify/test` | test broadcast ou canal ciblé |
| `GET/POST /api/scheduler/tasks`, `PATCH/DELETE /api/scheduler/tasks/{id}`, `POST …/run` | CRUD + exécution manuelle |
## 6. Tests
`tests/test_duplicates.py` (16), `tests/test_notify_channels.py` (14),
`tests/test_scheduler.py` (18) : services, routes (fixture `client`,
auth désactivée), outils (confirmation `DANGEROUS` vérifiée), stores isolés
en tmp, réseau mocké (jamais d'Internet en CI). Labels couverts par le
garde-fou `test_tool_labels.py` (tout outil IN_APP doit avoir son libellé).
## 7. Limites assumées (V1)
- Similarité lexicale (pas d'embeddings) — seuils réglables par l'agent.
- Pas d'UI dédiée (API + agent uniquement) ; les clés i18n des étapes
existent déjà pour la section « N étapes ».
- Scheduler in-process (pas de persistance distribuée, tick 60 s) ;
Redis/APScheduler resteraient l'option multi-workers.
- SMTP sans OAuth2 (login STARTTLS) ; Slack natif non ciblé (webhook
générique compatible `incoming-webhook` utilisable tel quel).
+37
View File
@@ -86,3 +86,40 @@
- Python 3.11 embed — téléchargé depuis python.org
- NSIS (Windows) — pour le générateur d'installateur `.exe`
- AppImageKit (Linux) — pour le packaging portable
## G. Gestion des vaults & dossiers — #159 — ✅ livré (2026-10-02, v2.50.0)
- [x] Retrait vault/dossier racine par menu contextuel (`ContextMenuManager`,
branche `vault`, gate `isTauriEnv()`) → `removeRoot()` dans `desktop.js` :
résolution vault/dossier via `list_vaults`/`list_dirs`, confirmation i18n,
`remove_vault`/`remove_dir` + `restart_backend` + reload — déregistration
seule, zéro suppression disque.
- [x] Section Configuration `cfg-desktop-roots` (+ entrée TOC i18n FR/EN) :
liste des roots injectés, retrait par ligne, ajout vault
(`pickAndAddVault`, existant) et ajout dossier (nouvelle commande Rust
`pick_folder` — sélecteur sans effet de bord, contrairement à
`pick_vault_folder` réservé au wizard). Section masquée hors desktop.
- [x] Jump list rafraîchie après chaque ajout/retrait de vault
(`refresh_jumplist()`).
- [x] i18n FR/EN (7 clés `config.*`/`desktop.*`), garde-fou ACL automatique
(`test_frontend_invokes_are_acl_allowed` : toute commande invoquée par le
frontend doit figurer dans `permissions/commands.toml`).
- [x] Tests : `tests/frontend/desktop-roots.test.mjs` (4 — helpers purs +
gating hors desktop), inscrit au CI ; suites frontend/JSDOM vertes,
`cargo test` 25 passed.
## H. Premier lancement & section Configuration harmonisée — #160 — ✅ livré (2026-10-02, v2.51.0)
- [x] `default_first_run_config(home)` (pure, testée) : `<home>/ObsiGate`
monté comme vault « ObsiGate » ET comme `vault_path`, racine home nommée
d'après son dernier segment — remplace `voute_obsidian` + le « bruno »
codé en dur ; les champs fenêtre de la config existante sont préservés.
- [x] `Prise en main.md` : contenu embarqué (`include_str!("prise_en_main.md")`),
écrit au premier lancement **si absent** (jamais d'écrasement).
- [x] Section `cfg-desktop-roots` refondue : markup à classes (zéro style
inline), bloc CSS `#160` sur variables (`desktop-roots-*`, motif
`webauthn-key-item`), boutons `.config-btn-sm` (primaire/secondaire),
override mobile 44px dans le bloc `#config-modal`.
- [x] Tests : `test_default_first_run_config`, `test_welcome_doc_embedded`
— `cargo test` 28 passed ; suites frontend vertes (validate-imports,
unit, config-mobile, desktop-roots, settings-order) + E2E locale.
+72
View File
@@ -0,0 +1,72 @@
# #179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile
> **Statut :** 🔵 en cours (2026-10-04) | **Effort :** 1-2 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) · [#156](./xlsx-editor-completeness.md)
## 1. Constat
La grille (#155), les menus Structure / Mise en forme / Export (#156-A8/A11/A14)
et les boutons du ruban (#154) étaient textuels, et les menus Structure / Mise
en forme / Export **restaient ouverts** quand le focus les quittait (seul le
menu grille se fermait, au clic extérieur / `Échap` uniquement).
## 2. Livré (A1-A4)
### A1 — Icônes du menu contextuel de la grille
Chaque entrée de `buildContextItems` (`viewer.js`) porte une icône Lucide
(`scissors`, `copy`, `clipboard-paste`, `rows`/`columns`, `trash-2` danger,
`arrow-up`/`arrow-down`, `eraser`). Rendu dans `xlsx/context-menu.js`
(`<i data-lucide>` + `<span class="xlsx-menu-label">`, label en `textContent`
— jamais d'HTML injecté), hydraté par `safeCreateIcons()` (jamais
`lucide.createIcons()` direct).
### A2 — Icônes Structure / Mise en forme / Export + boutons
- Structure : `file-plus`, `pencil`, `copy`, `trash-2` (danger), `rows`,
`columns` ; suppressions marquées `.xlsx-context-danger`.
- Mise en forme : `bold`/`italic`/`underline`, `remove-formatting`,
`align-left/center/right`, `palette`/`paint-bucket` (sélecteurs natifs),
`type`/`hash`/`percent`/`euro`/`calendar`/`pilcrow` (formats),
`combine`/`ungroup`, `snowflake`/`sun` (figer/libérer),
`move-horizontal`/`move-vertical` (largeur/hauteur).
- Export : `file-text` (MD), `code` (HTML), `printer` (impression).
- Ruban : bouton Enregistrer (`save` + libellé), onglet « + » (`plus`),
`aria-haspopup="menu"` / `aria-expanded` sur les 3 boutons à menu.
### A3 — Fermeture à la perte de focus + clavier
- `trackDismissable(menu, { toggle, onClose })` dans `context-menu.js` :
clic extérieur (le toggle garde ses propres clics), `Échap`, `focusout`
(vers menu **et** toggle exemptés), scroll (capture), resize ; `dismiss()`
idempotent qui débranche tout (pas de fuite entre re-rendus).
- Menu grille : `focusout` + scroll/resize en plus de l'existant ;
`enableArrowNav()` (`↓`/`↑`/`Home`/`End`, désactivés sautés), autofocus de
la 1ʳᵉ entrée à l'ouverture (`preventScroll`), `:focus` toujours visible.
- Menus Structure / Mise en forme / Export branchés sur le helper.
### A4 — Polish mobile
- `@media (pointer: coarse)` : entrées ≥ 44 px, police 0,9 rem.
- Menus bornés au viewport (grille : `min(320px, 86vw)` + `max-height: 80vh`
scrollable ; structure : `min(320px, 92vw)` ; export : `86vw`).
- Le ruban s'enroule déjà (`flex-wrap`), l'inspecteur s'empile ≤ 900 px :
vérifié sans débordement horizontal à 393 px (E2E projet mobile).
## 3. Tests
- `tests/frontend/xlsx-menus.test.mjs` (11) : rendu icônes + XSS, autofocus,
navigation clavier, `Échap` / clic / `focusout` / scroll / resize,
`trackDismissable`, icônes du ruban.
- `tests/frontend/xlsx-viewer.test.mjs` (+6, 113 total) : icônes et fermetures
au niveau viewer réel.
- `tests/e2e/xlsx-viewer.spec.js` (+5) : icônes par entrée, fermeture clic/`Échap`,
Structure au focus perdu, Export au clic extérieur, mobile (ruban, 44 px,
viewport) — ce dernier `skip` hors viewport ≤ 768 px.
## 4. Limites assumées
- Noms d'icônes de la gamme Lucide 0.344.0 (CDN `unpkg`) : si le CDN est
injoignable, les libellés restent (dégradation texte, jamais de crash).
- Pas de nouveaux libellés i18n (icônes + `aria-expanded` uniquement).
+43 -3
View File
@@ -1587,6 +1587,7 @@
<li><a href="#cfg-recent" class="help-nav-link" data-i18n="config.section_recent"></a></li>
<li><a href="#cfg-tags" class="help-nav-link" data-i18n="config.section_tags"></a></li>
<li><a href="#cfg-hidden-files" class="help-nav-link" data-i18n="config.section_hidden"></a></li>
<li><a href="#cfg-desktop-roots" class="help-nav-link" data-i18n="config.section_desktop"></a></li>
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
<li><a href="#cfg-diags" class="help-nav-link" data-i18n="settings.diagnostics"></a></li>
@@ -1950,6 +1951,45 @@
</div>
</section>
<!-- Desktop vaults & root directories (#159, #160) -->
<section
id="cfg-desktop-roots"
class="config-section help-section"
>
<h2 data-i18n="config.section_desktop">🖥️ Vaults &amp; dossiers (Desktop)</h2>
<p
class="config-description"
data-i18n="config.desktop_roots_desc"
>
Ajoutez ou retirez les vaults et dossiers
racine chargés par l'application desktop.
</p>
<div
id="desktop-roots-list"
class="desktop-roots-list"
>
<!-- Racines injectées par renderDesktopRoots() -->
</div>
<div class="config-actions-row desktop-roots-actions">
<button
class="config-btn-primary config-btn-sm"
id="cfg-desktop-add-vault"
data-i18n="desktop.add_vault"
>
📁 Ajouter un vault
</button>
<button
class="config-btn-secondary config-btn-sm"
id="cfg-desktop-add-dir"
data-i18n="desktop.add_dir"
>
📂 Ajouter un dossier
</button>
</div>
</section>
<!-- Watcher / Synchronisation -->
<section
class="config-section help-section"
@@ -4634,9 +4674,9 @@
conversation.
</li>
<li data-i18n="help.assistant_agent">
Le bouton « mode agent » active les outils (lire, lister,
chercher) ; les actions de modification demandent une
confirmation avec aperçu des changements.
L'assistant utilise toujours ses outils (lire, lister,
chercher, modifier) ; les actions de modification demandent
une confirmation avec aperçu des changements.
</li>
<li data-i18n="help.assistant_agent_run">
Les actions s'affichent dans le fil : l'assistant regroupe les
+2 -2
View File
@@ -46,8 +46,8 @@ export const ACTION_CATALOG = Object.freeze([
{ id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' },
{ id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' },
{ id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' },
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true },
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true },
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt' },
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt' },
{ id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' },
{ id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' },
// ── Code & Scripts ───────────────────────────────────────────────────
+12 -43
View File
@@ -151,9 +151,8 @@ class BooksLM {
this._sessions = [];
this._currentSessionId = null;
this._pendingNewSession = false;
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
// call read/search tools and propose mutations (confirmation cards).
this._agentMode = this._readAgentMode();
// #187: the assistant is always in agent mode (tools on /agent endpoint);
// the former header toggle was removed — images still use the /chat path.
// Ad-hoc context added with `@` (files/directories) and images attached
// by paste or by mentioning an image file.
this._adhocFiles = [];
@@ -188,33 +187,6 @@ class BooksLM {
this._extensions = null;
}
_readAgentMode() {
try {
return localStorage.getItem('obsigate-bookslm-agent') === 'true';
} catch {
return false;
}
}
_toggleAgentMode() {
this._agentMode = !this._agentMode;
try {
localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false');
} catch { /* private mode */ }
this._updateAgentToggle();
}
_updateAgentToggle() {
if (!this._panel) return;
const btn = this._panel.querySelector('.bookslm-btn-agent');
if (!btn) return;
btn.classList.toggle('active', this._agentMode);
const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off');
btn.title = label;
btn.setAttribute('aria-label', label);
btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false');
}
// ── Public API ──────────────────────────────────────────────────────
/**
@@ -966,7 +938,6 @@ class BooksLM {
</div>
<span class="bookslm-qa-badge hidden" data-qa-context=""></span>
<div class="bookslm-header-actions">
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
@@ -1011,7 +982,6 @@ class BooksLM {
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode());
panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => {
e.stopPropagation();
this._toggleHistoryMenu();
@@ -1552,10 +1522,9 @@ class BooksLM {
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
/** #97/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */
/** #97/#99 — Deep Research: a skill-like chip (the agent tools are always on). */
_startDeepResearch() {
this._closeExtMenu();
if (!this._agentMode) this._toggleAgentMode();
this._activeDeepResearch = true;
this._renderAttachments();
showToast(t('bookslm.deep_research_started'), 'info');
@@ -2201,15 +2170,14 @@ class BooksLM {
}
/** Immediate-send the action prompt through the composer (same path as a
* typed message: skills, agent mode and images all keep working). Actions
* flagged `agent` (they mutate the document) transparently switch the
* assistant to agent mode first — same pattern as Deep Research. */
* typed message: skills and images all keep working). #187: the assistant
* is always agent mode, so the former `agent: true` quick actions need no
* mode switch anymore. */
_runQuickAction(action) {
if (!this._panel || !action) return;
const { prompt } = actionTexts(action);
if (!prompt) return;
this._closeActionDrawer();
if (action.agent && !this._agentMode) this._toggleAgentMode();
const textarea = this._panel.querySelector('textarea');
if (!textarea) return;
textarea.value = prompt;
@@ -3253,14 +3221,15 @@ class BooksLM {
this._saveHistory();
}
/** POST to /chat or /agent depending on the agent-mode toggle. */
/** POST to /agent (always) or /chat when images are attached. */
_postChat(payload) {
// Images require the plain multimodal chat endpoint (the agent loop is
// #187: the assistant is always agent mode — images are the only case
// that still needs the plain multimodal chat endpoint (the agent loop is
// text/tool oriented).
const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
const endpoint = (this._agentMode && !hasImages)
? '/api/ai/bookslm/agent'
: '/api/ai/bookslm/chat';
const endpoint = hasImages
? '/api/ai/bookslm/chat'
: '/api/ai/bookslm/agent';
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch(endpoint, {
method: 'POST',
+48
View File
@@ -7,6 +7,7 @@ import { escapeHtml, safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, setLocale, getLocale } from './i18n.js';
import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
import { isTauriEnv, invoke, pickAndAddVault, pickAndAddDir, removeRoot } from './desktop.js';
let _recentTimestampTimer = null;
let _recentFilesCache = [];
@@ -776,6 +777,52 @@ function closeHelpModal() {
if (modal) modal.classList.remove("active");
}
// ── Vaults & root directories — desktop only (#159) ──────────────────────
// Lists the roots the Tauri shell injects into the backend (vaults +
// directories) and offers add/remove. The whole section — including its TOC
// entry — is hidden outside the desktop app.
async function renderDesktopRoots() {
const section = document.getElementById("cfg-desktop-roots");
const navLink = document.querySelector('#config-nav a[href="#cfg-desktop-roots"]');
if (!section) return;
if (!isTauriEnv()) {
section.style.display = "none";
if (navLink && navLink.parentElement) navLink.parentElement.style.display = "none";
return;
}
section.style.display = "";
if (navLink && navLink.parentElement) navLink.parentElement.style.display = "";
const vaults = (await invoke("list_vaults")) || [];
const dirs = (await invoke("list_dirs")) || [];
const list = document.getElementById("desktop-roots-list");
if (!list) return;
const row = (name, path, kind) =>
'<div class="desktop-roots-item">' +
`<i data-lucide="${kind === "vault" ? "vault" : "folder"}" class="desktop-roots-icon"></i>` +
'<div class="desktop-roots-text">' +
`<span class="desktop-roots-name">${escapeHtml(name)}</span>` +
`<span class="desktop-roots-path" title="${escapeHtml(path)}">${escapeHtml(path)}</span>` +
"</div>" +
`<button class="config-btn-secondary config-btn-sm" data-root="${escapeHtml(name)}">` +
`<i data-lucide="folder-minus" class="desktop-roots-btn-icon"></i>${escapeHtml(t("desktop.remove_root"))}</button>` +
"</div>";
if (!vaults.length && !dirs.length) {
list.innerHTML = `<div class="desktop-roots-empty">${escapeHtml(t("desktop.roots_empty"))}</div>`;
} else {
list.innerHTML = vaults.map((v) => row(v.name, v.path, "vault")).join("") +
dirs.map((d) => row(d.name, d.path, "dir")).join("");
for (const btn of list.querySelectorAll("button[data-root]")) {
// onclick (not addEventListener): the modal re-renders on every open.
btn.onclick = () => removeRoot(btn.dataset.root, t("desktop.remove_root_confirm", { name: btn.dataset.root }));
}
}
const addVaultBtn = document.getElementById("cfg-desktop-add-vault");
const addDirBtn = document.getElementById("cfg-desktop-add-dir");
if (addVaultBtn) addVaultBtn.onclick = () => pickAndAddVault();
if (addDirBtn) addDirBtn.onclick = () => pickAndAddDir();
safeCreateIcons();
}
function initConfigModal() {
const openBtn = document.getElementById("config-open-btn");
const closeBtn = document.getElementById("config-close");
@@ -806,6 +853,7 @@ function initConfigModal() {
loadTokensUI();
loadSharesUI();
loadToolKeys();
renderDesktopRoots();
safeCreateIcons();
});
+52 -2
View File
@@ -163,14 +163,64 @@ export async function pickAndAddVault() {
if (!path) return undefined;
// Derive a display name from the final path segment.
const segments = path.replace(/[\\/]+$/, '').split(/[\\/]/);
const name = segments[segments.length - 1] || 'Vault';
const name = lastPathSegment(path) || 'Vault';
await invoke('add_vault', { name: name, path: path });
await invoke('restart_backend');
return path;
}
// ── Vault & root-directory management (#159) ────────────────────────────
// The Tauri shell injects vaults (VAULT_N_*) and root directories (DIR_N_*)
// into the backend as environment variables. These helpers unregister them
// from the desktop config, restart the backend and reload the page.
// Pure: classify a root name against the desktop config lists.
// Returns 'vault' | 'dir' | null.
export function resolveRootKind(vaults, dirs, name) {
if ((vaults || []).some((v) => v.name === name)) return 'vault';
if ((dirs || []).some((d) => d.name === name)) return 'dir';
return null;
}
// Pure: last path segment, Windows or POSIX separators, trailing
// separators ignored ('' for root-ish inputs).
export function lastPathSegment(path) {
const segments = String(path).replace(/[\\/]+$/, '').split(/[\\/]/);
return segments[segments.length - 1] || '';
}
// Remove a vault or a root directory from the desktop config. `confirmMsg`
// (already translated) gates the destructive step; nothing on disk is
// deleted. Returns 'vault' | 'dir', or undefined when not on desktop,
// cancelled, unknown root, or a command failed.
export async function removeRoot(name, confirmMsg) {
if (!isTauriEnv() || !name) return undefined;
if (confirmMsg && !window.confirm(confirmMsg)) return undefined;
const vaults = (await invoke('list_vaults')) || [];
const dirs = (await invoke('list_dirs')) || [];
const kind = resolveRootKind(vaults, dirs, name);
if (!kind) return undefined;
const res = await invoke(kind === 'vault' ? 'remove_vault' : 'remove_dir', { name: name });
if (res === undefined) return undefined; // command failed (see invoke wrapper)
await invoke('restart_backend');
window.location.reload();
return kind;
}
// Add a root directory: side-effect-free native folder picker, register it,
// restart the backend and reload. Mirrors pickAndAddVault.
export async function pickAndAddDir() {
if (!isTauriEnv()) return undefined;
const path = await invoke('pick_folder');
if (typeof path !== 'string' || !path) return undefined;
const name = lastPathSegment(path) || 'Dossier';
await invoke('add_dir', { name: name, path: path });
await invoke('restart_backend');
window.location.reload();
return path;
}
// ── First-run wizard (DOM) ─────────────────────────────────────────────────
// Shows a dismissible welcome banner on first desktop launch offering to pick
// the user's Obsidian vault folder. Non-blocking; the default vault created
+10
View File
@@ -8,6 +8,7 @@ import { GraphViewManager } from './graph.js';
import { DashboardConflictsWidget } from './dashboard.js';
import { t, tr } from './i18n.js';
import { toggleThemeMode } from './themes.js';
import { isTauriEnv, removeRoot } from './desktop.js';
// ---------------------------------------------------------------------------
// Right Sidebar Manager
// ---------------------------------------------------------------------------
@@ -1806,6 +1807,15 @@ export const ContextMenuManager = {
this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly);
this._addSeparator();
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, '')); }, false);
// #159 — retrait d'un vault ou dossier racine : desktop uniquement,
// déregistration seule (aucun fichier supprimé), confirmation exigée.
if (isTauriEnv()) {
this._addSeparator();
this._addItem('folder-minus', t('desktop.remove_root'), () => {
const name = this._targetVault;
if (name) removeRoot(name, t('desktop.remove_root_confirm', { name: name }));
}, false);
}
} else if (type === 'directory') {
this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
+147 -61
View File
@@ -19,7 +19,7 @@ import { openAssistant } from './ai-fab.js';
import { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
import { buildCommandBar } from './xlsx/command-bar.js';
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js';
import { openContextMenu, closeContextMenu } from './xlsx/context-menu.js';
import { openContextMenu, closeContextMenu, trackDismissable, enableArrowNav } from './xlsx/context-menu.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -2284,25 +2284,42 @@ export function renderXlsxViewer(area, data) {
// Structure menu: built on demand, positioned under the button.
// (.csv / read-only workbooks have no such button — the menu block above
// is skipped for them.)
// #179 — tracked dismissal of the structure menu (outside, focus loss…).
let dismissStructureMenu = null;
const structureBtn = area.querySelector("#xlsx-structure-btn");
if (structureBtn) structureBtn.addEventListener("click", (e) => {
const old = area.querySelector(".xlsx-structure-menu");
if (old) { old.remove(); return; }
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; return; }
const idx = visibleSheetIndex();
const sheetName = sheets[idx]?.name || "";
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu";
const item = (label, fn) => {
menu.setAttribute("role", "menu");
// #179 — representative icon per entry; destructive ones read as danger.
const item = (iconName, label, fn, danger = false) => {
const b = document.createElement("button");
b.type = "button";
b.className = "btn-action xlsx-structure-item";
b.textContent = label;
b.addEventListener("click", () => { menu.remove(); fn(); });
b.className = `btn-action xlsx-structure-item${danger ? " xlsx-context-danger" : ""}`;
b.setAttribute("role", "menuitem");
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
b.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = label;
b.appendChild(lab);
b.addEventListener("click", () => {
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; }
fn();
});
menu.appendChild(b);
};
item(t("xlsx.sheet_add"), async () => {
item("file-plus", t("xlsx.sheet_add"), async () => {
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
@@ -2311,7 +2328,7 @@ export function renderXlsxViewer(area, data) {
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_rename"), async () => {
item("pencil", t("xlsx.sheet_rename"), async () => {
const to = await showPrompt({
title: t("xlsx.sheet_rename"),
message: t("xlsx.structure_prompt_rename"),
@@ -2321,7 +2338,7 @@ export function renderXlsxViewer(area, data) {
const actions = [{ op: "sheet_rename", from: sheetName, to }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_duplicate"), async () => {
item("copy", t("xlsx.sheet_duplicate"), async () => {
const as = await showPrompt({
title: t("xlsx.sheet_duplicate"),
message: t("xlsx.structure_prompt_add"),
@@ -2331,7 +2348,7 @@ export function renderXlsxViewer(area, data) {
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_delete"), async () => {
item("trash-2", t("xlsx.sheet_delete"), async () => {
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
const okDelete = await showConfirm({
title: t("xlsx.sheet_delete"),
@@ -2342,14 +2359,14 @@ export function renderXlsxViewer(area, data) {
if (!okDelete) return;
const actions = [{ op: "sheet_delete", name: sheetName }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
}, true);
if (parsed) {
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
item(t("xlsx.row_insert"), async () => {
item("rows", 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 () => {
item("trash-2", t("xlsx.row_delete"), async () => {
const okRow = await showConfirm({
title: t("xlsx.row_delete"),
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
@@ -2359,12 +2376,12 @@ export function renderXlsxViewer(area, data) {
if (!okRow) 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 () => {
}, true);
item("columns", 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 () => {
item("trash-2", t("xlsx.col_delete"), async () => {
const okCol = await showConfirm({
title: t("xlsx.col_delete"),
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
@@ -2374,9 +2391,21 @@ export function renderXlsxViewer(area, data) {
if (!okCol) return;
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
}, true);
}
enableArrowNav(menu, ".xlsx-structure-item");
e.target.closest(".xlsx-toolbar").appendChild(menu);
safeCreateIcons();
structureBtn.setAttribute("aria-expanded", "true");
// #179 — close on outside click, focus loss, Escape, scroll, resize.
dismissStructureMenu = trackDismissable(menu, {
toggle: structureBtn,
onClose: () => {
dismissStructureMenu = null;
structureBtn.setAttribute("aria-expanded", "false");
menu.remove();
},
});
});
// ── #156-A11 — sortie : CSV / Markdown / HTML / impression ────────────
@@ -2491,14 +2520,34 @@ export function renderXlsxViewer(area, data) {
}
};
// #179 — the export menu used to stay open until its button was clicked
// again; it now closes on outside click, focus loss, Escape, scroll, resize.
let dismissExportMenu = null;
const exportMenu = area.querySelector("#xlsx-export-menu");
if (exportMenu) {
area.querySelector("#xlsx-export-btn").addEventListener("click", () => {
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
const exportBtn = area.querySelector("#xlsx-export-btn");
if (exportMenu && exportBtn) {
const hideExportMenu = () => {
if (dismissExportMenu) { dismissExportMenu(); dismissExportMenu = null; }
exportMenu.style.display = "none";
exportBtn.setAttribute("aria-expanded", "false");
};
exportBtn.addEventListener("click", () => {
if (exportMenu.style.display === "block") { hideExportMenu(); return; }
exportMenu.style.display = "block";
exportBtn.setAttribute("aria-expanded", "true");
dismissExportMenu = trackDismissable(exportMenu, {
toggle: exportBtn,
onClose: () => {
dismissExportMenu = null;
exportMenu.style.display = "none";
exportBtn.setAttribute("aria-expanded", "false");
},
});
});
enableArrowNav(exportMenu, "[data-xlsx-export]");
exportMenu.querySelectorAll("[data-xlsx-export]").forEach((item) => {
item.addEventListener("click", () => {
exportMenu.style.display = "none";
hideExportMenu();
if (item.dataset.xlsxExport === "print") printSheet();
else exportDocument(item.dataset.xlsxExport);
});
@@ -2575,19 +2624,36 @@ export function renderXlsxViewer(area, data) {
return Boolean(td && td.style.textDecoration === "underline");
};
// #179 — tracked dismissal of the format menu (outside, focus loss…).
let dismissFormatMenu = null;
const formatBtn = area.querySelector("#xlsx-format-btn");
if (formatBtn) formatBtn.addEventListener("click", () => {
const old = area.querySelector(".xlsx-format-menu");
if (old) { old.remove(); return; }
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; return; }
const sheetName = sheets[visibleSheetIndex()]?.name || "";
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu xlsx-format-menu";
const item = (label, fn) => {
menu.setAttribute("role", "menu");
// #179 — representative icon per entry.
const item = (iconName, 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(); });
b.setAttribute("role", "menuitem");
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
b.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = label;
b.appendChild(lab);
b.addEventListener("click", () => {
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; }
fn();
});
menu.appendChild(b);
};
const sep = () => menu.appendChild(
@@ -2598,22 +2664,29 @@ export function renderXlsxViewer(area, data) {
]);
const numFormat = (fmt) => cellStyle({ number_format: fmt });
item(t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
item(t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
item(t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
item(t("xlsx.format_clear"), cellStyle({
item("bold", t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
item("italic", t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
item("underline", t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
item("remove-formatting", t("xlsx.format_clear"), cellStyle({
bold: false, italic: false, underline: false,
font_color: "", fill_color: "", align: "left", number_format: "General",
}));
sep();
item(t("xlsx.format_align_left"), cellStyle({ align: "left" }));
item(t("xlsx.format_align_center"), cellStyle({ align: "center" }));
item(t("xlsx.format_align_right"), cellStyle({ align: "right" }));
item("align-left", t("xlsx.format_align_left"), cellStyle({ align: "left" }));
item("align-center", t("xlsx.format_align_center"), cellStyle({ align: "center" }));
item("align-right", t("xlsx.format_align_right"), cellStyle({ align: "right" }));
sep();
// Colours come from the native picker; applied once when it closes.
const colour = (label, apply) => {
const colour = (iconName, label, apply) => {
const row = document.createElement("label");
row.className = "xlsx-format-colour";
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
row.appendChild(ic);
}
const span = document.createElement("span");
span.textContent = label;
const input = document.createElement("input");
@@ -2623,21 +2696,21 @@ export function renderXlsxViewer(area, data) {
row.appendChild(input);
menu.appendChild(row);
};
colour(t("xlsx.format_font_color"), (e) => runStyle([
colour("palette", t("xlsx.format_font_color"), (e) => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { font_color: e.target.value } },
]));
colour(t("xlsx.format_fill_color"), (e) => runStyle([
colour("paint-bucket", t("xlsx.format_fill_color"), (e) => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { fill_color: e.target.value } },
]));
sep();
item(t("xlsx.format_num_general"), numFormat("General"));
item(t("xlsx.format_num_number"), numFormat("0.00"));
item(t("xlsx.format_num_percent"), numFormat("0.0%"));
item(t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
item(t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
item(t("xlsx.format_num_text"), numFormat("@"));
item("type", t("xlsx.format_num_general"), numFormat("General"));
item("hash", t("xlsx.format_num_number"), numFormat("0.00"));
item("percent", t("xlsx.format_num_percent"), numFormat("0.0%"));
item("euro", t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
item("calendar", t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
item("pilcrow", t("xlsx.format_num_text"), numFormat("@"));
sep();
item(t("xlsx.format_merge"), () => {
item("combine", t("xlsx.format_merge"), () => {
const b = selectionBounds();
if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) {
showToast(t("xlsx.format_merge_needs_range"), "info");
@@ -2645,16 +2718,16 @@ export function renderXlsxViewer(area, data) {
}
runStyle([{ op: "merge", sheet: sheetName, range: rangeLabel() }]);
});
item(t("xlsx.format_unmerge"), () => {
item("ungroup", t("xlsx.format_unmerge"), () => {
runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]);
});
sep();
item(t("xlsx.format_freeze"), () => {
item("snowflake", t("xlsx.format_freeze"), () => {
runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]);
});
item(t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
item("sun", t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
sep();
item(t("xlsx.format_col_width"), async () => {
item("move-horizontal", t("xlsx.format_col_width"), async () => {
const b = selectionBounds();
const cols = [];
if (b && selection && selection.panel === visiblePanel()) {
@@ -2672,7 +2745,7 @@ export function renderXlsxViewer(area, data) {
if (!answer || !Number.isFinite(width) || width < 0 || width > 255) return;
runStyle(cols.map((col) => ({ op: "col_width", sheet: sheetName, col, width })));
});
item(t("xlsx.format_row_height"), async () => {
item("move-vertical", t("xlsx.format_row_height"), async () => {
const b = selectionBounds();
const rows = [];
if (b && selection && selection.panel === visiblePanel()) {
@@ -2690,7 +2763,19 @@ export function renderXlsxViewer(area, data) {
if (!answer || !Number.isFinite(height) || height < 0 || height > 409) return;
runStyle(rows.map((row) => ({ op: "row_height", sheet: sheetName, row, height })));
});
enableArrowNav(menu, ".xlsx-structure-item");
formatBtn.closest(".xlsx-toolbar").appendChild(menu);
safeCreateIcons();
formatBtn.setAttribute("aria-expanded", "true");
// #179 — close on outside click, focus loss, Escape, scroll, resize.
dismissFormatMenu = trackDismissable(menu, {
toggle: formatBtn,
onClose: () => {
dismissFormatMenu = null;
formatBtn.setAttribute("aria-expanded", "false");
menu.remove();
},
});
});
// ══ #155 — Excel-like selection & context menu ═════════════════════════
@@ -3228,27 +3313,28 @@ export function renderXlsxViewer(area, data) {
const colCount = b.c2 - b.c1 + 1;
const items = [];
// #156-A5 — clipboard entries first, in the spreadsheet order.
items.push({ label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
items.push({ label: t("xlsx.copy"), onClick: () => copySelection() });
items.push({ label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
// #179 — every entry carries a representative Lucide icon.
items.push({ icon: "scissors", label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
items.push({ icon: "copy", label: t("xlsx.copy"), onClick: () => copySelection() });
items.push({ icon: "clipboard-paste", label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
items.push({ separator: true });
if (editable) {
items.push({ label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ icon: "rows", label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ icon: "rows", label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ icon: "columns", label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ icon: "columns", label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ separator: true });
items.push({ label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ icon: "trash-2", label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ icon: "trash-2", label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ separator: true });
}
if (!isCsv) {
items.push({ label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
items.push({ icon: "arrow-up", label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ icon: "arrow-down", label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
}
if (editable) {
items.push({ separator: true });
items.push({ label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
items.push({ icon: "eraser", label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
}
return items;
};
+9 -9
View File
@@ -23,7 +23,7 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
).join("")}${editable
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}"><i data-lucide="plus" style="width:14px;height:14px" aria-hidden="true"></i></button>`
: ""}</div>`;
// Status pills make the viewer's limits visible up front.
@@ -55,10 +55,10 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
}
if (editable) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}">
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="paintbrush" style="width:14px;height:14px"></i>
</button>
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
</button>
</span>`);
@@ -72,16 +72,16 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
</button>
<span class="export-dropdown xlsx-export-dropdown">
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}">
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
</button>
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none">
<button class="export-menu-item" type="button" data-xlsx-export="md">${escapeHtml(t("xlsx.export_md"))}</button>
<button class="export-menu-item" type="button" data-xlsx-export="html">${escapeHtml(t("xlsx.export_html"))}</button>
<button class="export-menu-item" type="button" data-xlsx-export="print">${escapeHtml(t("xlsx.print"))}</button>
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none" role="menu">
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="md"><i data-lucide="file-text" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_md"))}</span></button>
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="html"><i data-lucide="code" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_html"))}</span></button>
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="print"><i data-lucide="printer" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.print"))}</span></button>
</div>
</span>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled><i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>
</span>`);
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
+135 -11
View File
@@ -1,18 +1,30 @@
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
/* ObsiGate — generic themed context menu for the XLSX grid (#155, #179).
*
* Builds a small menu anchored at a screen position, closes it on outside
* click / Escape / window blur. Labels and callbacks come from the caller, so
* no i18n lives here.
* Builds a small menu anchored at a screen position. Labels, icons and
* callbacks come from the caller, so no i18n lives here.
*
* #179 — every menu entry carries a representative Lucide icon, the menu
* closes as soon as the focus leaves it (outside click, focusout, Escape,
* window blur, scroll, resize) and arrow keys walk through the entries.
* trackDismissable() exposes the same dismissal contract to the other
* spreadsheet menus (Structure, Mise en forme, Export), which keep their
* own markup.
*/
import { safeCreateIcons } from '../utils.js';
let _menu = null;
let _openedAt = 0;
let _dismissBound = false;
export function closeContextMenu() {
if (_menu) {
_menu.remove();
_menu = null;
}
// BUG-108: drop the reference BEFORE touching the DOM — removing a menu
// that holds the focus fires blur/focusout synchronously, which re-enters
// this very function; the second remove() on the mid-flight node throws
// NotFoundError and aborts the caller's click handler before its action
// runs (contextual « Coller » silently dead, e2e xlsx-viewer A5).
const menu = _menu;
_menu = null;
if (menu) menu.remove();
}
export function isContextMenuOpen() {
@@ -33,13 +45,106 @@ function bindDismiss() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeContextMenu();
});
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
// #179 — never linger once the focus moves outside the menu (toolbar
// button, editor, another panel…). A null relatedTarget (click on a
// non-focusable cell) is already covered by the click-outside handler.
document.addEventListener("focusout", (e) => {
if (!_menu) return;
const next = e.relatedTarget;
if (next && _menu.contains(next)) return;
closeContextMenu();
});
if (typeof window !== "undefined") {
window.addEventListener("blur", closeContextMenu);
// A scroll or resize detaches the viewport-anchored menu from its cell.
window.addEventListener("scroll", closeContextMenu, true);
window.addEventListener("resize", closeContextMenu);
}
}
/**
* Walk through a menu's buttons with the keyboard (#179).
* @param {HTMLElement} menuEl - menu container
* @param {string} itemSelector - CSS selector of the navigable entries
*/
export function enableArrowNav(menuEl, itemSelector) {
menuEl.addEventListener("keydown", (e) => {
const items = [...menuEl.querySelectorAll(itemSelector)].filter((b) => !b.disabled);
if (!items.length) return;
const idx = items.indexOf(document.activeElement);
let next = -1;
if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % items.length;
else if (e.key === "ArrowUp") next = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
else return;
e.preventDefault();
items[next].focus();
});
}
/**
* Dismissal contract shared by the spreadsheet's other menus (#179).
* Closes on outside click (the toggle button owns its own clicks), Escape,
* focus leaving both the menu and its toggle, scroll and resize.
* @param {HTMLElement} menuEl - menu element to watch
* @param {object} opts
* @param {HTMLElement|null} opts.toggle - button opening the menu (exempt)
* @param {Function|null} opts.onClose - called once on dismissal (default: remove the element)
* @returns {Function} dismiss() — idempotent manual close (unbinds + onClose)
*/
export function trackDismissable(menuEl, { toggle = null, onClose = null } = {}) {
let done = false;
const unbind = () => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKey);
document.removeEventListener("focusout", onFocusOut);
if (typeof window !== "undefined") {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
}
};
const dismiss = () => {
if (done) return;
done = true;
unbind();
if (onClose) onClose();
else menuEl.remove();
};
const onDocClick = (e) => {
if (done) return;
if (!menuEl.isConnected) { done = true; unbind(); return; }
const t = e.target;
if (menuEl.contains(t)) return;
if (toggle && (toggle === t || toggle.contains(t))) return;
dismiss();
};
const onKey = (e) => {
if (e.key === "Escape" && menuEl.isConnected) dismiss();
};
const onFocusOut = (e) => {
if (done || !menuEl.isConnected) return;
const next = e.relatedTarget;
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
dismiss();
};
const onScroll = () => {
if (menuEl.isConnected) dismiss();
};
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
document.addEventListener("focusout", onFocusOut);
if (typeof window !== "undefined") {
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
}
return dismiss;
}
/**
* @param {number} x - viewport X of the pointer
* @param {number} y - viewport Y of the pointer
* @param {Array<{label?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @returns {HTMLElement} the menu element
*/
export function openContextMenu(x, y, items) {
@@ -52,6 +157,7 @@ export function openContextMenu(x, y, items) {
if (item.separator) {
const sep = document.createElement("div");
sep.className = "xlsx-context-sep";
sep.setAttribute("aria-hidden", "true");
menu.appendChild(sep);
continue;
}
@@ -59,7 +165,17 @@ export function openContextMenu(x, y, items) {
btn.type = "button";
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
if (item.icon) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", item.icon);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
btn.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = item.label;
btn.appendChild(lab);
btn.disabled = Boolean(item.disabled);
btn.addEventListener("click", () => {
closeContextMenu();
@@ -67,6 +183,7 @@ export function openContextMenu(x, y, items) {
});
menu.appendChild(btn);
}
enableArrowNav(menu, ".xlsx-context-item");
document.body.appendChild(menu);
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
@@ -81,5 +198,12 @@ export function openContextMenu(x, y, items) {
_menu = menu;
_openedAt = Date.now();
// Keyboard users land on the first entry right away; mouse users are
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
if (first && typeof first.focus === "function") {
try { first.focus({ preventScroll: true }); } catch (_) { first.focus(); }
}
if (typeof safeCreateIcons === "function") safeCreateIcons();
return menu;
}
+15 -3
View File
@@ -90,8 +90,6 @@
"ai.confirm_actions": "{count} actions to approve",
"ai.stop": "Stop the assistant",
"ai.stopped": "Run stopped.",
"ai.agent_mode_off": "Agent mode off (read/search + actions)",
"ai.agent_mode_on": "Agent mode on (read/search tools + actions)",
"ai.casual": "Casual tone",
"ai.close": "Close assistant",
"ai.completion_added": "AI: completion added",
@@ -512,6 +510,13 @@
"config.section_format-du-payload": "Format du payload",
"config.section_gestion-des-onglets": "📑 Gestion des onglets",
"config.section_hidden": "🗂️ Hidden files",
"config.section_desktop": "🖥️ Vaults & folders (Desktop)",
"config.desktop_roots_desc": "Add or remove the vaults and root folders loaded by the desktop application (local config removal only — no file is deleted).",
"desktop.remove_root": "Remove from app",
"desktop.remove_root_confirm": "Remove “{name}” from ObsiGate Desktop? No files will be deleted.",
"desktop.add_vault": "📁 Add a vault",
"desktop.add_dir": "📂 Add a folder",
"desktop.roots_empty": "No vault or folder configured.",
"config.section_historique-recent-redemarrage-non-requis": "📋 Recent History\n No restart required",
"config.section_indicateurs-visuels": "Indicateurs visuels",
"config.section_intelligence-artificielle-dans-l-editeur": "🤖 AI in the Editor",
@@ -1358,7 +1363,7 @@
"help.assistant_insert": "The \"Add\" button (revealed on hover of an answer) inserts the answer into the document open in the editor (Editer or Forge); each code block offers \"Add section\" to insert just that block.",
"help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.",
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
"help.assistant_agent": "The assistant always uses its tools (read, list, search, modify); modifying actions require confirmation with a change preview.",
"help.assistant_agent_run": "Actions appear in the thread: the assistant groups modifications into a single approval (\"Approve all\") and refreshes the file tree and the open document as soon as they are applied. The send button becomes \"Stop\" to interrupt the run at any time.",
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
@@ -2138,6 +2143,13 @@
"ai.step.docx_create": "Word document proposed: {value}",
"ai.step.csv_create": "CSV file proposed: {value}",
"ai.step.pdf_create": "PDF document proposed: {value}",
"ai.step.duplicates": "Searched duplicates: {value}",
"ai.step.duplicates_merge": "Merged notes: {value}",
"ai.step.notify": "Notification sent: {value}",
"ai.step.schedule_create": "Scheduled task: {value}",
"ai.step.schedule_list": "Listed scheduled tasks",
"ai.step.schedule_delete": "Deleted task: {value}",
"ai.step.schedule_run": "Ran task: {value}",
"bookslm.copied": "Copied to clipboard",
"bookslm.error": "AI service error",
"bookslm.regenerate": "Regenerate",
+15 -3
View File
@@ -90,8 +90,6 @@
"ai.confirm_actions": "{count} actions à approuver",
"ai.stop": "Arrêter l'assistant",
"ai.stopped": "Exécution arrêtée.",
"ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)",
"ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)",
"ai.casual": "Ton décontracté",
"ai.close": "Fermer l'assistant",
"ai.completion_added": "AI: complétion ajoutée",
@@ -512,6 +510,13 @@
"config.section_format-du-payload": "Format du payload",
"config.section_gestion-des-onglets": "📑 Gestion des onglets",
"config.section_hidden": "🗂️ Fichiers cachés",
"config.section_desktop": "🖥️ Vaults & dossiers (Desktop)",
"config.desktop_roots_desc": "Ajoutez ou retirez les vaults et dossiers racine chargés par l'application desktop (retrait local de la configuration, aucun fichier supprimé).",
"desktop.remove_root": "Retirer de l'application",
"desktop.remove_root_confirm": "Retirer « {name} » de l'application ObsiGate Desktop ? Les fichiers ne seront pas supprimés.",
"desktop.add_vault": "📁 Ajouter un vault",
"desktop.add_dir": "📂 Ajouter un dossier",
"desktop.roots_empty": "Aucun vault ou dossier configuré.",
"config.section_historique-recent-redemarrage-non-requis": "📋 Historique récent\n Redémarrage non requis",
"config.section_indicateurs-visuels": "Indicateurs visuels",
"config.section_intelligence-artificielle-dans-l-editeur": "🤖 Intelligence Artificielle dans l'Éditeur",
@@ -1358,7 +1363,7 @@
"help.assistant_insert": "Le bouton « Ajouter » (au survol d'une réponse) insère la réponse dans le document ouvert dans l'éditeur (Editer ou Forge) ; chaque bloc de code propose « Ajouter la section » pour n'insérer que ce bloc.",
"help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.",
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
"help.assistant_agent": "L'assistant utilise toujours ses outils (lire, lister, chercher, modifier) ; les actions de modification demandent une confirmation avec aperçu des changements.",
"help.assistant_agent_run": "Les actions s'affichent dans le fil : l'assistant regroupe les modifications en une seule approbation (« Tout approuver ») et met à jour l'arborescence et le document ouvert dès qu'elles sont appliquées. Le bouton d'envoi devient « Stop » pour interrompre l'exécution à tout moment.",
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
@@ -2138,6 +2143,13 @@
"ai.step.docx_create": "Document Word proposé : {value}",
"ai.step.csv_create": "Fichier CSV proposé : {value}",
"ai.step.pdf_create": "Document PDF proposé : {value}",
"ai.step.duplicates": "Doublons recherchés : {value}",
"ai.step.duplicates_merge": "Notes fusionnées : {value}",
"ai.step.notify": "Notification envoyée : {value}",
"ai.step.schedule_create": "Tâche planifiée : {value}",
"ai.step.schedule_list": "Tâches planifiées consultées",
"ai.step.schedule_delete": "Tâche supprimée : {value}",
"ai.step.schedule_run": "Tâche exécutée : {value}",
"bookslm.copied": "Réponse copiée dans le presse-papiers",
"bookslm.error": "Erreur du service AI",
"bookslm.regenerate": "Régénérer",
+128 -2
View File
@@ -5041,6 +5041,15 @@ body.resizing-v {
min-height: 44px;
min-width: 44px;
}
#config-modal .desktop-roots-item {
flex-wrap: wrap;
}
#config-modal .desktop-roots-item .config-btn-sm {
min-height: 44px;
}
#config-modal .desktop-roots-actions {
flex-wrap: wrap;
}
#config-modal .hidden-files-add-row {
flex-wrap: wrap;
}
@@ -11323,6 +11332,7 @@ body.desktop-mode .editor-container {
top: 100%;
z-index: 1000;
min-width: 200px;
max-width: 86vw;
padding: 4px;
background: var(--bg, #fff);
border: 1px solid var(--border);
@@ -11401,6 +11411,7 @@ body.desktop-mode .editor-container {
flex-direction: column;
gap: 2px;
min-width: 240px;
max-width: min(320px, 92vw);
margin-top: 4px;
padding: 6px;
border: 1px solid var(--border);
@@ -11599,6 +11610,32 @@ body.desktop-mode .editor-container {
background: var(--border);
}
/* #179 — shared menu-entry icon (grid context menu, structure, format, export). */
.xlsx-menu-icon {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.xlsx-menu-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.xlsx-context-item,
.xlsx-structure-item,
.xlsx-export-menu .export-menu-item {
display: flex;
align-items: center;
gap: 8px;
}
.xlsx-context-item:focus:not(:disabled),
.xlsx-structure-item:focus:not(:disabled),
.xlsx-export-menu .export-menu-item:focus {
background: var(--bg-hover);
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
/* #155 — Excel-like range selection + right-click / long-press context menu. */
.xlsx-table tbody td.xlsx-selected {
background: var(--accent-bg);
@@ -11616,12 +11653,30 @@ body.desktop-mode .editor-container {
display: flex;
flex-direction: column;
min-width: 220px;
max-width: min(320px, 86vw);
max-height: 80vh;
overflow-y: auto;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
}
/* #179 — touch-sized entries + viewport-bound menus on coarse pointers. */
@media (pointer: coarse) {
.xlsx-context-item,
.xlsx-structure-item,
.xlsx-export-menu .export-menu-item {
min-height: 44px;
font-size: 0.9rem;
}
.xlsx-context-menu {
min-width: min(260px, 86vw);
}
.xlsx-structure-menu {
max-width: 92vw;
}
}
.xlsx-context-item {
text-align: left;
border: none;
@@ -11963,6 +12018,79 @@ mark {
.webauthn-key-meta { font-size: 0.75rem; color: var(--text-muted, #888); }
.config-btn-sm { padding: 4px 10px; font-size: 0.78rem; }
/* ── #160 : Section Configuration — Vaults & dossiers (Desktop) ──────────── */
.desktop-roots-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.desktop-roots-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--surface2, #1a1a2e);
border: 1px solid var(--border, transparent);
border-radius: 6px;
transition: border-color 150ms;
}
.desktop-roots-item:hover {
border-color: var(--accent);
}
.desktop-roots-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--accent);
}
.desktop-roots-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.desktop-roots-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--text, #fff);
}
.desktop-roots-path {
font-size: 0.75rem;
color: var(--text-muted, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.desktop-roots-item .config-btn-sm {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
white-space: nowrap;
}
.desktop-roots-btn-icon {
width: 14px;
height: 14px;
}
.desktop-roots-empty {
padding: 12px;
font-size: 0.85rem;
color: var(--text-muted, #888);
background: var(--surface2, #1a1a2e);
border-radius: 6px;
}
.desktop-roots-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.desktop-roots-actions .config-btn-sm {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* MFA Disable card */
.mfa-disable-card {
padding: 16px; border-radius: 10px;
@@ -12116,8 +12244,6 @@ mark {
background: var(--accent); color: #fff; cursor: pointer; font-size: 12px; flex-shrink: 0; }
.bookslm-action-apply:disabled { opacity: 0.6; cursor: default; }
.bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
/* Agent-mode toggle in the header. */
.bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); }
/* Tool-call trace shown above an assistant answer (agent mode).
The header is the live indicator (« ●●● 3 étapes ▶ »); expanding it reveals
the chronological steps, thinking notes as « Réflexion » sub-sections and
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.49.2",
"version": "2.53.3",
"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": {
+88
View File
@@ -20,6 +20,10 @@
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
* with the real A1 coordinates (#153 A9).
*
* Third block (#179) : menus carry one Lucide icon per entry and close on
* focus loss (outside click, Escape); mobile viewport checks the ribbon
* wrapping and 44px touch targets.
*
* The fixture is restored byte-for-byte in `afterAll` so a local run never
* dirties the working copy.
*
@@ -314,3 +318,87 @@ test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)',
expect(await textOf('C10')).toBe(src.c10);
});
});
// ── #179 — icônes des menus, fermeture au focus, polish mobile ──────────────
test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)', () => {
test('chaque entrée du menu contextuel porte une icône', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
const menu = page.locator('.xlsx-context-menu');
await expect(menu).toBeVisible();
// Icône rendue par Lucide (svg) ou à rendre (<i data-lucide>) si le CDN
// est injoignable — dans les deux cas l'entrée est iconisée.
for (const label of ['Couper', 'Copier', 'Coller']) {
const entry = menu.locator('.xlsx-context-item', { hasText: label });
await expect(entry.locator('i[data-lucide], svg')).toHaveCount(1);
}
await expect(menu.locator('.xlsx-context-item i[data-lucide="trash-2"], .xlsx-context-item svg.lucide-trash-2')).not.toHaveCount(0);
});
test('le menu contextuel se ferme au clic extérieur et à Échap', async ({ page }) => {
await login(page);
await openFixture(page);
const cell = page.locator('#content-area td[data-cell="A1"]');
await cell.click({ button: 'right' });
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
await page.locator('#content-area .xlsx-table').click({ position: { x: 5, y: 5 } });
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
await cell.click({ button: 'right' });
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
});
test('le menu Structure se ferme quand le focus le quitte', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#xlsx-structure-btn').click();
const menu = page.locator('.xlsx-structure-menu');
await expect(menu).toBeVisible();
await expect(menu.locator('.xlsx-structure-item i[data-lucide], .xlsx-structure-item svg').first()).toBeAttached();
// Clic dans la grille = le focus quitte le menu → fermeture.
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(page.locator('.xlsx-structure-menu')).toHaveCount(0);
await expect(page.locator('#xlsx-structure-btn')).toHaveAttribute('aria-expanded', 'false');
});
test('le menu Export se ferme au clic extérieur', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#xlsx-export-btn').click();
const menu = page.locator('#xlsx-export-menu');
await expect(menu).toBeVisible();
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(menu).toBeHidden();
});
test('mobile : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
await login(page);
await page.evaluate(() => window.TabManager.open('TestVault', FIXTURE));
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
// Le ruban s'enroule au lieu de déborder horizontalement.
const overflow = await page.evaluate(() => {
const bar = document.querySelector('#content-area .xlsx-cmdbar');
return bar ? bar.scrollWidth - bar.clientWidth : -1;
});
expect(overflow).toBeLessThanOrEqual(1);
// Menu contextuel (clic droit émulé) : tient dans le viewport, entrées ≥ 44px.
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
const menu = page.locator('.xlsx-context-menu');
await expect(menu).toBeVisible();
const box = await menu.boundingBox();
expect(box.width).toBeLessThanOrEqual(viewport.width - 8);
const itemBox = await menu.locator('.xlsx-context-item').first().boundingBox();
expect(itemBox.height).toBeGreaterThanOrEqual(44);
});
});
+3 -4
View File
@@ -114,10 +114,9 @@ await test("the 4 scenarios of the design table map to the expected actions", ()
assert.deepEqual(suggestionsFor("selection").map((a) => a.id), ["concise", "fix_style", "explain_selection"]);
});
await test("frontmatter actions are agent-mode (they mutate the document)", () => {
assert.equal(getAction("frontmatter").agent, true);
assert.equal(getAction("frontmatter_update").agent, true);
assert.ok(!getAction("summarize_3").agent);
await test("#187: frontmatter actions need no agent-mode flag (always agent)", () => {
assert.equal(getAction("frontmatter").agent, undefined);
assert.equal(getAction("frontmatter_update").agent, undefined);
});
// ── i18n completeness (FR + EN) ────────────────────────────────────────────
+15 -26
View File
@@ -159,7 +159,8 @@ async function main() {
read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }),
};
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/chat")) {
// #187: plain-text sends now go to /agent — accept both bookslm endpoints.
if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) {
capturedBody = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => reader } };
}
@@ -1171,38 +1172,28 @@ async function main() {
assert.equal(b._messages[0].content, "Ancienne conversation");
});
// ── 14. Agent mode & confirmations (B5) ──
await test("agent mode toggle persists and switches the endpoint", async () => {
// ── 14. Agent mode & confirmations (B5 / #187) ──
await test("#187: no agent toggle — plain requests always target /agent", async () => {
localStorage.clear();
const b = new BooksLM();
assert.equal(b._agentMode, false, "agent mode off by default");
b._toggleAgentMode();
assert.equal(b._agentMode, true);
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true");
const b2 = new BooksLM();
assert.equal(b2._agentMode, true, "persisted across instances");
assert.equal(b._agentMode, undefined, "agent-mode state removed");
assert.equal(b._toggleAgentMode, undefined, "toggle method removed");
let url = null;
globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; };
b2._abortCtrl = null;
await b2._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent");
b2._agentMode = false;
await b2._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat");
b._abortCtrl = null;
await b._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/agent"), "default targets /agent");
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), null, "no legacy persistence");
localStorage.clear();
});
await test("header exposes the agent-mode toggle", () => {
await test("header no longer exposes the agent-mode toggle", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
const btn = panel.querySelector(".bookslm-btn-agent");
assert.ok(btn, "agent toggle button present");
assert.equal(btn.getAttribute("aria-pressed"), "false");
btn.click();
assert.equal(btn.getAttribute("aria-pressed"), "true");
assert.equal(panel.querySelector(".bookslm-btn-agent"), null, "toggle button removed");
panel.remove();
localStorage.clear();
});
@@ -1236,7 +1227,6 @@ async function main() {
};
const b = new BooksLM();
b._agentMode = true;
b._panel = b._render();
document.body.appendChild(b._panel);
const msg = {
@@ -1418,7 +1408,7 @@ async function main() {
let posted = null;
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body);
if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) posted = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
};
await b._sendMessage();
@@ -1432,10 +1422,9 @@ async function main() {
localStorage.clear();
});
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ──
await test("images route to /chat even in agent mode", async () => {
// ── 32. Images force the plain chat endpoint (#81 / #187: always-on agent) ──
await test("images route to /chat, text to /agent", async () => {
const b = new BooksLM();
b._agentMode = true;
let url = "";
globalThis.fetch = async (u) => {
url = String(u);
+75
View File
@@ -0,0 +1,75 @@
// Tests unitaires — gestion desktop des vaults & dossiers (#159)
// Helpers purs de frontend/js/desktop.js (module sans import, importable en Node).
//
// Usage: node tests/frontend/desktop-roots.test.mjs
import { strict as assert } from 'assert';
import {
resolveRootKind,
lastPathSegment,
removeRoot,
pickAndAddDir,
} from '../../frontend/js/desktop.js';
const vaults = [
{ name: 'Obsidian', path: 'C:/Users/x/voute_obsidian' },
{ name: 'Work', path: 'D:/work' },
];
const dirs = [{ name: 'Bruno-Download', path: 'C:/Users/x/Downloads' }];
function testResolveRootKind() {
assert.equal(resolveRootKind(vaults, dirs, 'Obsidian'), 'vault');
assert.equal(resolveRootKind(vaults, dirs, 'Bruno-Download'), 'dir');
assert.equal(resolveRootKind(vaults, dirs, 'inconnu'), null);
// Listes absentes (invoke en échec → undefined) : pas de crash.
assert.equal(resolveRootKind(undefined, undefined, 'Obsidian'), null);
// Un nom ne peut être que vault OU dir (les deux listes sont disjointes).
assert.equal(resolveRootKind(vaults, dirs, 'Work'), 'vault');
console.log(' ✓ resolveRootKind');
}
function testLastPathSegment() {
assert.equal(lastPathSegment('C:\\Obsidian_doc\\Obsidian-Sandbox'), 'Obsidian-Sandbox');
assert.equal(lastPathSegment('C:/vaults/perso/'), 'perso');
assert.equal(lastPathSegment('/home/bruno/vault'), 'vault');
assert.equal(lastPathSegment('/'), '');
assert.equal(lastPathSegment('C:\\'), 'C:');
console.log(' ✓ lastPathSegment');
}
async function testDesktopGating() {
// Hors desktop (pas de window.__TAURI__) : tout est un no-op silencieux —
// c'est ce qui protège l'app web (Docker) où la section est masquée.
assert.equal(await removeRoot('Obsidian', 'confirmer ?'), undefined);
assert.equal(await pickAndAddDir(), undefined);
assert.equal(await removeRoot('', 'x'), undefined);
console.log(' ✓ gating hors desktop');
}
// Le nom dérivé par pickAndAddDir/pickAndAddVault doit être un segment simple
// (jamais un chemin complet) : réutilisé telle quelle par add_dir/add_vault.
function testDerivedNameIsBasename() {
for (const p of ['C:\\Obsidian_doc\\Obsidian-Sandbox', '/home/bruno/Downloads/']) {
const name = lastPathSegment(p) || 'Dossier';
assert.ok(!name.includes('/') && !name.includes('\\'), `basename expected for ${p}`);
}
console.log(' ✓ derived name is a basename');
}
const tests = [
testResolveRootKind,
testLastPathSegment,
testDesktopGating,
testDerivedNameIsBasename,
];
let failed = 0;
for (const fn of tests) {
try {
await fn();
} catch (e) {
failed++;
console.error(` ✗ ${fn.name}: ${e.message}`);
}
}
console.log(failed === 0 ? `${tests.length} passed, 0 failed` : `${tests.length - failed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM tests for the spreadsheet menus polish (ROADMAP #179).
*
* Covers frontend/js/xlsx/context-menu.js and frontend/js/xlsx/command-bar.js:
* - every grid-menu entry renders its representative Lucide icon + safe label;
* - the grid menu closes on Escape / outside click / focus loss / scroll /
* resize, and offers arrow-key navigation with autofocus on open;
* - trackDismissable() gives Structure / Mise en forme / Export the same
* dismissal contract (toggle exempt, custom onClose);
* - the ribbon carries the new icons (save, tab "+", export entries).
*
* Usage: node tests/frontend/xlsx-menus.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
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.FocusEvent = w.FocusEvent;
globalThis.KeyboardEvent = w.KeyboardEvent;
globalThis.MouseEvent = w.MouseEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
// ── Module under test ───────────────────────────────────────────────────────
const ctxMenuUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")
).href;
const cmdBarUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js")
).href;
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
await import(ctxMenuUrl);
const { buildCommandBar } = await import(cmdBarUrl);
// ── Mini runner ─────────────────────────────────────────────────────────────
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
closeContextMenu();
document.body.querySelectorAll(".tmp-menu").forEach((m) => m.remove());
try {
await fn();
passCount++;
console.log(` ✓ ${name}`);
} catch (e) {
console.error(` ✗ ${name}`);
console.error(e);
process.exitCode = 1;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const itemsOf = () => [
{ icon: "scissors", label: "Cut" },
{ icon: "copy", label: "Copy", disabled: true },
{ separator: true },
{ icon: "trash-2", label: "Delete", danger: true, onClick: () => {} },
];
// ── Rendering ───────────────────────────────────────────────────────────────
await test("entries render their icon, label, separator and danger style", () => {
const menu = openContextMenu(10, 10, itemsOf());
const btns = [...menu.querySelectorAll(".xlsx-context-item")];
assert.equal(btns.length, 3);
assert.equal(btns[0].querySelector("i").getAttribute("data-lucide"), "scissors");
assert.equal(btns[0].querySelector(".xlsx-menu-label").textContent, "Cut");
assert.equal(btns[1].disabled, true);
assert.ok(btns[2].classList.contains("xlsx-context-danger"));
assert.ok(menu.querySelector(".xlsx-context-sep"), "separator rendered");
assert.equal(menu.getAttribute("role"), "menu");
});
await test("labels are text, never HTML (XSS-safe)", () => {
const menu = openContextMenu(10, 10, [{ icon: "copy", label: "<img src=x onerror=1>" }]);
assert.equal(menu.querySelector("img"), null);
assert.equal(
menu.querySelector(".xlsx-menu-label").textContent,
"<img src=x onerror=1>"
);
});
await test("opening focuses the first enabled entry", () => {
const menu = openContextMenu(10, 10, itemsOf());
const first = menu.querySelectorAll(".xlsx-context-item")[0];
assert.equal(document.activeElement, first);
});
// ── Keyboard navigation ─────────────────────────────────────────────────────
await test("ArrowDown / ArrowUp / Home / End walk through enabled entries", () => {
const menu = openContextMenu(10, 10, itemsOf());
const btns = [...menu.querySelectorAll(".xlsx-context-item")];
const key = (k) =>
menu.dispatchEvent(new w.KeyboardEvent("keydown", { key: k, bubbles: true }));
key("ArrowDown"); // Cut (0, disabled Copy skipped) -> Delete (2)
assert.equal(document.activeElement, btns[2]);
key("ArrowDown"); // wraps to Cut
assert.equal(document.activeElement, btns[0]);
key("ArrowUp"); // back to Delete
assert.equal(document.activeElement, btns[2]);
key("Home");
assert.equal(document.activeElement, btns[0]);
key("End");
assert.equal(document.activeElement, btns[2]);
});
// ── Dismissal ───────────────────────────────────────────────────────────────
await test("Escape closes the menu", () => {
openContextMenu(10, 10, itemsOf());
assert.ok(isContextMenuOpen());
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
assert.ok(!isContextMenuOpen());
assert.equal(document.querySelector(".xlsx-context-menu"), null);
});
await test("outside click closes, inside click does not", async () => {
const menu = openContextMenu(10, 10, itemsOf());
await sleep(300); // past the long-press click guard
menu.querySelector(".xlsx-context-item").dispatchEvent(
new w.MouseEvent("click", { bubbles: true })
);
assert.ok(!isContextMenuOpen(), "item click closes");
openContextMenu(10, 10, itemsOf());
await sleep(300);
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.ok(!isContextMenuOpen(), "outside click closes");
});
await test("focus leaving the menu closes it, focus inside keeps it", () => {
const menu = openContextMenu(10, 10, itemsOf());
const [first, , last] = menu.querySelectorAll(".xlsx-context-item");
first.dispatchEvent(
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: last })
);
assert.ok(isContextMenuOpen(), "focus moving inside keeps the menu");
const outside = document.createElement("button");
document.body.appendChild(outside);
last.dispatchEvent(
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside })
);
assert.ok(!isContextMenuOpen(), "focus moving outside closes the menu");
outside.remove();
});
await test("scroll and resize close the menu", () => {
openContextMenu(10, 10, itemsOf());
window.dispatchEvent(new w.Event("scroll"));
assert.ok(!isContextMenuOpen(), "scroll closes");
openContextMenu(10, 10, itemsOf());
window.dispatchEvent(new w.Event("resize"));
assert.ok(!isContextMenuOpen(), "resize closes");
});
// ── trackDismissable (Structure / Format / Export) ──────────────────────────
function tmpMenu(toggle) {
const menu = document.createElement("div");
menu.className = "tmp-menu";
const b = document.createElement("button");
b.type = "button";
b.textContent = "Action";
menu.appendChild(b);
(toggle.closest("div") || document.body).appendChild(menu);
return menu;
}
await test("trackDismissable: toggle exempt, outside closes with onClose", async () => {
const host = document.createElement("div");
const toggle = document.createElement("button");
host.appendChild(toggle);
document.body.appendChild(host);
const menu = tmpMenu(toggle);
let closed = 0;
const dismiss = trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } });
toggle.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(closed, 0, "toggle click does not dismiss");
assert.ok(menu.isConnected);
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(closed, 1, "outside click dismisses once");
assert.ok(!menu.isConnected);
dismiss(); // idempotent manual close
assert.equal(closed, 1);
host.remove();
});
await test("trackDismissable: Escape and focus loss dismiss", () => {
const toggle = document.createElement("button");
document.body.appendChild(toggle);
const menu = tmpMenu(toggle);
let closed = 0;
trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } });
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
assert.equal(closed, 1, "Escape dismisses");
assert.ok(!menu.isConnected);
const menu2 = tmpMenu(toggle);
let closed2 = 0;
trackDismissable(menu2, { toggle, onClose: () => { closed2++; menu2.remove(); } });
const outside = document.createElement("input");
document.body.appendChild(outside);
menu2.querySelector("button").dispatchEvent(
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside })
);
assert.equal(closed2, 1, "focus loss dismisses");
outside.remove();
toggle.remove();
});
// ── Ribbon icons ────────────────────────────────────────────────────────────
await test("the ribbon carries save, tab-plus and export icons", () => {
const { actionsHtml, tabs } = buildCommandBar({
sheets: [{ name: "F1" }],
isCsv: false,
readOnly: false,
editable: true,
});
const host = document.createElement("div");
host.innerHTML = tabs + actionsHtml;
const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]");
assert.ok(saveIcon, "save button has an icon");
assert.equal(saveIcon.getAttribute("data-lucide"), "save");
const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]");
assert.ok(plusIcon, "tab + button has an icon");
assert.equal(plusIcon.getAttribute("data-lucide"), "plus");
const exportIcons = [...host.querySelectorAll("#xlsx-export-menu i[data-lucide]")].map((i) =>
i.getAttribute("data-lucide")
);
assert.deepEqual(exportIcons, ["file-text", "code", "printer"]);
});
console.log(`\n${passCount}/${testCount} passed`);
process.exit(process.exitCode || 0);
+89
View File
@@ -1867,6 +1867,95 @@ await test("the find scans every sheet and steps across tabs (#156-A11)", () =>
assert.ok(area.querySelector('.xlsx-panel[data-sheet="1"] mark.xlsx-find-current'), "the current hit sits in sheet 2");
});
// ── #179 — menu icons + dismissal at viewer level ─────────────────────────
await test("the grid context menu shows one icon per entry (#179-A1)", () => {
const area = mount();
const menu = openMenuOn(area, "A1");
assert.ok(menu, "menu opens on right-click");
const items = [...menu.querySelectorAll(".xlsx-context-item")];
assert.ok(items.length > 3, "several entries");
for (const b of items) {
const icon = b.querySelector("i[data-lucide]");
assert.ok(icon, `"${b.textContent}" carries an icon`);
assert.match(icon.getAttribute("data-lucide"), /^[a-z0-9-]+$/);
}
assert.equal(
menu.querySelector(".xlsx-context-item .xlsx-menu-label").textContent,
FR["xlsx.cut"]
);
const danger = items.find(
(b) => b.textContent === FR["xlsx.delete_rows"].replace("{n}", "1")
);
assert.ok(danger && danger.querySelector('i[data-lucide="trash-2"]'), "delete reads as danger");
});
await test("the grid context menu closes when the focus leaves it (#179-A3)", () => {
const area = mount();
openMenuOn(area, "A1");
assert.ok(document.querySelector(".xlsx-context-menu"), "menu open");
const first = document.querySelector(".xlsx-context-item");
const outside = document.createElement("button");
document.body.appendChild(outside);
first.dispatchEvent(new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside }));
assert.equal(document.querySelector(".xlsx-context-menu"), null, "menu closed on focus loss");
outside.remove();
});
await test("the structure menu shows icons and closes on outside click (#179-A2/A3)", () => {
const area = mount();
area.querySelector("#xlsx-structure-btn").click();
const menu = area.querySelector(".xlsx-structure-menu");
assert.ok(menu, "structure menu opens");
const icons = [...menu.querySelectorAll(".xlsx-structure-item i[data-lucide]")].map((i) =>
i.getAttribute("data-lucide")
);
assert.ok(icons.includes("file-plus"), "sheet add icon");
assert.ok(icons.includes("trash-2"), "delete icon");
assert.equal(
area.querySelector("#xlsx-structure-btn").getAttribute("aria-expanded"),
"true"
);
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(area.querySelector(".xlsx-structure-menu"), null, "outside click closes");
assert.equal(
area.querySelector("#xlsx-structure-btn").getAttribute("aria-expanded"),
"false"
);
});
await test("the format menu shows icons and closes on Escape (#179-A2/A3)", () => {
const area = mount();
area.querySelector("#xlsx-format-btn").click();
const menu = area.querySelector(".xlsx-format-menu");
assert.ok(menu, "format menu opens");
const find = (icon) => menu.querySelector(`.xlsx-structure-item i[data-lucide="${icon}"]`);
assert.ok(find("bold"), "bold icon");
assert.ok(find("align-center"), "align icon");
assert.ok(menu.querySelector('.xlsx-format-colour i[data-lucide="palette"]'), "font colour icon");
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
assert.equal(area.querySelector(".xlsx-format-menu"), null, "Escape closes");
});
await test("the export menu closes on outside click (#179-A3)", () => {
const area = mount();
area.querySelector("#xlsx-export-btn").click();
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "block", "menu opens");
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "none", "outside click closes");
});
await test("the ribbon save and tab + buttons carry icons (#179-A2)", () => {
const area = mount();
assert.equal(
area.querySelector('#xlsx-save-btn i[data-lucide]').getAttribute("data-lucide"),
"save"
);
assert.equal(
area.querySelector('#xlsx-tab-add i[data-lucide]').getAttribute("data-lucide"),
"plus"
);
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+25
View File
@@ -940,6 +940,31 @@ class TestBooksLMAgentEndpoint:
assert "create_file" in captured["system"]
assert '"action": "create_file"' not in captured["system"]
def test_agent_llm_uses_resolved_provider(self, bookslm_client, monkeypatch):
"""#187: the agent loop must call the provider resolved by
_resolve_provider_name (not the raw request value), so the provider
tag reported by SSE matches the engine that actually answered."""
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse
seen = {}
async def fake_chat_completion(messages, **kwargs):
seen["provider"] = kwargs.get("provider")
return LLMResponse(content="ok")
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "openrouter")
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/agent",
json={"directory": "", "message": "salut", "mode": "general", "provider": "nope"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert seen["provider"] == "openrouter"
def test_agent_tool_call_flow(self, bookslm_client, monkeypatch):
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse, ToolCall
+1
View File
@@ -255,6 +255,7 @@ class TestDependencySecurityFloors:
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
"multidict": (6, 9, 1), # CVE-2026-104874 (fix 6.9.1 ; 6.7.x toolcache runner)
}
def test_security_floors_are_declared(self):
+32
View File
@@ -80,3 +80,35 @@ def test_nonce_matches_injected_html(client):
nonce = _nonce_of(resp.headers.get("content-security-policy"))
assert nonce, path
assert f'nonce="{nonce}"' in resp.text, path
def test_docs_inline_script_nonced(client):
"""BUG-106 : /docs (script inline d'init Swagger) porte le nonce de son
en-tête — sinon script-src sans 'unsafe-inline' rend la page blanche."""
resp = client.get("/docs")
assert resp.status_code == 200
nonce = _nonce_of(resp.headers.get("content-security-policy"))
assert nonce
assert f'nonce="{nonce}"' in resp.text, "/docs : script inline sans nonce"
# Le bundle externe (jsdelivr) reste tel quel, couvert par script-src.
assert "<script src=" in resp.text
def test_redoc_served_with_csp(client):
"""/redoc n'a aucun script inline (bundle externe seul) : il est servi
avec sa CSP sans aucune injection nécessaire."""
resp = client.get("/redoc")
assert resp.status_code == 200
assert _nonce_of(resp.headers.get("content-security-policy"))
assert "<script src=" in resp.text
assert "<script nonce=" not in resp.text
def test_docs_external_bundle_not_nonced(client):
"""L'injection ne touche que les balises <script> exécutables sans src."""
from backend.csp import inject_csp_nonce
html = '<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/x.js"></script><script>init()</script>'
out = inject_csp_nonce(html, NONCE)
assert out.count(f'nonce="{NONCE}"') == 1
assert '<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/x.js">' in out
+166
View File
@@ -0,0 +1,166 @@
# tests/test_duplicates.py — Duplicate detection & merge (#166)
"""Tests for backend.services.duplicates, its REST routes and AI tools.
Fusion is destructive: the service takes backups first and the tool/route
require an explicit confirmation (DANGEROUS + ``confirm: true``).
"""
from pathlib import Path
import pytest
from backend.services import duplicates as _duplicates
from backend.services.errors import ServiceError
from backend.tools.api import ToolContext, call_tool
from backend.tools.context import ToolConfirmationRequired
def _ctx(**kwargs) -> ToolContext:
user = {"username": "tester", "role": "admin", "vaults": ["*"]}
kwargs.setdefault("audit_enabled", False)
return ToolContext(user=user, **kwargs)
class TestSimilarity:
def test_identical_texts_score_one(self):
text = "# Titre\nContenu identique avec plusieurs mots significatifs."
assert _duplicates.similarity_score(text, text) == 1.0
def test_different_texts_score_low(self):
a = "# Recette pizza\nFarine, tomate, mozzarella, four à bois."
b = "# Config réseau\nAdresse IP, masque, passerelle, DNS du datacenter."
assert _duplicates.similarity_score(a, b) < 0.5
def test_empty_text_scores_zero(self):
assert _duplicates.similarity_score("", "# Titre\ncontenu") == 0.0
def test_frontmatter_ignored(self):
a = "---\ntitle: A\ntags: [x]\n---\n# Note\nContenu commun significatif ici."
b = "---\ntitle: B\ntags: [y]\n---\n# Note\nContenu commun significatif ici."
assert _duplicates.similarity_score(a, b) > 0.8
class TestFindPairs:
def test_finds_planted_duplicates(self, client, test_vault_dir):
vault = Path(test_vault_dir)
body = "# Rapport mensuel\nVentes en hausse de vingt pour cent ce trimestre."
(vault / "rapport-a.md").write_text(body, encoding="utf-8")
(vault / "rapport-b.md").write_text(body + "\nLigne complémentaire mineure.", encoding="utf-8")
result = _duplicates.find_duplicate_pairs("TestVault", threshold=0.5, limit=20)
assert result["vault"] == "TestVault"
assert result["files_scanned"] >= 2
pair_files = {(p["file_a"], p["file_b"]) for p in result["pairs"]}
assert ("rapport-a.md", "rapport-b.md") in pair_files
def test_threshold_filters(self, client, test_vault_dir):
result = _duplicates.find_duplicate_pairs("TestVault", threshold=1.0, limit=20)
assert result["pairs"] == []
def test_invalid_threshold_rejected(self, client):
with pytest.raises(ServiceError):
_duplicates.find_duplicate_pairs("TestVault", threshold=0.1)
def test_unknown_vault_rejected(self, client):
with pytest.raises(ServiceError):
_duplicates.find_duplicate_pairs("NoSuchVault")
class TestMerge:
def _plant(self, test_vault_dir: str, name: str, content: str) -> None:
Path(test_vault_dir, name).write_text(content, encoding="utf-8")
def test_merge_append(self, client, test_vault_dir):
self._plant(test_vault_dir, "src.md", "# Source\nContenu source unique.")
self._plant(test_vault_dir, "dst.md", "# Cible\nContenu cible unique.")
result = _duplicates.merge_duplicates("TestVault", "src.md", "dst.md", strategy="append")
assert result["strategy"] == "append"
assert result["deleted"] == "src.md"
assert not Path(test_vault_dir, "src.md").exists()
merged = Path(test_vault_dir, "dst.md").read_text(encoding="utf-8")
assert "Contenu cible unique" in merged
assert "Contenu source unique" in merged
def test_merge_prefer_target(self, client, test_vault_dir):
self._plant(test_vault_dir, "old.md", "# Vieux\nAncien contenu.")
self._plant(test_vault_dir, "new.md", "# Neuf\nNouveau contenu.")
result = _duplicates.merge_duplicates("TestVault", "old.md", "new.md", strategy="prefer_target")
assert result["strategy"] == "prefer_target"
assert Path(test_vault_dir, "new.md").read_text(encoding="utf-8") == "# Neuf\nNouveau contenu."
assert not Path(test_vault_dir, "old.md").exists()
def test_merge_same_path_rejected(self, client, test_vault_dir):
self._plant(test_vault_dir, "same.md", "# Same\nContenu.")
with pytest.raises(ServiceError):
_duplicates.merge_duplicates("TestVault", "same.md", "same.md")
def test_merge_missing_source_rejected(self, client, test_vault_dir):
self._plant(test_vault_dir, "exists.md", "# Exists\nContenu.")
with pytest.raises(ServiceError):
_duplicates.merge_duplicates("TestVault", "missing.md", "exists.md")
class TestDuplicatesApi:
def test_list_endpoint(self, client, test_vault_dir):
(Path(test_vault_dir) / "dup1.md").write_text("# Doublon\nTexte commun significatif.", encoding="utf-8")
(Path(test_vault_dir) / "dup2.md").write_text("# Doublon\nTexte commun significatif.", encoding="utf-8")
resp = client.get("/api/duplicates", params={"vault": "TestVault", "threshold": 0.5})
assert resp.status_code == 200, resp.text
assert resp.json()["vault"] == "TestVault"
def test_merge_requires_confirm(self, client, test_vault_dir):
(Path(test_vault_dir) / "m1.md").write_text("# M1\nContenu.", encoding="utf-8")
(Path(test_vault_dir) / "m2.md").write_text("# M2\nContenu.", encoding="utf-8")
resp = client.post(
"/api/duplicates/merge",
json={"vault": "TestVault", "source_path": "m1.md", "target_path": "m2.md"},
)
assert resp.status_code == 400
def test_merge_with_confirm(self, client, test_vault_dir):
(Path(test_vault_dir) / "c1.md").write_text("# C1\nContenu un.", encoding="utf-8")
(Path(test_vault_dir) / "c2.md").write_text("# C2\nContenu deux.", encoding="utf-8")
resp = client.post(
"/api/duplicates/merge",
json={
"vault": "TestVault",
"source_path": "c1.md",
"target_path": "c2.md",
"strategy": "append",
"confirm": True,
},
)
assert resp.status_code == 200, resp.text
assert resp.json()["deleted"] == "c1.md"
class TestDuplicateTools:
def test_find_tool(self, client, test_vault_dir):
(Path(test_vault_dir) / "t1.md").write_text("# Outil\nRecherche de doublons.", encoding="utf-8")
result = call_tool(
"find_duplicates",
_ctx(),
{"vault": "TestVault", "threshold": 0.5, "limit": 10},
)
assert result.ok
assert result.data["vault"] == "TestVault"
def test_merge_tool_requires_confirmation(self, client, test_vault_dir):
(Path(test_vault_dir) / "s1.md").write_text("# S1\nContenu.", encoding="utf-8")
(Path(test_vault_dir) / "s2.md").write_text("# S2\nContenu.", encoding="utf-8")
with pytest.raises(ToolConfirmationRequired):
call_tool(
"merge_duplicate_notes",
_ctx(),
{"vault": "TestVault", "source_path": "s1.md", "target_path": "s2.md"},
)
def test_merge_tool_confirmed(self, client, test_vault_dir):
(Path(test_vault_dir) / "k1.md").write_text("# K1\nContenu k.", encoding="utf-8")
(Path(test_vault_dir) / "k2.md").write_text("# K2\nContenu l.", encoding="utf-8")
result = call_tool(
"merge_duplicate_notes",
_ctx(confirmed=True),
{"vault": "TestVault", "source_path": "k1.md", "target_path": "k2.md", "strategy": "append"},
)
assert result.ok
assert result.data["deleted"] == "k1.md"
+202
View File
@@ -0,0 +1,202 @@
# tests/test_notify_channels.py — External notifications (#168)
"""Tests for backend.notify (Discord, Telegram, SMTP, webhook), its REST
routes and the ``notify_external`` AI tool.
Network calls are mocked: no test ever hits the real Internet (hermetic CI).
"""
import pytest
from backend import notify as _notify
from backend.tools.api import ToolContext, call_tool
@pytest.fixture
def isolated_store(tmp_path, monkeypatch):
"""Redirect the channel + secret stores to a tmp dir."""
channels = tmp_path / "notify_channels.json"
secrets = tmp_path / "notify_secrets.json"
monkeypatch.setattr(_notify, "CHANNELS_FILE", channels)
monkeypatch.setattr(_notify, "SECRETS_FILE", secrets)
monkeypatch.setenv("OBSIGATE_WEBHOOK_ALLOW_PRIVATE", "true")
return tmp_path
def _ctx(**kwargs) -> ToolContext:
user = {"username": "tester", "role": "admin", "vaults": ["*"]}
kwargs.setdefault("audit_enabled", False)
return ToolContext(user=user, **kwargs)
class TestValidation:
def test_unknown_type_rejected(self, isolated_store):
with pytest.raises(ValueError):
_notify.create_channel("x", "slack", {})
def test_discord_requires_url(self, isolated_store):
with pytest.raises(ValueError):
_notify.create_channel("d", "discord", {"webhook_url": "not-a-url"})
def test_telegram_requires_chat(self, isolated_store):
with pytest.raises(ValueError):
_notify.create_channel("t", "telegram", {})
def test_smtp_requires_fields(self, isolated_store):
with pytest.raises(ValueError):
_notify.create_channel("m", "smtp", {"host": "smtp.example.com"})
def test_bad_trigger_falls_back_to_manual(self, isolated_store):
channel = _notify.create_channel(
"w",
"webhook",
{"url": "https://example.com/hook", "triggers": ["nope"]},
)
assert channel["config"]["triggers"] == ["manual"]
class TestCrud:
def test_create_masks_secret(self, isolated_store):
channel = _notify.create_channel(
"disc",
"discord",
{
"webhook_url": "https://discord.com/api/webhooks/123/abcdef-secret-token",
"triggers": ["manual", "schedule_failure"],
},
)
assert channel["has_secret"] is True
assert channel["config"]["webhook_url"] == "***"
# Le secret vit dans le store dédié, pas dans le fichier public.
raw = isolated_store.joinpath("notify_channels.json").read_text(encoding="utf-8")
assert "abcdef-secret-token" not in raw
def test_update_and_delete(self, isolated_store):
channel = _notify.create_channel("w", "webhook", {"url": "https://example.com/a"})
updated = _notify.update_channel(channel["id"], {"enabled": False})
assert updated is not None and updated["enabled"] is False
assert _notify.delete_channel(channel["id"]) is True
assert _notify.delete_channel(channel["id"]) is False
def test_update_unknown_returns_none(self, isolated_store):
assert _notify.update_channel("nope", {"enabled": True}) is None
class TestDispatch:
def test_broadcast_skips_unsubscribed_trigger(self, isolated_store, monkeypatch):
_notify.create_channel("w", "webhook", {"url": "https://example.com/a", "triggers": ["manual"]})
calls: list = []
monkeypatch.setattr(_notify, "send_via_channel", lambda ch, t, m, trig="manual": calls.append(ch["id"]))
results = _notify.broadcast("schedule_failure", "T", "M")
assert results == []
assert calls == []
def test_broadcast_delivers_and_records_failure(self, isolated_store, monkeypatch):
channel = _notify.create_channel(
"w", "webhook", {"url": "https://example.com/a", "triggers": ["manual"]}
)
monkeypatch.setattr(_notify, "send_via_channel", lambda ch, t, m, trig="manual": None)
results = _notify.broadcast("manual", "T", "M")
assert results == [{"channel_id": channel["id"], "ok": True}]
def _boom(ch, t, m, trig="manual"):
raise RuntimeError("down")
monkeypatch.setattr(_notify, "send_via_channel", _boom)
results = _notify.broadcast("manual", "T", "M")
assert results[0]["ok"] is False
assert "down" in results[0]["error"]
def test_smtp_send_uses_smtplib(self, isolated_store, monkeypatch):
_notify.create_channel(
"mail",
"smtp",
{
"host": "smtp.example.com",
"port": 587,
"from_addr": "[email protected]",
"to_addr": "[email protected]",
"username": "a",
"password": "s3cret-pwd",
"triggers": ["manual"],
},
)
sent: dict = {}
class _FakeSMTP:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def starttls(self):
sent["tls"] = True
def login(self, user, pwd):
sent["login"] = (user, pwd)
def send_message(self, msg):
sent["subject"] = msg["Subject"]
monkeypatch.setattr(_notify.smtplib, "SMTP", _FakeSMTP)
results = _notify.broadcast("manual", "Sujet", "Corps")
assert results[0]["ok"] is True
assert sent["tls"] is True
assert sent["login"] == ("a", "s3cret-pwd")
assert "Sujet" in sent["subject"]
class TestNotifyTool:
def test_tool_broadcasts(self, isolated_store, monkeypatch):
monkeypatch.setattr(_notify, "broadcast", lambda trig, t, m: [{"channel_id": "c", "ok": True}])
result = call_tool(
"notify_external",
_ctx(confirmed=True),
{"title": "Hello", "message": "World", "trigger": "manual"},
)
assert result.ok
assert result.data["deliveries"] == [{"channel_id": "c", "ok": True}]
def test_tool_unknown_channel(self, isolated_store):
from backend.tools.context import ToolError
with pytest.raises(ToolError):
call_tool(
"notify_external",
_ctx(confirmed=True),
{"title": "H", "message": "M", "channel_id": "unknown"},
)
class TestNotifyApi:
def test_channels_crud(self, client, isolated_store):
created = client.post(
"/api/notify/channels",
json={"name": "w", "type": "webhook", "config": {"url": "https://example.com/a"}},
)
assert created.status_code == 200, created.text
channel_id = created.json()["id"]
assert created.json()["has_secret"] is False
listed = client.get("/api/notify/channels")
assert listed.status_code == 200
assert any(c["id"] == channel_id for c in listed.json())
patched = client.patch(f"/api/notify/channels/{channel_id}", json={"enabled": False})
assert patched.status_code == 200
assert patched.json()["enabled"] is False
deleted = client.delete(f"/api/notify/channels/{channel_id}")
assert deleted.status_code == 200
def test_create_rejects_bad_type(self, client, isolated_store):
resp = client.post("/api/notify/channels", json={"name": "x", "type": "slack", "config": {}})
assert resp.status_code == 400
def test_test_endpoint_no_channel(self, client, isolated_store):
resp = client.post("/api/notify/test", json={"title": "T", "message": "M"})
assert resp.status_code == 200
assert resp.json()["deliveries"] == []
+198
View File
@@ -0,0 +1,198 @@
# tests/test_scheduler.py — Scheduled tasks, type cron (#170)
"""Tests for backend.scheduler, its REST routes and AI tools.
The file store is redirected to tmp; task actions run against the TestVault
fixture vault (create/append reuse the real mutation services).
"""
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from backend import scheduler as _scheduler
from backend.tools.api import ToolContext, call_tool
@pytest.fixture
def isolated_tasks(tmp_path, monkeypatch):
"""Redirect the task store to a tmp file."""
tasks_file = tmp_path / "scheduled_tasks.json"
monkeypatch.setattr(_scheduler, "TASKS_FILE", tasks_file)
monkeypatch.setattr("backend.notify.CHANNELS_FILE", tmp_path / "notify_channels.json")
monkeypatch.setattr("backend.notify.SECRETS_FILE", tmp_path / "notify_secrets.json")
return tasks_file
def _ctx(**kwargs) -> ToolContext:
user = {"username": "tester", "role": "admin", "vaults": ["*"]}
kwargs.setdefault("audit_enabled", False)
return ToolContext(user=user, **kwargs)
class TestValidation:
def test_unknown_action_rejected(self, isolated_tasks):
with pytest.raises(ValueError):
_scheduler.create_task(
"x",
{"kind": "launch_rocket", "params": {}},
{"kind": "interval_hours", "hours": 1},
)
def test_interval_too_short_rejected(self, isolated_tasks):
with pytest.raises(ValueError):
_scheduler.create_task(
"x",
{"kind": "notify", "params": {"title": "T", "message": "M"}},
{"kind": "interval_hours", "hours": 0.1},
)
def test_bad_daily_time_rejected(self, isolated_tasks):
with pytest.raises(ValueError):
_scheduler.create_task(
"x",
{"kind": "notify", "params": {"title": "T", "message": "M"}},
{"kind": "daily_time", "at": "25:00"},
)
class TestNextRun:
def test_interval_from_now(self, isolated_tasks):
now = datetime(2026, 10, 4, 12, 0, tzinfo=timezone.utc)
nxt = _scheduler.compute_next_run(
{"schedule": {"kind": "interval_hours", "hours": 2}, "last_run_at": None}, now
)
assert nxt == now + timedelta(hours=2)
def test_daily_tomorrow_when_passed(self, isolated_tasks):
now = datetime(2026, 10, 4, 12, 0, tzinfo=timezone.utc)
nxt = _scheduler.compute_next_run({"schedule": {"kind": "daily_time", "at": "08:00"}}, now)
assert (nxt - now).total_seconds() == pytest.approx(20 * 3600)
def test_daily_today_when_upcoming(self, isolated_tasks):
now = datetime(2026, 10, 4, 7, 0, tzinfo=timezone.utc)
nxt = _scheduler.compute_next_run({"schedule": {"kind": "daily_time", "at": "08:00"}}, now)
assert (nxt - now).total_seconds() == pytest.approx(3600)
class TestExecution:
def test_create_and_run_append(self, client, test_vault_dir, isolated_tasks):
(Path(test_vault_dir) / "journal.md").write_text("# Journal\n", encoding="utf-8")
task = _scheduler.create_task(
"append",
{"kind": "append_to_file", "params": {"vault": "TestVault", "path": "journal.md", "content": "Ligne auto."}},
{"kind": "once_at", "at": "2020-01-01T00:00:00"},
)
outcome = _scheduler.run_task(task["id"], manual=True)
assert outcome["ok"] is True
assert "Ligne auto." in Path(test_vault_dir, "journal.md").read_text(encoding="utf-8")
stored = _scheduler.get_task(task["id"])
assert stored is not None and stored["last_status"] == "ok"
assert stored["run_count"] == 1
assert stored["enabled"] is False # one-shot consommé
def test_tick_runs_due_task(self, client, test_vault_dir, isolated_tasks):
task = _scheduler.create_task(
"once",
{"kind": "create_file", "params": {"vault": "TestVault", "path": "auto/tache.md", "content": "auto"}},
{"kind": "once_at", "at": "2020-01-01T00:00:00"},
)
outcomes = _scheduler.tick()
assert any(o.get("task_id") == task["id"] and o.get("ok") for o in outcomes)
assert Path(test_vault_dir, "auto", "tache.md").exists()
def test_failure_recorded(self, client, isolated_tasks):
task = _scheduler.create_task(
"fail",
{"kind": "append_to_file", "params": {"vault": "TestVault", "path": "missing/nope.md", "content": "x"}},
{"kind": "once_at", "at": "2020-01-01T00:00:00"},
)
outcome = _scheduler.run_task(task["id"], manual=True)
assert outcome["ok"] is False
stored = _scheduler.get_task(task["id"])
assert stored is not None and stored["last_status"] == "error"
assert stored["last_error"]
def test_run_unknown_raises(self, isolated_tasks):
with pytest.raises(KeyError):
_scheduler.run_task("nope", manual=True)
def test_delete(self, isolated_tasks):
task = _scheduler.create_task(
"del",
{"kind": "notify", "params": {"title": "T", "message": "M"}},
{"kind": "interval_hours", "hours": 24},
)
assert _scheduler.delete_task(task["id"]) is True
assert _scheduler.delete_task(task["id"]) is False
class TestSchedulerApi:
def test_crud_and_run(self, client, test_vault_dir, isolated_tasks):
created = client.post(
"/api/scheduler/tasks",
json={
"name": "t",
"action": {
"kind": "create_file",
"params": {"vault": "TestVault", "path": "sched/api.md", "content": "hello"},
},
"schedule": {"kind": "interval_hours", "hours": 24},
},
)
assert created.status_code == 200, created.text
task_id = created.json()["id"]
listed = client.get("/api/scheduler/tasks")
assert listed.status_code == 200
assert any(t["id"] == task_id for t in listed.json())
run = client.post(f"/api/scheduler/tasks/{task_id}/run")
assert run.status_code == 200, run.text
assert run.json()["ok"] is True
assert Path(test_vault_dir, "sched", "api.md").exists()
deleted = client.delete(f"/api/scheduler/tasks/{task_id}")
assert deleted.status_code == 200
def test_create_rejects_unknown_vault(self, client, isolated_tasks):
resp = client.post(
"/api/scheduler/tasks",
json={
"name": "t",
"action": {"kind": "create_file", "params": {"vault": "NoVault", "path": "x.md"}},
"schedule": {"kind": "interval_hours", "hours": 24},
},
)
assert resp.status_code in (400, 403, 404)
def test_run_unknown_404(self, client, isolated_tasks):
assert client.post("/api/scheduler/tasks/nope/run").status_code == 404
class TestSchedulerTools:
def test_create_list_delete_run(self, client, test_vault_dir, isolated_tasks):
created = call_tool(
"create_scheduled_task",
_ctx(confirmed=True),
{
"name": "tool-task",
"action": {
"kind": "create_file",
"params": {"vault": "TestVault", "path": "sched/tool.md", "content": "hi"},
},
"schedule": {"kind": "interval_hours", "hours": 24},
},
)
assert created.ok
task_id = created.data["id"]
listed = call_tool("list_scheduled_tasks", _ctx(), {})
assert listed.ok
assert any(t["id"] == task_id for t in listed.data)
ran = call_tool("run_scheduled_task_now", _ctx(confirmed=True), {"task_id": task_id})
assert ran.ok and ran.data["ok"] is True
deleted = call_tool("delete_scheduled_task", _ctx(confirmed=True), {"task_id": task_id})
assert deleted.ok
+10
View File
@@ -93,6 +93,16 @@ class TestRegistry:
props = read["function"]["parameters"]["properties"]
assert "vault" in props and "path" in props
def test_schemas_cached_but_caller_safe(self):
"""#187: repeated calls hit the cache and a caller mutating its copy
(e.g. stripping keys) must never corrupt the next request."""
a = get_tool_schemas()
b = get_tool_schemas()
assert a == b
assert all(x is not y for x, y in zip(a, b)), "top-level dicts must be fresh copies"
a[0]["poisoned"] = True
assert "poisoned" not in get_tool_schemas()[0]
def test_duplicate_tool_name_raises(self):
from backend.tools.registry import tool