Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79202e698 | ||
|
|
4ce677902a | ||
|
|
fe9f7b49f7 | ||
|
|
649f965c52 | ||
|
|
d6fa8c7bb1 | ||
|
|
d2734dc8ce | ||
|
|
bab272bb5c | ||
|
|
34428ea7b3 | ||
|
|
83e6a951dd | ||
|
|
3d618eb660 |
@@ -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
|
||||
|
||||
+135
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.49.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.53.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,140 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
---
|
||||
|
||||
## [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é
|
||||
|
||||
- Tests : `mobileBlockContaining` ignore les commentaires CSS — un sélecteur cité dans
|
||||
une annotation ne faisait plus trouver le bon bloc mobile (CI #842).
|
||||
|
||||
---
|
||||
|
||||
## [2.49.1] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Mobile (BUG-103)** : le bas du sidebar de navigation n'est plus masqué par la barre
|
||||
d'outils du bas — `z-index` du sidebar mobile `200` → `950` (au-dessus de
|
||||
`.mobile-toolbar`, `900`).
|
||||
|
||||
---
|
||||
|
||||
## [2.49.0] — 2026-10-02
|
||||
|
||||
### Ajouté
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -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.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.49.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.53.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -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.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.49.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.53.0 | Last updated: September 2026*
|
||||
|
||||
+49
-1
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`."""
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.49.0"
|
||||
version = "2.53.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.49.0"
|
||||
version = "2.53.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
{"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"]}}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.*
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.49.0",
|
||||
"version": "2.53.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
@@ -13,6 +13,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "ObsiGate",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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é
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -210,6 +210,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-100* | Retour à l'accueil incomplet : le clic sur le titre perd la section « Raccourcis & Astuces » et laisse la recherche globale ainsi que le filtre de la sidebar actifs | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`showWelcome`), `frontend/js/legacy.js` (`goHome`) | Ouvrir un fichier (le dashboard est détruit), puis cliquer le titre ObsiGate | `viewer.js` : `#quick-help` capturé au chargement du module et réinjecté dans le template reconstruit de `showWelcome()` ; `legacy.js::goHome` clique `#search-clear-btn` (chips + barre de résultats) et `#sidebar-filter-clear-btn` avant d'afficher l'accueil | **Cause racine :** `showWelcome()` reconstruit `#dashboard-home` depuis un template qui n'embarque pas le bloc `#quick-help` présent dans `index.html` — il ne disparaissait qu'après la première reconstruction (ouverture de fichier, puis retour). Livré avec #158 A3 |
|
||||
| *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`) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -316,6 +321,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-02 | #158 (A4-A6) | Fonctionnalité | `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/legacy.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/e2e/nav-tab.spec.js` (nouveau), `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/navigation-tab-158.md` | **A4** : `TabManager.openHome()` — le clic sur le titre ouvre la page d'accueil dans un **onglet Accueil** (icône Maison, `common.home` FR/EN) remonté en position 0 à chaque fois ; `deactivate()` conserve cet onglet actif, `pane-manager.js` a son propre `openHome` pour le split. **A5** : menus contextuels répertoires **et** fichiers de la page de navigation via `ContextMenuManager.show()` (avec `stopPropagation()`, le handler global referme sinon le menu) — listes vérifiées identiques à la sidebar ; la ligne d'un vault rouvre la page de navigation. **A6** : icône des répertoires à `var(--accent)` (couleur de l'arbre). **Correctif annexé** : l'effacement du filtre sidebar au retour à l'accueil n'est déclenché que s'il était actif (sinon `restoreSidebarTree()` repliait l'arbre). Vérifié : Playwright jetable 16/16, `nav-tab.spec.js` 4 tests, validate-imports/unit/i18n parity verts. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 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é |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+194
-2
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.49.0 | **Dernière mise à jour :** 2026-10-02
|
||||
> **Version :** 2.53.0 | **Dernière mise à jour :** 2026-10-04
|
||||
> **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,30 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 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é — Navigation
|
||||
|
||||
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
|
||||
@@ -226,6 +270,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 +286,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 +312,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 +448,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 +519,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 +531,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 +552,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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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 & 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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>');
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
/* 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;
|
||||
@@ -33,13 +41,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 +153,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 +161,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 +179,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 +194,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;
|
||||
}
|
||||
|
||||
@@ -512,6 +512,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",
|
||||
@@ -2138,6 +2145,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",
|
||||
|
||||
@@ -512,6 +512,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",
|
||||
@@ -2138,6 +2145,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",
|
||||
|
||||
+131
-1
@@ -4038,7 +4038,9 @@ body.resizing-v {
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
/* Par-dessus la barre d'outils mobile (.mobile-toolbar, z-index 900)
|
||||
pour ne pas masquer le bas du sidebar — BUG-103 */
|
||||
z-index: 950;
|
||||
width: 280px !important;
|
||||
min-width: 280px !important;
|
||||
max-width: 85vw !important;
|
||||
@@ -5039,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;
|
||||
}
|
||||
@@ -11321,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);
|
||||
@@ -11399,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);
|
||||
@@ -11597,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);
|
||||
@@ -11614,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;
|
||||
@@ -11961,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;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.49.0",
|
||||
"version": "2.53.0",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -42,19 +42,23 @@ function test(label, fn) {
|
||||
/** Body of the first @media (max-width: 768px) block whose body contains `needle`. */
|
||||
function mobileBlockContaining(needle) {
|
||||
const marker = "@media (max-width: 768px)";
|
||||
// Les commentaires sont ignorés : un sélecteur cité dans une annotation
|
||||
// (ex. « .mobile-toolbar » dans un commentaire de style.css) ne doit pas
|
||||
// faire rétrograder ce helper sur le mauvais bloc mobile (BUG-103/CI #842).
|
||||
const source = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
let idx = 0;
|
||||
while ((idx = css.indexOf(marker, idx)) !== -1) {
|
||||
const open = css.indexOf("{", idx);
|
||||
while ((idx = source.indexOf(marker, idx)) !== -1) {
|
||||
const open = source.indexOf("{", idx);
|
||||
let depth = 0;
|
||||
let end = open;
|
||||
for (; end < css.length; end++) {
|
||||
if (css[end] === "{") depth++;
|
||||
else if (css[end] === "}") {
|
||||
for (; end < source.length; end++) {
|
||||
if (source[end] === "{") depth++;
|
||||
else if (source[end] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
const body = css.slice(open + 1, end);
|
||||
const body = source.slice(open + 1, end);
|
||||
if (body.includes(needle)) return body;
|
||||
idx = end + 1;
|
||||
}
|
||||
|
||||
@@ -307,6 +307,22 @@ function testSyntaxHighlightTheme() {
|
||||
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
|
||||
}
|
||||
|
||||
// ── Test mobile : le sidebar passe par-dessus la barre d'outils du bas (BUG-103) ──
|
||||
function testMobileSidebarOverToolbar() {
|
||||
const css = readFileSync(join(JS_DIR, '..', 'style.css'), 'utf-8');
|
||||
const zOf = (selector) => {
|
||||
const blocks = css.match(new RegExp('\\' + selector + ' \\{[^}]*\\}', 'g')) || [];
|
||||
return Math.max(0, ...blocks.map((b) => Number((b.match(/z-index: (\d+)/) || [])[1] || 0)));
|
||||
};
|
||||
const sidebarZ = zOf('.sidebar');
|
||||
const toolbarZ = zOf('.mobile-toolbar');
|
||||
assert.ok(
|
||||
sidebarZ > toolbarZ,
|
||||
`.sidebar z-index (${sidebarZ}) doit être supérieur à .mobile-toolbar (${toolbarZ})`,
|
||||
);
|
||||
console.log(' ✓ mobile sidebar over toolbar');
|
||||
}
|
||||
|
||||
// ── Run all tests ──────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
let passed = 0, failed = 0;
|
||||
@@ -324,6 +340,7 @@ async function main() {
|
||||
['config TOC icons', testConfigTocIcons],
|
||||
['sidebar user section', testSidebarUserSection],
|
||||
['syntax highlight theme', testSyntaxHighlightTheme],
|
||||
['mobile sidebar over toolbar', testMobileSidebarOverToolbar],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user