Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
431d6e9338 | ||
|
|
d453969708 | ||
|
|
778fa65b4c | ||
|
|
69c3817579 | ||
|
|
b89af917b1 | ||
|
|
d79202e698 | ||
|
|
4ce677902a | ||
|
|
fe9f7b49f7 | ||
|
|
649f965c52 | ||
|
|
d6fa8c7bb1 | ||
|
|
d2734dc8ce | ||
|
|
bab272bb5c | ||
|
|
34428ea7b3 | ||
|
|
83e6a951dd | ||
|
|
3d618eb660 | ||
|
|
2add24a9c1 | ||
|
|
a50227849d | ||
|
|
5ffc9d851a | ||
|
|
1e0e419210 | ||
|
|
2813d546a7 | ||
|
|
19e94dfa51 | ||
|
|
49f97fc26e | ||
|
|
562290d922 | ||
|
|
66a5505965 | ||
|
|
c5c225a68e | ||
|
|
1bacfd69d9 | ||
|
|
383ffa6a65 | ||
|
|
69927176df | ||
|
|
5c2ae26a74 |
+15
-8
@@ -38,6 +38,8 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
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
|
||||
@@ -58,6 +60,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -78,6 +81,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -168,17 +172,20 @@ jobs:
|
||||
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
|
||||
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
|
||||
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
|
||||
# s'exécutent jamais. PYSEC-2026-178 (pyjwt) est, lui, corrigé par le
|
||||
# plancher pyjwt>=2.13.0 de backend/requirements.txt (BUG-091).
|
||||
# s'exécutent jamais. Les advisories pyjwt (PYSEC-2026-178 puis
|
||||
# CVE-2026-102274) sont corrigées par le plancher pyjwt>=2.14.0 de
|
||||
# backend/requirements.txt (BUG-091, BUG-095).
|
||||
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
||||
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
||||
# via backend/pdf_reader.py) sont corrigés par le plancher
|
||||
# pypdf>=6.16.1 (BUG-093). Ces planchers doivent rester *au-dessus*
|
||||
# des versions préinstallées dans la toolcache de l'image du runner :
|
||||
# en dessous, pip répond « already satisfied » et n'aligne jamais
|
||||
# (c'est exactement ce qui a fait échouer ce job). Le garde-fou
|
||||
# tests/test_ci_workflow.py::TestDependencySecurityFloors verrouille
|
||||
# ces planchers.
|
||||
# pypdf>=6.16.1 (BUG-093).
|
||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||
# corrigés par le plancher urllib3>=2.8.0.
|
||||
# Ces planchers doivent rester *au-dessus* des versions préinstallées
|
||||
# dans la toolcache de l'image du runner : en dessous, pip répond
|
||||
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
|
||||
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
|
||||
# TestDependencySecurityFloors verrouille ces planchers.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
|
||||
|
||||
+439
-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.43.1**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.53.2**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,444 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.53.2] — 2026-10-07
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#187 - Assistant IA : mode agent toujours actif**
|
||||
- Le bouton « mode agent » du panneau a été **supprimé** : l'assistant utilise
|
||||
en permanence ses outils (lire, lister, chercher, modifier) ; la sécurité
|
||||
est inchangée — chaque modification demande une confirmation avec aperçu.
|
||||
Les images jointes passent toujours par l'endpoint multimodal classique.
|
||||
- Le provider réel de l'agent est désormais résolu comme pour le chat simple
|
||||
(l'étiquette « fournisseur · modèle » affichée est exacte), et les schémas
|
||||
d'outils sont mis en cache (≈ 27 ms économisées par requête).
|
||||
|
||||
---
|
||||
|
||||
## [2.53.1] — 2026-10-04
|
||||
|
||||
---
|
||||
|
||||
## [2.53.0] — 2026-10-04
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile**
|
||||
- Une icône Lucide par entrée (grille, Structure, Mise en forme, Export) et
|
||||
sur les boutons Enregistrer / « + » ; suppressions marquées danger,
|
||||
`aria-haspopup`/`aria-expanded` sur les boutons à menu.
|
||||
- Tout menu se ferme à la perte de focus (clic extérieur, `focusout`,
|
||||
`Échap`, défilement, redimensionnement) via `trackDismissable()` ;
|
||||
navigation clavier (`↑`/`↓`/`Home`/`End`) dans le menu grille.
|
||||
- Mobile : entrées ≥ 44 px (`pointer: coarse`), menus bornés au viewport,
|
||||
ruban sans débordement horizontal.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Build Docker** : `build.ps1` plaçait `--no-cache` avant `build`
|
||||
(`unknown flag`) — flag déplacé après `docker compose -f … build`, la
|
||||
procédure `.\build.ps1 -Test` refonctionne.
|
||||
|
||||
---
|
||||
|
||||
## [2.52.0] — 2026-10-04
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#166 — Assistant IA : détection & fusion de doublons**
|
||||
- Score déterministe (Jaccard 70 % + titre 30 %, frontmatter exclu),
|
||||
scan borné (500 fichiers, `truncated` exposé).
|
||||
- Outils `find_duplicates` (READ) / `merge_duplicate_notes` (DANGEROUS,
|
||||
backup préalable) ; API `GET /api/duplicates` et
|
||||
`POST /api/duplicates/merge` (`confirm: true` obligatoire, stratégies
|
||||
`append` / `prefer_target` / `prefer_source`).
|
||||
- **#168 — Assistant IA : notifications externes Discord / Telegram / SMTP / webhook**
|
||||
- Canaux avec déclencheurs (`manual`, `schedule_failure`,
|
||||
`schedule_success`, `duplicate_found`), secrets hors config
|
||||
(`notify_secrets.json` 0600 ou `OBSIGATE_NOTIFY_SECRET_<ID>`), SSRF-safe.
|
||||
- CRUD admin `/api/notify/channels`, test `/api/notify/test`,
|
||||
outil `notify_external` (WRITE).
|
||||
- **#170 — Assistant IA : tâches planifiées type cron**
|
||||
- Actions `create_file` / `append_to_file` / `notify` (services existants),
|
||||
planifications `interval_hours` / `daily_time` / `once_at`, tick 60 s
|
||||
(`OBSIGATE_SCHEDULER=0` pour désactiver), échec enregistré + notifié.
|
||||
- API `/api/scheduler/tasks` (CRUD + `POST …/run`), outils
|
||||
`create/list/delete/run_scheduled_task*`.
|
||||
|
||||
---
|
||||
|
||||
## [2.51.0] — 2026-10-03
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#160 — Premier lancement professionnel + section Configuration harmonisée**
|
||||
- **Premier lancement** : `%USERPROFILE%\ObsiGate` est créé et monté comme
|
||||
vault de démarrage (nom « ObsiGate », remplace `voute_obsidian`), le
|
||||
contexte initial s'ouvre dessus, le dossier home reste une racine nommée
|
||||
d'après son chemin (fin du « bruno » codé en dur), et le document
|
||||
**`Prise en main.md`** (contenu embarqué) y est écrit une seule fois —
|
||||
jamais écrasé.
|
||||
- **Section « 🖥️ Vaults & dossiers (Desktop) »** refondue : lignes au motif
|
||||
des cartes de la configuration (icône, nom, chemin tronqué), boutons
|
||||
Retirer/Ajouter en `.config-btn-sm` secondaire/ primaire, zéro style
|
||||
inline, variables CSS uniquement, cibles tactiles 44px en mobile.
|
||||
|
||||
---
|
||||
|
||||
## [2.50.2] — 2026-10-02
|
||||
|
||||
---
|
||||
|
||||
## [2.50.1] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-106 — page blanche sur `/docs`** : la CSP `script-src` sans
|
||||
`unsafe-inline` (#87 T5c) refusait le script inline d'init de Swagger UI,
|
||||
généré par FastAPI et jamais noncé — `SecurityHeadersMiddleware` injecte
|
||||
désormais le nonce dans le HTML de `/docs` et `/redoc` (helper existant
|
||||
`inject_csp_nonce`, corps ré-encodé gzip), avec 3 tests de non-régression.
|
||||
- **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry
|
||||
remplaçait le gestionnaire de drag & drop du WebView2 par le sien (aucun
|
||||
événement natif écouté) — les dépôts depuis l'Explorateur n'atteignaient
|
||||
jamais la page. La fenêtre est désormais créée en code (`create: false`) avec
|
||||
`disable_drag_drop_handler()` : le HTML5 drag & drop (#89) fonctionne comme
|
||||
sur le web, avec un garde-fou de test.
|
||||
|
||||
---
|
||||
|
||||
## [2.50.0] — 2026-10-02
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#159 — Gestion desktop des vaults & dossiers** : retrait d'un vault ou dossier
|
||||
racine par menu contextuel (« Retirer de l'application », desktop uniquement,
|
||||
confirmation — déregistration locale, aucun fichier supprimé) et nouvelle section
|
||||
Configuration « 🖥️ Vaults & dossiers (Desktop) » avec listes, retrait par ligne et
|
||||
ajout de vault / dossier racine (nouvelle commande `pick_folder`, sélecteur natif
|
||||
sans effet de bord). La jump list des raccourcis suit les ajouts et retraits de
|
||||
vaults.
|
||||
|
||||
---
|
||||
|
||||
## [2.49.4] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-104 — commandes desktop bloquées par l'ACL Tauri** : la fenêtre desktop sert
|
||||
sa page depuis `http://127.0.0.1` (origine *remote*) et aucune commande applicative
|
||||
n'était déclarée — toutes étaient rejetées « not allowed by ACL », erreurs avalées
|
||||
par `desktop.js:invoke()`. Nouveau manifeste `desktop/permissions/commands.toml`
|
||||
(`allow-app-commands`, 19 commandes) référencé par la capability, avec un test
|
||||
garde-fou (`test_frontend_invokes_are_acl_allowed`). Le bouton « Choisir mon
|
||||
dossier » du wizard et toute la gestion vaults/dossiers desktop fonctionnent.
|
||||
- **BUG-105 — crash du desktop à l'ouverture (heap corruption `c0000374`)** : le
|
||||
setter de titre du menu contextuel des raccourcis (#77) confiait un `PROPVARIANT
|
||||
VT_LPWSTR` sur un buffer heap Rust au property store du shell, qui le libérait avec
|
||||
l'allocateur Windows — l'application se fermait 2 à 15 s après le lancement. Fonction
|
||||
supprimée (libellé assuré par `SetDescription`).
|
||||
|
||||
---
|
||||
|
||||
## [2.49.2] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- 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é
|
||||
|
||||
- **#158 — onglet Accueil.** Le clic sur le titre ouvre désormais la page d'accueil **dans un
|
||||
onglet** (icône Maison) systématiquement épinglé **en tête de barre**, réactivable d'un clic
|
||||
comme un onglet normal (le mode split a son propre onglet Accueil dans le panneau actif).
|
||||
- **#158 — menus contextuels dans la page de navigation.** Clic droit sur les répertoires et
|
||||
les fichiers de la vue : même menu que celui de la sidebar (`ContextMenuManager`), et la
|
||||
ligne d'un vault dans l'arbre ouvre à nouveau la page de navigation (racine du vault).
|
||||
- **#158 — icône des répertoires alignée sur la sidebar.** La couleur du dossier dans la
|
||||
section Répertoires est celle de l'arbre (`var(--accent)`).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-102 — menu contextuel refermé par son propre affichage.** L'auto-scroll déclenché par
|
||||
la reflow des icônes (lucide) à l'ouverture du menu arrivait dans le listener `scroll`
|
||||
(capture) et refermait le menu ~10 ms après son apparition : un décalage de 250 ms est
|
||||
ignoré, les vrais défilements ferment toujours le menu.
|
||||
- **BUG-101 — raccourcis d'onglets déclarés deux fois.** Le bloc « Keyboard shortcuts for
|
||||
tabs » existed en double dans `ui.js` (fin de fichier) : un seul `Ctrl+W` fermait **deux**
|
||||
onglets (le second refermait celui qui venait d'être ré-activé) et `Ctrl+Tab` sautait un
|
||||
onglet. Bloc dupliqué supprimé.
|
||||
- **#158 — clic titre ne repliait plus l'arborescence.** L'effacement du filtre de la sidebar
|
||||
n'est déclenché que s'il contenait quelque chose (sinon `restoreSidebarTree()` repliait tout
|
||||
l'arbre à chaque retour à l'accueil).
|
||||
|
||||
---
|
||||
|
||||
## [2.48.0] — 2026-10-02
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#158 — onglet de navigation.** Chaque clic sur un répertoire de l'arbre (mode focus vault
|
||||
**et** mode All) ouvre un **onglet de navigation** dédié qui coexiste avec les onglets de
|
||||
fichiers : la vue chemin + Récents + fichiers n'est plus écrasée par un document ouvert, et
|
||||
change de répertoire en se mettant à jour dans son onglet. La ligne d'un vault reste un
|
||||
simple expandeur ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette Vaults)
|
||||
ou le sélecteur de vault.
|
||||
- **#158 — contenu de la page de navigation.** La vue liste désormais les **sous-répertoires
|
||||
cliquables** et embarque le mécanisme de la page de recherche : panneau de facettes
|
||||
**Vaults · Tags · Extensions** (repli partagé, filtre local avec pastille active), tri
|
||||
**Pertinence / Date** et bouton **Sauver** (enregistre le répertoire comme recherche
|
||||
sauvegardée). `GET /api/vault/{vault}/files` renvoie les `tags` indexés de chaque fichier
|
||||
(champ `tags` ajouté à `VaultFileEntry`) pour calculer les facettes.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-100 — retour Home incomplet.** Le clic sur le titre perdait la section
|
||||
**Raccourcis & Astuces** (le template de reconstruction de `showWelcome()` ne la contenait
|
||||
pas) et laissait la recherche globale ainsi que le filtre de la sidebar actifs : le bloc
|
||||
`#quick-help` est désormais capturé puis réinjecté, et `goHome()` efface la recherche
|
||||
(chips, barre de résultats) avant d'afficher l'accueil.
|
||||
|
||||
---
|
||||
|
||||
## [2.47.1] — 2026-10-02
|
||||
|
||||
---
|
||||
|
||||
## [2.47.0] — 2026-10-01
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#157 — panneau de facettes repliable.** Le bloc **Vaults · Tags · Extensions** des résultats
|
||||
de recherche se masque/restaure d'un clic sur un bouton discret (chevron), état mémorisé
|
||||
dans `localStorage` et conservé d'une recherche à l'autre.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#157 — facette « Extensions » absente de l'API.** `SearchFacets` (`backend/schemas.py`)
|
||||
ne déclarait que `tags` et `vaults` : la clé `extensions` ajoutée par `backend/search.py`
|
||||
était supprimée par le `response_model` avant d'atteindre le navigateur. Champ ajouté,
|
||||
plus les tests d'advanced-search pointaient la route inexistante `/api/advanced-search`
|
||||
et sautaient en silence — ils ciblent désormais `/api/search/advanced`, avec une assertion
|
||||
stricte sur `facets.extensions`.
|
||||
- **#157 — bouton de repli des facettes maladroit sur mobile.** En dessous de 768 px, le
|
||||
panneau passe en colonne et le bouton s'étirait en pleine largeur d'écran, pivotait
|
||||
entièrement à la vertical au clic. Le bouton reste compact (`align-self: flex-start`) et
|
||||
**seule l'icône chevron pivote** (transition 120 ms), sur mobile comme sur desktop.
|
||||
|
||||
---
|
||||
|
||||
## [2.46.0] — 2026-10-01
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#157 — facette « Extensions » dans les facettes des résultats de recherche.** Le panneau de
|
||||
facettes de la page de résultats (Vaults · Tags) gagne un troisième groupe qui liste les
|
||||
extensions de fichiers présents dans les résultats (`.md`, `.xlsx`, `.pdf`, …) avec leurs
|
||||
compteurs ; cliquer une extension ajoute l'opérateur `ext:` à la requête (en remplaçant un
|
||||
`ext:` déjà actif). Le backend calcule `facets.extensions` dans `backend/search.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.45.2] — 2026-10-01
|
||||
|
||||
---
|
||||
|
||||
## [2.45.1] — 2026-10-01
|
||||
|
||||
---
|
||||
|
||||
## [2.45.0] — 2026-09-30
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#156-A5 — presse-papiers de plage dans l'éditeur Excel.** Copier, couper et coller un
|
||||
**bloc** de cellules (TSV), au clavier (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`) comme par le menu
|
||||
contextuel ; le collage remplit la plage à partir de la cellule active et la laisse
|
||||
sélectionnée. La grille tient son propre presse-papiers (le presse-papiers système est
|
||||
synchronisé quand le navigateur l'autorise), le collage est toujours du **texte** — jamais de
|
||||
HTML — et il est **annulable**. Un bloc plus large que la grille affichée est tronqué et signalé.
|
||||
- **#156-A6 — raccourcis clavier complets.** `Ctrl+S` (enregistrer), `Ctrl+A` (sélectionner la
|
||||
feuille rendue), `Suppr` (vider la sélection), `F2` (édition), `Ctrl+Home`/`Ctrl+End` (coins de
|
||||
la feuille), `Home`/`End` (bords de la ligne), `PgUp`/`PgDn` (un écran), `Ctrl+flèches` (bout de
|
||||
la plage de données) et `Maj+Entrée` (saut de ligne dans la cellule). Une cellule reste en mode
|
||||
« navigation » jusqu'à la saisie (ou `F2`) : `Suppr` et `Home`/`End` ne volent donc jamais une
|
||||
touche du curseur de texte.
|
||||
- **#156-A7 — zone Nom éditable et aide à la saisie.** La zone Nom devient un champ
|
||||
(« Atteindre ») : saisir `B12`, `A1:B3` ou `Feuille!A1` puis `Entrée` sélectionne la cellule ou
|
||||
la plage (avec changement d'onglet si la feuille est nommée) ; une référence inconnue est
|
||||
refusée avec un message et l'adresse précédente restaurée. La barre de formule propose les noms
|
||||
de fonctions courants (liste localisée FR/EN).
|
||||
- **#156-A8 — mise en forme en écriture.** Un bouton **Mise en forme** ouvre un menu qui
|
||||
agit sur la **sélection courante** (une cellule ou une plage) : gras, italique, souligné,
|
||||
effacement de la mise en forme, alignement gauche/centré/droite, couleur de police et de fond,
|
||||
format de nombre (général, nombre, pourcentage, devise, date, texte), fusion/défusion, volets
|
||||
figés/libérés et largeur de colonne / hauteur de ligne. L'écriture passe par une nouvelle route
|
||||
`PUT /api/file/{vault}/xlsx/style`, sur le même modèle que `xlsx/save` et `xlsx/structure` :
|
||||
verrou par fichier, écriture atomique (`.tmp` + `os.replace`), backup automatique, garde de perte
|
||||
(une opération qui effacerait des éléments non préservables est refusée sans `force`) et
|
||||
**concurrence optimiste** (`If-Match`, cf. A12). Les mises en forme ne sont pas proposées sur un
|
||||
`.csv` (le format n'en porte pas) ni sur une grille en lecture seule. Les couleurs viennent du
|
||||
sélecteur natif, jamais d'une palette codée en dur. Tests : `tests/test_xlsx_styles.py` (17) et
|
||||
JSDOM (application d'un format, refus d'un CSV, garde de perte).
|
||||
- **#156-A9 — le calcul est explicitement hors périmètre, et annoncé.** L'éditeur n'embarque
|
||||
aucun moteur de formule : ObsiGate affiche et écrit les formules telles qu'Excel les a
|
||||
enregistrées, les cellules dépendantes ne se recalculent pas à l'écran. Le choix est désormais
|
||||
**dit à l'utilisateur** : un enregistrement contenant des valeurs commençant par `=` ou `@`
|
||||
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé (la garde
|
||||
anti-DDE de BUG-088 reste en place). La pastille « Formules non recalculées » et la fiche #156
|
||||
portent la décision. Tests JSDOM : l'avertissement apparaît, et disparaît une fois `f(x)`
|
||||
activé.
|
||||
- **#156-A10 — historique annuler/rétablir unifié, conservé au re-rendu.** La pile ne couvrait
|
||||
que les éditions de cellules : « Effacer le contenu », le tri, le filtre et les actions de
|
||||
**structure** y entrent désormais, avec des entrées typées (`cell`, `order`, `hidden`,
|
||||
`structure`). Les piles sont **conservées par fichier** (8 fichiers, `_xlsxHistory`) au lieu
|
||||
d'être vidées par un re-rendu ; `Suppr` passe par le même chemin (donc annulable) ; le filtre
|
||||
est **coalescé** en une seule entrée par session de frappe ; un rejeu de structure appelle
|
||||
l'endpoint en mode silencieux (pas de doublon d'historique ni de bandeau parasite) et une
|
||||
action destructrice (suppression de ligne/colonne/feuille) n'est **pas** annulable, faute
|
||||
d'inverse. Tests JSDOM : édition → effacement → tri annulés successivement, insertion de ligne
|
||||
rejouée par l'endpoint, action destructive non annulable, filtre annulé par un seul `Ctrl+Z`.
|
||||
- **#156-A11 — sortie (CSV, Markdown, HTML, impression) et recherche sur tout le classeur.**
|
||||
L'export ne se limite plus à la feuille entière : le bouton **CSV** exporte la **sélection**
|
||||
quand une plage de plusieurs cellules est active (nom de fichier `Feuille-A1B2.csv`), sinon la
|
||||
feuille ; un menu **Exporter** ajoute le **Markdown** (tableau), le **HTML** (document
|
||||
autonome) et l'**impression** (la feuille seule, sans le ruban, via un cadre hors écran). Les
|
||||
quatre formats suivent le **contenu affiché** (la valeur calculée en cache reste une
|
||||
infobulle). L'écart doc/code de #153-A13 (« export de la sélection ») est tranché : la
|
||||
sélection est le défaut quand elle existe. La recherche (`Ctrl+F`) parcourt désormais **toutes
|
||||
les feuilles** : compteur « n/m · k feuilles » et activation automatique de l'onglet de la
|
||||
correspondance atteinte.
|
||||
- **#156-A14 — l'assistant IA couvre les trois formats édités et gagne trois outils.** Les
|
||||
outils existants acceptent `.xlsm` (écriture avec macros préservées) et `.csv` (lecture et
|
||||
écriture, délimiteur conservé) au lieu du seul `.xlsx` ; `update_xlsx_cells` écrit un `.csv`
|
||||
via `save_csv_cells`. Nouveaux outils : `search_workbook` (recherche dans toutes les feuilles,
|
||||
comptée par feuille), `analyze_range` (nombre, somme, moyenne, min, max d'une plage A1, avec
|
||||
échantillon borné) et `edit_xlsx_structure` (ajouter/renommer/dupliquer/supprimer une feuille,
|
||||
insérer/supprimer lignes et colonnes) — les deux premiers en risque READ, le dernier conservant
|
||||
la confirmation obligatoire des outils mutants. Tests `tests/test_spreadsheet_tools.py` (34).
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#156 — Éditeur Excel : audit de complétude, roadmap et 4 défauts enregistrés (documentation
|
||||
seule).** Nouvelle fiche `docs/features/xlsx-editor-completeness.md` (audit par couche, manques
|
||||
fonctionnels, risques, critères d'acceptation) et item **#156** dans `docs/ROADMAP.md`
|
||||
(P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3
|
||||
sortie/robustesse/perf). **BUG-096 → BUG-099** ouverts dans `docs/ISSUES_TODOLIST.md` :
|
||||
enregistrer un `.csv` depuis la visionneuse lève un `TypeError` (reproduit en JSDOM, aucun
|
||||
`PUT …/csv/save` émis) ; « Charger la suite » d'une feuille `.xlsm` tronquée répond **415** ;
|
||||
le délimiteur CSV est figé sur `,` alors que l'export écrit `;` ; la sonde de perte plafonnée à
|
||||
8 Mo peut laisser passer une écriture qui détruit les valeurs calculées en cache. L'audit
|
||||
lui-même ne changeait aucun code ; les correctifs correspondants suivent ci-dessous.
|
||||
- **#156-A12 — concurrence optimiste entre processus (`If-Match`, 409 réparable).** Chaque
|
||||
lecture renvoie la **version** du fichier (`xlsx_revision` : `mtime_ns` + taille) et les trois
|
||||
routes d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête `If-Match` ou
|
||||
le champ `if_match` : si le fichier a changé depuis, l'écriture est refusée en **409**
|
||||
`conflict` (`details.reason = "stale_revision"`, versions attendue et courante) au lieu
|
||||
d'écraser l'autre écrivain. Le bandeau « Réessayer » **relit** le fichier puis rejoue la
|
||||
sauvegarde avec la version courante ; les réponses de succès renvoient la nouvelle version, que
|
||||
le client enchaîne sur les écritures suivantes. Tests : `TestOptimisticConcurrency` (6) et
|
||||
JSDOM (version lue, version rafraîchie par la réponse, relecture avant le retry).
|
||||
- **#156-A13 — cache des métadonnées par `mtime`.** `read_workbook_meta()` rechargeait le
|
||||
classeur entier à chaque fenêtre servie ; les métadonnées (styles, fusions, volets, largeurs)
|
||||
sont désormais mémoïsées par `(chemin, mtime_ns, taille)` (LRU de 8) et invalidées à chaque
|
||||
écriture (cellules, structure, CSV). Charger une fenêtre ne rescanne plus le classeur. L'axe
|
||||
des colonnes au-delà de `MAX_COLS` reste hors périmètre (voir la fiche #156). Tests :
|
||||
`TestMetaCache` (4).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-096 — enregistrer un `.csv` depuis la visionneuse échoue (`TypeError`, aucun
|
||||
`PUT …/csv/save` émis).** Le job de sauvegarde lisait le nom de la feuille dans
|
||||
`xlsx_sheets` — absent du payload d'un CSV — donc `sheets[0].name` levait un `TypeError`
|
||||
**avant** toute requête : aucune modification de CSV ne pouvait être enregistrée. Le nom est
|
||||
désormais résolu avec repli (feuille → nom du fichier) ; l'endpoint `csv/save` est inchangé.
|
||||
Tests : JSDOM (un CSV monté **sans** `xlsx_sheets` émet un unique `PUT …/csv/save`).
|
||||
- **BUG-097 — « Charger la suite » d'une feuille `.xlsm` renvoyait **415**.** Un `.xlsm`
|
||||
s'édite dans la même visionneuse que le `.xlsx`, mais `GET …/xlsx/sheet` refusait tout autre
|
||||
format : les lignes au-delà du plafond de 500 étaient inaccessibles. L'endpoint accepte
|
||||
désormais `.xlsx` **et** `.xlsm` (les autres formats restent en **415**). Tests :
|
||||
`test_xlsx_formats.py` (fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`).
|
||||
- **BUG-098 — un CSV français (`;`) s'affichait en une seule colonne.** Le délimiteur était
|
||||
figé à la virgule, en lecture comme en réécriture. `sniff_csv_delimiter()` détecte `;`, `,`
|
||||
ou tabulation (repli : décompte sur la première ligne, puis `,`) et `save_csv_cells()`
|
||||
**réutilise le délimiteur détecté** — un CSV point-virgule s'affiche en colonnes distinctes et
|
||||
le reste après édition. Tests : `test_xlsx_formats.py` (lecture, écriture, champs entre
|
||||
guillemets contenant le séparateur).
|
||||
- **BUG-099 — perte silencieuse possible des valeurs calculées en cache (sonde plafonnée).**
|
||||
La détection des `<f>…</f><v>…</v>` disposait d'un budget **unique** de 8 Mo partagé entre les
|
||||
feuilles : au-delà, un résultat négatif était pris pour une preuve et l'écriture détruisait
|
||||
les valeurs mises en cache sans avertissement. Le budget est désormais **par feuille** (4 Mo)
|
||||
avec un plafond global (32 Mo), et un budget épuisé est **signalé** (`cached_values_unverified`,
|
||||
libellé FR/EN) : la garde de sauvegarde demande une confirmation au lieu de laisser passer.
|
||||
Tests : `TestXlsxCachedValueProbe` (budget épuisé → signalé, feuille volumineuse → la suivante
|
||||
reste scannée, scan complet propre → aucun faux positif).
|
||||
|
||||
---
|
||||
|
||||
## [2.44.1] — 2026-09-29
|
||||
|
||||
### Sécurité
|
||||
|
||||
- **BUG-095 — le job CI `security` repasse au vert : `pyjwt` monté au-dessus du dernier
|
||||
correctif.** `pip-audit` bloquait sur **CVE-2026-102274** dans `pyjwt` 2.13.0 (correctif
|
||||
**2.14.0**) : le plancher introduit par BUG-091 (`>=2.13.0`, qui corrigeait PYSEC-2026-178)
|
||||
était devenu insuffisant. Le plancher passe à `pyjwt[crypto]>=2.14.0` — volontairement au-dessus
|
||||
de la version préinstallée dans la toolcache du runner (sinon `pip` répond « already satisfied »
|
||||
et n'aligne jamais la version). Le garde-fou
|
||||
`tests/test_ci_workflow.py::TestDependencySecurityFloors` verrouille ce nouveau plancher
|
||||
(contre-preuve : plancher remis à `2.13.0` → test rouge).
|
||||
|
||||
---
|
||||
|
||||
## [2.44.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#155 — Ergonomie du tableur : menu contextuel (clic droit / appui long) et sélection type
|
||||
Excel.** Un **menu contextuel** thémé (nouveau module `frontend/js/xlsx/context-menu.js`)
|
||||
s'ouvre au **clic droit** sur une cellule, un numéro de ligne ou un en-tête de colonne, et par
|
||||
**appui long** sur mobile : insérer une ligne au-dessus/en dessous, une colonne à gauche/à
|
||||
droite (avec `count` pour une plage), supprimer la/les ligne(s) ou colonne(s) après confirmation,
|
||||
trier A→Z / Z→A et effacer le contenu. La grille adopte la **sélection d'Excel** : plage par
|
||||
**glisser**, extension par **`Maj+clic`** et **`Maj+flèches`**, sélection de la ligne/colonne
|
||||
entière au clic sur son en-tête, **curseur croix** sur les cellules et zone **Nom** affichant la
|
||||
plage (`A1:B3`). Le tri par simple clic sur l'en-tête laisse place à la sélection (tri déplacé
|
||||
dans le menu contextuel). `SW_VERSION` passe à `v29`. Tests JSDOM
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (60, dont 8 nouveaux) et E2E (9) verts.
|
||||
|
||||
---
|
||||
|
||||
## [2.43.2] — 2026-09-29
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-094 — une feuille `.xlsx` vide ou nouvellement ajoutée est désormais éditable et
|
||||
manipulable.** Elle s'affichait comme un simple « Feuille vide » sans aucune cellule : il était
|
||||
donc impossible d'y saisir une valeur ou d'y insérer une ligne/colonne (aucune cellule active →
|
||||
aucune action de structure). `render_sheets()` substitue maintenant un quadrillage vierge
|
||||
**20×8** aux vraies coordonnées A1 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`), et la
|
||||
visionneuse retombe sur `{row:1,col:1}` quand aucune cellule n'est active, de sorte que le menu
|
||||
Structure propose toujours insérer/supprimer ligne et colonne. Test de non-régression :
|
||||
`TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid`.
|
||||
|
||||
---
|
||||
|
||||
## [2.43.1] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
+5
-5
@@ -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/)
|
||||
@@ -75,7 +75,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md))
|
||||
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar ; chaque clic sur un répertoire de l'arbre ouvre un **onglet de navigation** — chemin, récents, sous-répertoires cliquables, facettes Vaults · Tags · Extensions, tri Pertinence/Date et enregistrement du répertoire — qui coexiste avec vos fichiers ouverts
|
||||
- **🔍 Recherche avancée** : Moteur TF-IDF avec stemming français, normalisation des accents, snippets surlignés, facettes, pagination et tri — plus une **recherche sémantique** optionnelle (embeddings `all-MiniLM-L6-v2`, fusion hybride TF-IDF + RRF) activable via le toggle `~` ([détail](docs/features/semantic-search.md))
|
||||
- **💡 Autocomplétion intelligente** : Suggestions de fichiers, tags et historique avec navigation clavier
|
||||
- **🧩 Syntaxe de requête** : Opérateurs `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` avec chips visuels
|
||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation clavier, barre de formule, tri/filtre/recherche, export CSV, édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`. L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, modifier des cellules et ajouter des lignes
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
@@ -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.43.1).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.2).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.43.1 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.53.2 | 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/)
|
||||
@@ -74,7 +74,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md))
|
||||
- **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously
|
||||
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar
|
||||
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar; clicking a folder in the tree opens a dedicated **navigation tab** — path, recents, clickable subfolders, Vaults · Tags · Extensions facets, Pertinence/Date sorting and save-as-search — coexisting with your open files
|
||||
- **🔍 Advanced Search** : TF-IDF search engine with French stemming, accent normalization, highlighted snippets, facets, pagination, and sorting — plus an optional **semantic search** (embeddings via `all-MiniLM-L6-v2`, hybrid TF-IDF + RRF fusion) toggled with `~` ([details](docs/features/semantic-search.md))
|
||||
- **💡 Smart Autocomplete** : Suggestions for files, tags, and history with keyboard navigation
|
||||
- **🧩 Query Syntax** : Operators `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` with visual chips
|
||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation, a formula bar, sort/filter/find, CSV export, sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled. The AI assistant can list sheets, dump a bounded table to its context, update cells and append rows
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -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.43.1).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.2).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.43.1 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.53.2 | Last updated: September 2026*
|
||||
|
||||
+17
-12
@@ -536,18 +536,6 @@ async def api_bookslm_agent(
|
||||
# run no longer pauses on every subsequent mutating call.
|
||||
ctx.confirmed = True
|
||||
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
msgs,
|
||||
tools=tool_schemas,
|
||||
provider=req.provider,
|
||||
model=req.model,
|
||||
temperature=0.3,
|
||||
# Tool-call arguments can carry a whole file body (e.g. a generated
|
||||
# table): leave more room than the plain-chat default.
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
async def generate_sse():
|
||||
import asyncio
|
||||
|
||||
@@ -561,6 +549,23 @@ async def api_bookslm_agent(
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
return
|
||||
|
||||
# #187: resolve the provider like /chat does — the agent must use
|
||||
# the same engine the SSE "provider" tag reports (the raw
|
||||
# req.provider could name an unavailable provider and silently
|
||||
# fall back to another one via _get_provider_config).
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
msgs,
|
||||
tools=tool_schemas,
|
||||
provider=cfg_name,
|
||||
model=req.model,
|
||||
temperature=0.3,
|
||||
# Tool-call arguments can carry a whole file body (e.g. a
|
||||
# generated table): leave more room than the plain-chat
|
||||
# default.
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
# Stream tool events live: each executed step is pushed on the
|
||||
# queue by the loop callback and emitted as soon as it happens,
|
||||
# so the UI can grow its « N steps » block while thinking.
|
||||
|
||||
+49
-1
@@ -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",
|
||||
@@ -185,6 +194,17 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/style"): {
|
||||
"request": {
|
||||
"ops": [
|
||||
{"op": "cell", "sheet": "Budget", "range": "A1:B1", "style": {"bold": True, "fill_color": "#ffe08a"}},
|
||||
{"op": "col_width", "sheet": "Budget", "col": "A", "width": 24},
|
||||
],
|
||||
"force": False,
|
||||
"if_match": "18f2c0ab-1f4",
|
||||
},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 2, "revision": "18f2c0ab-1f6"},
|
||||
},
|
||||
# GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI
|
||||
# invalide) — les paramètres sont documentés par leurs Query().
|
||||
("get", "/api/file/{vault_name}/xlsx/sheet"): {
|
||||
|
||||
@@ -26,9 +26,10 @@ webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
mcp==1.28.1
|
||||
# Plancher de sécurité (BUG-091) : pyjwt est une dépendance transitive (mcp) ;
|
||||
# 2.12.x est vulnérable (PYSEC-2026-178, fix 2.13.0) et pip-audit bloque sinon.
|
||||
pyjwt[crypto]>=2.13.0
|
||||
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
|
||||
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
|
||||
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
|
||||
pyjwt[crypto]>=2.14.0
|
||||
sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
xlrd==2.0.2
|
||||
@@ -36,3 +37,5 @@ odfpy==1.4.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||
urllib3>=2.8.0
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -42,6 +42,7 @@ from backend.schemas import (
|
||||
XlsxSheetWindowResponse,
|
||||
)
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
|
||||
@@ -226,7 +227,7 @@ def api_file_xlsx_dashboard(
|
||||
)
|
||||
def api_file_xlsx_sheet(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
|
||||
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
|
||||
limit: int = Query(
|
||||
@@ -234,7 +235,7 @@ def api_file_xlsx_sheet(
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a window of rows of one sheet of an .xlsx workbook (#153 A9).
|
||||
"""Return a window of rows of one sheet of an .xlsx/.xlsm workbook (#153 A9).
|
||||
|
||||
Backs the viewer's lazy loading: instead of every sheet in a single JSON
|
||||
payload, the client asks for the block it is about to display. The row
|
||||
@@ -258,7 +259,7 @@ def api_file_xlsx_sheet(
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
|
||||
415 (not an .xlsx file), 500 (unreadable workbook).
|
||||
415 (not an .xlsx/.xlsm file), 500 (unreadable workbook).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -269,8 +270,12 @@ def api_file_xlsx_sheet(
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".xlsx":
|
||||
raise HTTPException(status_code=415, detail="Le fichier n'est pas un classeur .xlsx")
|
||||
# BUG-097 — a .xlsm rides the same editable viewer (and its lazy loading),
|
||||
# so its row windows must be servable too; other formats stay refused.
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
|
||||
from backend.xlsx_reader import read_sheet_window
|
||||
@@ -368,6 +373,10 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
# #156-A12 — optimistic-concurrency token: the viewer sends it
|
||||
# back as `if_match` so another writer cannot be overwritten in
|
||||
# silence (409 `conflict` instead).
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# #153 A1 — parts a save would drop; the viewer warns and asks
|
||||
# for an explicit confirmation before forcing the write.
|
||||
"xlsx_lossy_features": inspect_workbook(file_path),
|
||||
@@ -507,6 +516,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# Macros are NOT lossy for .xlsm: keep_vba re-serializes them
|
||||
# (an empty LOSSY probe is what makes the save gate pass).
|
||||
"xlsx_lossy_features": [],
|
||||
@@ -554,6 +564,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
"html": html, "raw_length": len(raw), "extension": ext,
|
||||
"is_markdown": False, "is_csv": True,
|
||||
# #156-A12 — same stale-write guard as the workbooks.
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
# === JSON: syntax-highlighted display ===
|
||||
|
||||
+109
-10
@@ -67,6 +67,9 @@ from backend.services.mutations import (
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_structure as service_mutate_xlsx_structure,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_style as service_mutate_xlsx_style,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
@@ -127,7 +130,7 @@ def api_file_xlsx_save(
|
||||
...,
|
||||
description=(
|
||||
'{"sheet": str, "cells": {"A1": value}, '
|
||||
'"allow_formula": false, "force": false}'
|
||||
'"allow_formula": false, "force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -144,6 +147,10 @@ def api_file_xlsx_save(
|
||||
(slicers, form controls, connections, custom XML, signature, cached formula
|
||||
results). Without it the call fails **409** ``xlsx_lossy_content`` and the
|
||||
client asks the user to confirm (#153 A1).
|
||||
* ``if_match`` — revision token returned by the read (#156-A12). When it no
|
||||
longer matches the file on disk the write is refused with **409**
|
||||
``conflict`` (``reason=stale_revision``) instead of overwriting a change
|
||||
made by another writer. Omitted: last writer wins (curl, AI tools).
|
||||
|
||||
A backup is created before the workbook is rewritten, and the new archive
|
||||
swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl
|
||||
@@ -168,16 +175,22 @@ def api_file_xlsx_save(
|
||||
if not isinstance(raw, bool):
|
||||
raise HTTPException(status_code=400, detail=f"Flag invalide: {name}")
|
||||
flags[name] = raw
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_edit_xlsx_cells(
|
||||
vault_name, path, sheet, cells, **flags
|
||||
vault_name, path, sheet, cells, expected_revision=if_match, **flags
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
|
||||
@@ -186,7 +199,11 @@ def api_file_csv_save(
|
||||
path: str = Query(..., description="Relative path to the .csv file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description='{"cells": {"A1": value}} — A1-addressed text edits (#153 A16)',
|
||||
description=(
|
||||
'{"cells": {"A1": value}, "if_match": str} — A1-addressed text '
|
||||
'edits (#153 A16). `if_match` is the revision token of the read '
|
||||
'(#156-A12): a stale token fails with 409 `conflict`.'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
@@ -195,6 +212,9 @@ def api_file_csv_save(
|
||||
The grid is re-parsed with :mod:`csv`, patched and re-serialized
|
||||
(RFC 4180 quoting). References beyond the extent grow the grid. Values
|
||||
are stored verbatim as text — a CSV has no formula engine.
|
||||
|
||||
``if_match`` (optional, #156-A12) is the revision the client read: when the
|
||||
file changed in the meantime the write is refused with **409** ``conflict``.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -206,13 +226,20 @@ def api_file_csv_save(
|
||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
||||
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
||||
|
||||
result = service_save_csv_cells(vault_name, path, cells)
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_save_csv_cells(vault_name, path, cells, expected_revision=if_match)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
|
||||
@@ -224,7 +251,7 @@ def api_file_xlsx_structure(
|
||||
description=(
|
||||
'{"actions": [{"op": "sheet_add", "name": "X"}, '
|
||||
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
|
||||
'"force": false}'
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -239,7 +266,9 @@ def api_file_xlsx_structure(
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced.
|
||||
edits). A backup is created before the archive is replaced. The optional
|
||||
``if_match`` revision (#156-A12) refuses a structural rewrite on a file that
|
||||
changed since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
@@ -258,16 +287,86 @@ def api_file_xlsx_structure(
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_structure(
|
||||
vault_name, path, actions, force=raw_force
|
||||
vault_name, path, actions, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(actions),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"])}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/style", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"ops": [{"op": "cell", "sheet": "X", "range": "A1:B2", '
|
||||
'"style": {"bold": true, "fill_color": "#ffe08a"}}], '
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Write formatting on an .xlsx/.xlsm workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
|
||||
``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``,
|
||||
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
|
||||
and ``freeze`` (``cell``, empty to release).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced; the optional
|
||||
``if_match`` revision (#156-A12) refuses a rewrite on a file that changed
|
||||
since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx``/``.xlsm`` file.
|
||||
body: JSON body with ``ops`` (1 to 50) and optional ``force``.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
ops = body.get("ops")
|
||||
if not isinstance(ops, list) or not ops or len(ops) > 50:
|
||||
raise HTTPException(status_code=400, detail="Ops invalides (1 à 50 par requête)")
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_style(
|
||||
vault_name, path, ops, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(ops),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
|
||||
@@ -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
|
||||
+18
-1
@@ -301,6 +301,14 @@ class FileContentResponse(BaseModel):
|
||||
"when the sheet exceeds the 500x40 render caps (#153 A8)"
|
||||
),
|
||||
)
|
||||
xlsx_revision: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optimistic-concurrency token of the spreadsheet (#156-A12): the "
|
||||
"client sends it back as the `if_match` of a write so a change made "
|
||||
"elsewhere is refused (409 `conflict`) instead of overwritten"
|
||||
),
|
||||
)
|
||||
xlsx_lossy_features: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
@@ -401,6 +409,12 @@ class FileSaveResponse(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
size: int = Field(description="Size of saved content in characters")
|
||||
# #156-A12 — optimistic-concurrency token of the file AFTER the write, so a
|
||||
# client can chain writes without re-reading (absent on non-spreadsheets).
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
description="Opaque revision of the saved spreadsheet (send it back as `if_match`)",
|
||||
)
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
@@ -632,6 +646,7 @@ class SearchFacets(BaseModel):
|
||||
|
||||
tags: dict[str, int] = Field(default_factory=dict)
|
||||
vaults: dict[str, int] = Field(default_factory=dict)
|
||||
extensions: dict[str, int] = Field(default_factory=dict, description="Counts per file extension, dotless and lowercase (query-ready for the ext: operator)")
|
||||
|
||||
|
||||
class AdvancedSearchResponse(BaseModel):
|
||||
@@ -641,7 +656,7 @@ class AdvancedSearchResponse(BaseModel):
|
||||
total: int = Field(description="Total number of matching results")
|
||||
offset: int = Field(description="Current pagination offset")
|
||||
limit: int = Field(description="Page size")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag, vault and file extension")
|
||||
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
|
||||
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
|
||||
|
||||
@@ -652,6 +667,7 @@ class TitleSuggestion(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(default_factory=list, description="File tags")
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
@@ -847,6 +863,7 @@ class VaultFileEntry(BaseModel):
|
||||
modified_iso: str | None = None
|
||||
extension: str = ""
|
||||
rel_dir: str | None = None
|
||||
tags: list[str] = Field(default_factory=list, description="Tags de l'index (#158)")
|
||||
|
||||
|
||||
class VaultFilesResponse(BaseModel):
|
||||
|
||||
+16
-3
@@ -373,6 +373,7 @@ class InvertedIndex:
|
||||
self.doc_vault: dict[str, str] = {}
|
||||
self.vault_docs: dict[str, set] = defaultdict(set)
|
||||
self.tag_docs: dict[str, set] = defaultdict(set)
|
||||
self.doc_tags: dict[str, set] = defaultdict(set)
|
||||
self._sorted_tokens: SortedList = SortedList()
|
||||
self._ready: bool = False # True after initial build
|
||||
|
||||
@@ -403,6 +404,7 @@ class InvertedIndex:
|
||||
self.doc_vault = {}
|
||||
self.vault_docs = defaultdict(set)
|
||||
self.tag_docs = defaultdict(set)
|
||||
self.doc_tags = defaultdict(set)
|
||||
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
for file_info in vault_data.get("files", []):
|
||||
@@ -417,6 +419,7 @@ class InvertedIndex:
|
||||
# --- Per-document tag index ---
|
||||
for tag in file_info.get("tags", []):
|
||||
self.tag_docs[tag.lower()].add(doc_key)
|
||||
self.doc_tags[file_info['path']].add(tag.lower())
|
||||
|
||||
# --- Title tokens ---
|
||||
title_tokens = tokenize(file_info.get("title", ""))
|
||||
@@ -1324,6 +1327,7 @@ def advanced_search(
|
||||
scored_results: list[tuple[float, dict[str, Any]]] = []
|
||||
facet_tags: dict[str, int] = defaultdict(int)
|
||||
facet_vaults: dict[str, int] = defaultdict(int)
|
||||
facet_extensions: dict[str, int] = defaultdict(int)
|
||||
|
||||
# Pre-compute prefix expansions once per term (avoid repeated binary search)
|
||||
prefix_expansions: dict[str, list[str]] = {}
|
||||
@@ -1423,6 +1427,11 @@ def advanced_search(
|
||||
facet_vaults[result["vault"]] = facet_vaults.get(result["vault"], 0) + 1
|
||||
for tag in result.get("tags", []):
|
||||
facet_tags[tag] = facet_tags.get(tag, 0) + 1
|
||||
# Extension normalised without leading dot ("md", not ".md") so it can be
|
||||
# fed back directly as the `ext:` query operator.
|
||||
ext = str(result.get("extension") or "").lower().lstrip(".")
|
||||
if ext:
|
||||
facet_extensions[ext] = facet_extensions.get(ext, 0) + 1
|
||||
|
||||
total = len(scored_results)
|
||||
page = scored_results[offset: offset + limit]
|
||||
@@ -1436,6 +1445,7 @@ def advanced_search(
|
||||
"facets": {
|
||||
"tags": dict(sorted(facet_tags.items(), key=lambda x: -x[1])[:20]),
|
||||
"vaults": dict(sorted(facet_vaults.items(), key=lambda x: -x[1])),
|
||||
"extensions": dict(sorted(facet_extensions.items(), key=lambda x: -x[1])),
|
||||
},
|
||||
"query_time_ms": elapsed_ms,
|
||||
"semantic_available": semantic_available,
|
||||
@@ -1538,7 +1548,7 @@ def suggest_titles(
|
||||
prefix: str,
|
||||
vault_filter: str = "all",
|
||||
limit: int = SUGGEST_LIMIT,
|
||||
) -> list[dict[str, str]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Args:
|
||||
@@ -1547,7 +1557,7 @@ def suggest_titles(
|
||||
limit: Maximum suggestions.
|
||||
|
||||
Returns:
|
||||
List of ``{"vault", "path", "title"}`` dicts.
|
||||
List of ``{"vault", "path", "title", "tags"}`` dicts.
|
||||
"""
|
||||
if not prefix or len(prefix) < MIN_PREFIX_LENGTH:
|
||||
return []
|
||||
@@ -1565,7 +1575,10 @@ def suggest_titles(
|
||||
key = f"{entry['vault']}::{entry['path']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(entry)
|
||||
# Add tags from the index
|
||||
entry_with_tags: dict[str, Any] = dict(entry)
|
||||
entry_with_tags["tags"] = list(inv.doc_tags.get(entry["path"], set()))
|
||||
results.append(entry_with_tags)
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -278,6 +278,67 @@ def _xlsx_write_lock(key: str) -> Iterator[None]:
|
||||
lock.release()
|
||||
|
||||
|
||||
def _invalidate_meta(file_path: Path) -> None:
|
||||
"""Drop the cached workbook metadata after a write (#156-A13).
|
||||
|
||||
Imported lazily so the module stays importable when the spreadsheet reader
|
||||
is not needed (it pulls openpyxl in).
|
||||
"""
|
||||
try:
|
||||
from backend.xlsx_reader import invalidate_workbook_meta
|
||||
|
||||
invalidate_workbook_meta(file_path)
|
||||
except Exception: # pragma: no cover - cache invalidation is best-effort
|
||||
logger.debug("workbook meta invalidation skipped", exc_info=True)
|
||||
|
||||
|
||||
def file_revision(file_path: Path) -> str:
|
||||
"""Opaque revision token of a file — ``mtime_ns:size`` in hex (#156-A12).
|
||||
|
||||
Cheap by design (one ``stat``) and enough for optimistic concurrency: any
|
||||
writer that replaces the file changes at least one of the two fields. The
|
||||
viewer reads it with the file and sends it back as the ``if_match`` of a
|
||||
write, so an external editor (Excel, the watcher, another worker) can no
|
||||
longer be silently overwritten.
|
||||
|
||||
Returns:
|
||||
The token, or ``""`` when the file cannot be stat'ed.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
return f"{st.st_mtime_ns:x}-{st.st_size:x}"
|
||||
|
||||
|
||||
def _check_revision(file_path: Path, expected: str | None) -> None:
|
||||
"""Refuse a write on a file that changed since it was read (#156-A12).
|
||||
|
||||
``expected`` comes from the read payload (``if_match``): when it is absent
|
||||
the write keeps its pre-A12 behaviour (last writer wins), so curl, the AI
|
||||
tools and the batch uploader are unaffected.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``conflict`` (409, ``reason=stale_revision``) when the
|
||||
file on disk is not the revision the caller read.
|
||||
"""
|
||||
if not expected:
|
||||
return
|
||||
current = file_revision(file_path)
|
||||
if current and current != expected:
|
||||
raise ServiceError(
|
||||
"The file changed on disk since it was read; reload before saving",
|
||||
code="conflict",
|
||||
status=409,
|
||||
details={
|
||||
"reason": "stale_revision",
|
||||
"path": str(file_path),
|
||||
"expected_revision": expected,
|
||||
"current_revision": current,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _coerce_xlsx_value(value: Any) -> Any:
|
||||
"""Turn the string sent by the cell editor back into a scalar (#153 A10).
|
||||
|
||||
@@ -354,6 +415,7 @@ def edit_xlsx_cells(
|
||||
backup: bool = True,
|
||||
allow_formula: bool = False,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
||||
|
||||
@@ -416,6 +478,9 @@ def edit_xlsx_cells(
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — inside the lock: no writer can slip in between the check
|
||||
# and the load.
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
|
||||
@@ -451,6 +516,9 @@ def edit_xlsx_cells(
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
# #156-A13 — the metadata cache is keyed on (mtime, size); drop it too
|
||||
# so a rewrite that lands on the same tick can never serve stale maps.
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||
return {
|
||||
@@ -458,6 +526,7 @@ def edit_xlsx_cells(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
@@ -468,6 +537,7 @@ def mutate_xlsx_structure(
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
|
||||
|
||||
@@ -521,6 +591,8 @@ def mutate_xlsx_structure(
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — stale-write guard (see edit_xlsx_cells).
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.worksheet.copier import WorksheetCopy
|
||||
|
||||
@@ -636,6 +708,7 @@ def mutate_xlsx_structure(
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(
|
||||
f"XLSX structure: {vault_name}/{rel_path} {applied}"
|
||||
@@ -645,6 +718,306 @@ def mutate_xlsx_structure(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
# #156-A8 — write-side formatting. Every value is data-driven (the colours
|
||||
# come from the caller, never from a hardcoded palette) and the whole batch
|
||||
# rides the same lock, backup and atomic swap as the cell edits.
|
||||
_STYLE_KEYS = {
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"font_color",
|
||||
"fill_color",
|
||||
"align",
|
||||
"number_format",
|
||||
}
|
||||
_STYLE_ALIGNS = {"left", "center", "right"}
|
||||
MAX_STYLE_CELLS = 10_000
|
||||
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def _style_color(value: Any) -> str | None:
|
||||
"""Normalise ``#rrggbb`` to openpyxl's ARGB, or ``None`` to clear."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str) or not _HEX_COLOR_RE.match(value.strip()):
|
||||
raise ServiceError(
|
||||
f"Couleur invalide: {value!r}", code="invalid", status=400
|
||||
)
|
||||
return "FF" + value.strip().lstrip("#").upper()
|
||||
|
||||
|
||||
def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
"""Apply the data-driven style fragment of one ``cell`` operation.
|
||||
|
||||
Only the keys listed in :data:`_STYLE_KEYS` are accepted — an unknown one
|
||||
is a client bug, not something to ignore silently. Font attributes are
|
||||
copied before mutation so the shared style of the other cells is left
|
||||
untouched.
|
||||
"""
|
||||
import copy as copy_mod
|
||||
|
||||
from openpyxl.styles import Alignment, Color, PatternFill
|
||||
|
||||
unknown = set(style) - _STYLE_KEYS
|
||||
if unknown:
|
||||
raise ServiceError(
|
||||
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
|
||||
)
|
||||
if {"bold", "italic", "underline", "font_color"} & set(style):
|
||||
font = copy_mod.copy(cell.font)
|
||||
if "bold" in style:
|
||||
font.bold = bool(style["bold"])
|
||||
if "italic" in style:
|
||||
font.italic = bool(style["italic"])
|
||||
if "underline" in style:
|
||||
font.underline = "single" if style["underline"] else None
|
||||
if "font_color" in style:
|
||||
rgb = _style_color(style["font_color"])
|
||||
font.color = Color(rgb=rgb) if rgb else None
|
||||
cell.font = font
|
||||
if "fill_color" in style:
|
||||
rgb = _style_color(style["fill_color"])
|
||||
cell.fill = (
|
||||
PatternFill(start_color=rgb, end_color=rgb, fill_type="solid")
|
||||
if rgb
|
||||
else PatternFill(fill_type=None)
|
||||
)
|
||||
if "align" in style:
|
||||
align = style["align"]
|
||||
if align not in _STYLE_ALIGNS:
|
||||
raise ServiceError(
|
||||
f"Alignement invalide: {align!r}", code="invalid", status=400
|
||||
)
|
||||
current = cell.alignment
|
||||
cell.alignment = Alignment(
|
||||
horizontal=align,
|
||||
vertical=getattr(current, "vertical", None),
|
||||
wrap_text=getattr(current, "wrap_text", None),
|
||||
)
|
||||
if "number_format" in style:
|
||||
fmt = style["number_format"] or "General"
|
||||
if not isinstance(fmt, str) or len(fmt) > 120:
|
||||
raise ServiceError(
|
||||
"Format de nombre invalide", code="invalid", status=400
|
||||
)
|
||||
cell.number_format = fmt
|
||||
|
||||
|
||||
def mutate_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
ops: list[dict[str, Any]],
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write formatting on an ``.xlsx``/``.xlsm`` workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
|
||||
|
||||
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
|
||||
``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color``
|
||||
(``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and
|
||||
``number_format`` ;
|
||||
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
|
||||
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
|
||||
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
|
||||
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
|
||||
|
||||
Same guards as the cell edits: per-file lock, ``.bak`` backup,
|
||||
``.tmp`` + ``os.replace`` atomic swap, lossy-write 409 and the optional
|
||||
``expected_revision`` concurrency check (#156-A12).
|
||||
"""
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise ServiceError(
|
||||
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
|
||||
)
|
||||
if not ops or len(ops) > 50:
|
||||
raise ServiceError(
|
||||
"Invalid ops (1 to 50 per request)", code="invalid", status=400
|
||||
)
|
||||
|
||||
if not force and file_path.suffix.lower() != ".xlsm":
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Restyling this workbook would drop features ObsiGate cannot "
|
||||
"preserve; retry with force=true after confirmation",
|
||||
code="xlsx_lossy_content",
|
||||
status=409,
|
||||
details={"path": path, "features": lossy},
|
||||
)
|
||||
|
||||
def _range(ref: str) -> tuple[int, int, int, int]:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(str(ref).upper())
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Plage invalide: {ref!r}", code="invalid", status=400
|
||||
) from exc
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_STYLE_CELLS:
|
||||
raise ServiceError(
|
||||
f"Plage trop grande (max {MAX_STYLE_CELLS} cellules)",
|
||||
code="invalid",
|
||||
status=400,
|
||||
)
|
||||
return min_col, min_row, max_col, max_row
|
||||
|
||||
def _sheet(wb: Any, action: dict[str, Any]) -> Any:
|
||||
name = str(action.get("sheet", ""))
|
||||
if name not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {name}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
details={"sheets": wb.sheetnames},
|
||||
)
|
||||
return wb[name]
|
||||
|
||||
applied: list[str] = []
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
try:
|
||||
wb = load_workbook(
|
||||
file_path, keep_vba=file_path.suffix.lower() == ".xlsm"
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
rel_path = _rel(root, file_path)
|
||||
try:
|
||||
for i, action in enumerate(ops):
|
||||
if not isinstance(action, dict):
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} invalide", code="invalid", status=400
|
||||
)
|
||||
op = action.get("op")
|
||||
try:
|
||||
if op == "cell":
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range") or action.get("cell") or "")
|
||||
min_col, min_row, max_col, max_row = _range(ref)
|
||||
style = action.get("style") or {}
|
||||
if not isinstance(style, dict) or not style:
|
||||
raise ServiceError(
|
||||
"Style vide", code="invalid", status=400
|
||||
)
|
||||
for row in ws.iter_rows(
|
||||
min_row=min_row,
|
||||
max_row=max_row,
|
||||
min_col=min_col,
|
||||
max_col=max_col,
|
||||
):
|
||||
for cell in row:
|
||||
_apply_cell_style(cell, style)
|
||||
applied.append(
|
||||
f"cell:{ws.title}!{ref}:{','.join(sorted(style))}"
|
||||
)
|
||||
elif op in ("merge", "unmerge"):
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range", ""))
|
||||
_range(ref)
|
||||
if op == "merge":
|
||||
ws.merge_cells(ref)
|
||||
else:
|
||||
ws.unmerge_cells(ref)
|
||||
applied.append(f"{op}:{ws.title}!{ref}")
|
||||
elif op == "col_width":
|
||||
ws = _sheet(wb, action)
|
||||
col = action.get("col")
|
||||
width = action.get("width")
|
||||
if isinstance(col, int):
|
||||
col = get_column_letter(col)
|
||||
if not isinstance(col, str) or not col.strip():
|
||||
raise ServiceError(
|
||||
"Colonne invalide", code="invalid", status=400
|
||||
)
|
||||
if not isinstance(width, (int, float)) or not 0 <= float(width) <= 255:
|
||||
raise ServiceError(
|
||||
"Largeur invalide (0 à 255)", code="invalid", status=400
|
||||
)
|
||||
ws.column_dimensions[col.strip().upper()].width = float(width)
|
||||
applied.append(f"col_width:{ws.title}!{col}={width}")
|
||||
elif op == "row_height":
|
||||
ws = _sheet(wb, action)
|
||||
row = action.get("row")
|
||||
height = action.get("height")
|
||||
if not isinstance(row, int) or row < 1:
|
||||
raise ServiceError("Ligne invalide", code="invalid", status=400)
|
||||
if not isinstance(height, (int, float)) or not 0 <= float(height) <= 409:
|
||||
raise ServiceError(
|
||||
"Hauteur invalide (0 à 409)", code="invalid", status=400
|
||||
)
|
||||
ws.row_dimensions[row].height = float(height)
|
||||
applied.append(f"row_height:{ws.title}!{row}={height}")
|
||||
elif op == "freeze":
|
||||
ws = _sheet(wb, action)
|
||||
cell_ref = str(action.get("cell", ""))
|
||||
if cell_ref:
|
||||
_range(cell_ref)
|
||||
ws.freeze_panes = cell_ref.upper() or None
|
||||
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
|
||||
else:
|
||||
raise ServiceError(
|
||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
||||
)
|
||||
except ServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} ({op}) a échoué: {exc}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
) from exc
|
||||
except ServiceError:
|
||||
wb.close()
|
||||
raise
|
||||
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
wb.save(tmp_path)
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX style: {vault_name}/{rel_path} {applied}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
@@ -654,14 +1027,18 @@ def save_csv_cells(
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
|
||||
|
||||
The file is re-parsed, patched and re-serialized with :mod:`csv` so
|
||||
quoting follows RFC 4180. References beyond the current extent grow the
|
||||
grid (missing rows/cells are filled with empty strings). Values are
|
||||
stored as text: a CSV has no formula engine, so any string — including
|
||||
ones starting with ``=`` — is written verbatim (the render escapes it).
|
||||
quoting follows RFC 4180. BUG-098 — the delimiter is **sniffed** and the
|
||||
same one is reused on write-back: a `;`-separated French CSV used to be
|
||||
parsed as a single column and rewritten with `,`. References beyond the
|
||||
current extent grow the grid (missing rows/cells are filled with empty
|
||||
strings). Values are stored as text: a CSV has no formula engine, so any
|
||||
string — including ones starting with ``=`` — is written verbatim (the
|
||||
render escapes it).
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
|
||||
@@ -670,6 +1047,8 @@ def save_csv_cells(
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
@@ -691,9 +1070,13 @@ def save_csv_cells(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
# #156-A12 — same stale-write guard as the workbook path.
|
||||
_check_revision(file_path, expected_revision)
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
# BUG-098 — parse AND rewrite with the file's own delimiter.
|
||||
delimiter = sniff_csv_delimiter(raw)
|
||||
try:
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw)))
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter))
|
||||
except csv_mod.Error:
|
||||
rows = [[line] for line in raw.splitlines()]
|
||||
|
||||
@@ -721,7 +1104,7 @@ def save_csv_cells(
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
buf = io_mod.StringIO()
|
||||
csv_mod.writer(buf, lineterminator="\n").writerows(rows)
|
||||
csv_mod.writer(buf, delimiter=delimiter, lineterminator="\n").writerows(rows)
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
tmp_path.write_text(buf.getvalue(), encoding="utf-8")
|
||||
@@ -736,6 +1119,7 @@ def save_csv_cells(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,29 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
return {"vault": vault_name, "path": path, "items": items}
|
||||
|
||||
|
||||
def _indexed_tags(vault_name: str, rel_path: str) -> list[str]:
|
||||
"""Tags of a file as stored in the search index (#158).
|
||||
|
||||
Empty list when the file is not indexed yet (binary formats, index still
|
||||
building) — the listing itself comes from the filesystem, tags are a
|
||||
decoration used by the navigation page facets/filters.
|
||||
|
||||
Args:
|
||||
vault_name: Vault name.
|
||||
rel_path: Path of the file relative to the vault root (``/`` separated).
|
||||
|
||||
Returns:
|
||||
The file's tags, or an empty list when unknown.
|
||||
"""
|
||||
try:
|
||||
from backend.search import get_inverted_index
|
||||
|
||||
info = get_inverted_index().doc_info.get(f"{vault_name}::{rel_path}")
|
||||
except Exception:
|
||||
return []
|
||||
return list((info or {}).get("tags") or [])
|
||||
|
||||
|
||||
def list_all_files(
|
||||
vault_name: str,
|
||||
dir: str = "",
|
||||
@@ -189,6 +212,7 @@ def list_all_files(
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"extension": ext.lstrip(".") if ext else "",
|
||||
"tags": _indexed_tags(vault_name, rel_path),
|
||||
}
|
||||
if rel_to_dir and rel_to_dir != ".":
|
||||
file_entry["rel_dir"] = rel_to_dir
|
||||
|
||||
@@ -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
|
||||
@@ -56,9 +56,19 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
||||
"xlsx_to_markdown": ("xlsx_read", "path"),
|
||||
"update_xlsx_cells": ("xlsx_update", "path"),
|
||||
"append_xlsx_rows": ("xlsx_append", "path"),
|
||||
"search_workbook": ("xlsx_search", "query"),
|
||||
"analyze_range": ("xlsx_analyze", "path"),
|
||||
"edit_xlsx_structure": ("xlsx_structure", "path"),
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
"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
|
||||
@@ -117,9 +117,22 @@ def list_tools(*, scope: ToolScope | None = None) -> list[ToolSpec]:
|
||||
return specs
|
||||
|
||||
|
||||
# ponytail: tool schemas are static after import (registration is decorator
|
||||
# only); keying the cache on len(_REGISTRY) invalidates it if a tool is ever
|
||||
# registered at runtime. Rebuilding 50 pydantic JSON schemas cost ~27 ms per
|
||||
# agent/MCP request.
|
||||
_SCHEMAS_CACHE: dict[Any, list[dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def get_tool_schemas(*, scope: ToolScope | None = None) -> list[dict[str, Any]]:
|
||||
"""Return OpenAI-compatible schemas for registered tools."""
|
||||
return [spec.openai_schema() for spec in list_tools(scope=scope)]
|
||||
"""Return OpenAI-compatible schemas for registered tools (cached)."""
|
||||
key = (scope, len(_REGISTRY))
|
||||
cached = _SCHEMAS_CACHE.get(key)
|
||||
if cached is None:
|
||||
cached = [spec.openai_schema() for spec in list_tools(scope=scope)]
|
||||
_SCHEMAS_CACHE.clear()
|
||||
_SCHEMAS_CACHE[key] = cached
|
||||
return [dict(s) for s in cached]
|
||||
|
||||
|
||||
def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: bool, error: str | None = None) -> None:
|
||||
|
||||
@@ -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
|
||||
@@ -332,12 +332,42 @@ class XlsxToMarkdownInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SearchWorkbookInput(BaseModel):
|
||||
"""Find a text across the sheets of a spreadsheet (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
query: str = Field(..., description="Text to look for")
|
||||
sheet: str = Field("", description="Restrict to one sheet (empty = all sheets)")
|
||||
case_sensitive: bool = Field(False, description="Match case")
|
||||
|
||||
|
||||
class AnalyzeRangeInput(BaseModel):
|
||||
"""Aggregate the values of an A1 range (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
sheet: str = Field(
|
||||
"", description="Sheet name (empty = the first/active sheet)"
|
||||
)
|
||||
range: str = Field(
|
||||
"",
|
||||
description="A1 range to analyse (e.g. 'B2:B50'); empty = the whole sheet",
|
||||
)
|
||||
|
||||
|
||||
class UpdateXlsxCellsInput(BaseModel):
|
||||
"""Batch-edit cells of an existing .xlsx workbook (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(..., description="Worksheet title to edit")
|
||||
sheet: str = Field(
|
||||
"", description="Worksheet title to edit (ignored for a .csv)"
|
||||
)
|
||||
cells: dict[str, str | int | float | bool | None] = Field(
|
||||
..., description="A1 reference -> new value (max 500 per call)"
|
||||
)
|
||||
@@ -370,6 +400,25 @@ class AppendXlsxRowsInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class EditXlsxStructureInput(BaseModel):
|
||||
"""Structural CRUD on an existing workbook (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx/.xlsm file")
|
||||
actions: list[dict[str, Any]] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Ordered structural actions (1-50): sheet_add/sheet_rename/"
|
||||
"sheet_duplicate/sheet_delete, row_insert/row_delete/"
|
||||
"col_insert/col_delete"
|
||||
),
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class CsvInput(BaseModel):
|
||||
"""Create a .csv file in a vault from rows of cells."""
|
||||
|
||||
@@ -392,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`."""
|
||||
|
||||
|
||||
+392
-105
@@ -1,21 +1,30 @@
|
||||
"""Spreadsheet tools (#153 A6) — read and mutate existing ``.xlsx`` workbooks.
|
||||
"""Spreadsheet tools (#153 A6, #156 A14) — read and mutate existing workbooks.
|
||||
|
||||
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
|
||||
file; here the assistant can read and edit one that already exists):
|
||||
file; here the assistant can read and edit one that already exists). The same
|
||||
three formats the viewer edits are supported — ``.xlsx``, ``.xlsm`` and
|
||||
``.csv`` (#156-A14 used to be ``.xlsx`` only, which made a workbook the UI
|
||||
edits invisible to the assistant):
|
||||
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (wraps the guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet.
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``search_workbook`` — READ, find text across every sheet (#156-A14);
|
||||
* ``analyze_range`` — READ, aggregate stats over an A1 range (#156-A14);
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet;
|
||||
* ``edit_xlsx_structure`` — WRITE, structural CRUD (sheets/rows/columns).
|
||||
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`,
|
||||
which already carries the #153 P0 guards: per-file lock, atomic replace,
|
||||
formula neutralisation (``allow_formula`` opt-in) and the lossy-write 409.
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`
|
||||
(or ``save_csv_cells`` / ``mutate_xlsx_structure``), which already carry the
|
||||
#153 P0 guards: per-file lock, atomic replace, formula neutralisation
|
||||
(``allow_formula`` opt-in) and the lossy-write 409. Every write is a WRITE-risk
|
||||
tool, so the registry keeps asking for an explicit confirmation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -25,8 +34,11 @@ from backend.services.vaults import get_vault_root
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
AnalyzeRangeInput,
|
||||
AppendXlsxRowsInput,
|
||||
EditXlsxStructureInput,
|
||||
ListXlsxSheetsInput,
|
||||
SearchWorkbookInput,
|
||||
UpdateXlsxCellsInput,
|
||||
XlsxToMarkdownInput,
|
||||
)
|
||||
@@ -39,12 +51,28 @@ MAX_MD_ROWS = 100
|
||||
MAX_MD_COLS = 20
|
||||
MAX_MD_CHARS = 20_000
|
||||
|
||||
# #156-A14 — the newer read tools scan more than the markdown table (a search
|
||||
# or an aggregate must not stop at row 100) but stay bounded all the same:
|
||||
# a runaway scan would load a whole ledger into the model's context.
|
||||
MAX_SCAN_ROWS = 5_000
|
||||
MAX_SCAN_COLS = 100
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
MAX_RANGE_CELLS = 10_000
|
||||
MAX_RANGE_VALUES = 200
|
||||
|
||||
def _workbook_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx`` path."""
|
||||
# The formats the spreadsheet editor (and now the assistant) can handle.
|
||||
SPREADSHEET_EXTENSIONS = (".xlsx", ".xlsm", ".csv")
|
||||
|
||||
_NUMBER_RE = re.compile(r"^-?\d+(?:[.,]\d+)?$")
|
||||
|
||||
|
||||
def _spreadsheet_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx``/``.xlsm``/``.csv`` path."""
|
||||
path = (path or "").strip()
|
||||
if not path.lower().endswith(".xlsx"):
|
||||
raise ToolError("Extension attendue : .xlsx", code="invalid_arguments")
|
||||
if not path.lower().endswith(SPREADSHEET_EXTENSIONS):
|
||||
raise ToolError(
|
||||
"Extension attendue : .xlsx, .xlsm ou .csv", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
root = get_vault_root(vault)
|
||||
except ServiceError as e:
|
||||
@@ -52,57 +80,175 @@ def _workbook_path(vault: str, path: str) -> Path:
|
||||
return resolve_safe_path(root, path)
|
||||
|
||||
|
||||
def _is_csv(file_path: Path) -> bool:
|
||||
return file_path.suffix.lower() == ".csv"
|
||||
|
||||
|
||||
def _map_service_error(e: ServiceError) -> ToolError:
|
||||
return ToolError(e.message, code=e.code, details=e.details)
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of an .xlsx workbook with their dimensions "
|
||||
"(rows x columns) and whether the display caps truncate them. "
|
||||
"Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS, _sheet_extent
|
||||
def _sheet_titles(file_path: Path) -> list[str]:
|
||||
"""Sheet names of a workbook; a CSV has a single, unnamed “sheet”."""
|
||||
if _is_csv(file_path):
|
||||
return [""]
|
||||
from openpyxl import load_workbook
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
return list(wb.sheetnames)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _read_grid(
|
||||
file_path: Path, sheet: str, max_rows: int, max_cols: int
|
||||
) -> tuple[str, list[list[str]], bool]:
|
||||
"""Read one sheet (or a CSV) as bounded, formatted, trimmed rows.
|
||||
|
||||
Returns ``(title, rows, truncated)``; ``truncated`` is True when real data
|
||||
sits just beyond the row cap (probed one row further) so the caller can say
|
||||
so instead of silently dropping it.
|
||||
"""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
if _is_csv(file_path):
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
stem = file_path.stem
|
||||
if sheet and sheet != stem:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
raw = file_path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=sniff_csv_delimiter(raw))
|
||||
grid: list[list[str]] = []
|
||||
truncated = False
|
||||
for i, row in enumerate(reader):
|
||||
if i >= max_rows:
|
||||
truncated = any(str(c).strip() for c in row)
|
||||
break
|
||||
grid.append([str(c) for c in row][:max_cols])
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return stem, grid, truncated
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
total_rows, total_cols = _sheet_extent(ws)
|
||||
sheets.append(
|
||||
{
|
||||
"name": ws.title,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
if sheet:
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
ws = wb[sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
grid = []
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=max_rows, max_col=max_cols, values_only=True
|
||||
):
|
||||
grid.append([_fmt(v) for v in row])
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=max_rows + 1,
|
||||
max_row=max_rows + 1,
|
||||
max_col=max_cols,
|
||||
values_only=True,
|
||||
)
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
)
|
||||
truncated = any(any(str(v or "").strip() for v in r) for r in probe)
|
||||
finally:
|
||||
wb.close()
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return title, grid, truncated
|
||||
|
||||
|
||||
def _to_number(text: str) -> float | None:
|
||||
"""Coerce a displayed cell to a float, or ``None`` when it is not one."""
|
||||
t = text.strip().replace("\u00a0", "").replace(" ", "")
|
||||
if not _NUMBER_RE.match(t):
|
||||
return None
|
||||
try:
|
||||
return float(t.replace(",", "."))
|
||||
except ValueError: # pragma: no cover - regex already guarantees the shape
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of a spreadsheet (.xlsx, .xlsm or .csv) with their "
|
||||
"dimensions (rows x columns) and whether the display caps truncate "
|
||||
"them. Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook (or of the CSV)."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ToolError(f"Fichier introuvable: {params.path}", code="not_found")
|
||||
|
||||
extents: list[tuple[str, int, int]] = []
|
||||
if _is_csv(file_path):
|
||||
_, rows, _ = _read_grid(file_path, "", MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
extents.append(
|
||||
(
|
||||
file_path.stem,
|
||||
len(rows),
|
||||
max((len(r) for r in rows), default=0),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Declared dimensions are enough here (and far cheaper than scanning
|
||||
# every row): the caller just needs a size to decide what to read.
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _sheet_extent
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
extents = [
|
||||
(ws.title, *_sheet_extent(ws)) for ws in wb.worksheets
|
||||
]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
sheets = [
|
||||
{
|
||||
"name": name,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
for name, total_rows, total_cols in extents
|
||||
]
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
|
||||
|
||||
@tool(
|
||||
name="xlsx_to_markdown",
|
||||
description=(
|
||||
"Read a sheet of an .xlsx workbook as a bounded markdown table "
|
||||
"(up to 100 rows x 20 columns). Use to inspect spreadsheet data "
|
||||
"before answering or editing."
|
||||
"Read a sheet of a spreadsheet (.xlsx, .xlsm or .csv) as a bounded "
|
||||
"markdown table (up to 100 rows x 20 columns). Use to inspect "
|
||||
"spreadsheet data before answering or editing."
|
||||
),
|
||||
input_model=XlsxToMarkdownInput,
|
||||
risk=ToolRisk.READ,
|
||||
@@ -110,49 +256,8 @@ def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str,
|
||||
)
|
||||
def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str, Any]:
|
||||
"""Render one sheet as a markdown table for the LLM context."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
if params.sheet:
|
||||
if params.sheet not in wb.sheetnames:
|
||||
raise ToolError(
|
||||
f"Feuille introuvable: {params.sheet}", code="not_found"
|
||||
)
|
||||
ws = wb[params.sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
rows: list[list[str]] = []
|
||||
truncated = False
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=MAX_MD_ROWS, max_col=MAX_MD_COLS, values_only=True
|
||||
):
|
||||
cells = [_fmt(v) for v in row]
|
||||
if not any(c.strip() for c in cells):
|
||||
continue
|
||||
rows.append(cells)
|
||||
# Real tail beyond the caps? Probe one row further.
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=MAX_MD_ROWS + 1,
|
||||
max_row=MAX_MD_ROWS + 1,
|
||||
max_col=MAX_MD_COLS,
|
||||
values_only=True,
|
||||
)
|
||||
)
|
||||
if any(any(str(v or "").strip() for v in r) for r in probe):
|
||||
truncated = True
|
||||
finally:
|
||||
wb.close()
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, truncated = _read_grid(file_path, params.sheet, MAX_MD_ROWS, MAX_MD_COLS)
|
||||
|
||||
lines: list[str] = []
|
||||
if rows:
|
||||
@@ -174,34 +279,166 @@ def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_workbook",
|
||||
description=(
|
||||
"Search a text across every sheet of a spreadsheet (.xlsx, .xlsm or "
|
||||
".csv) and return the matching cells with their sheet and A1 "
|
||||
"reference (max 100 matches). Use it to find where a value lives "
|
||||
"without dumping whole sheets into the context."
|
||||
),
|
||||
input_model=SearchWorkbookInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def search_workbook(ctx: ToolContext, params: SearchWorkbookInput) -> dict[str, Any]:
|
||||
"""Find a needle across all sheets, bounded and counted per sheet."""
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
needle = (params.query or "").strip()
|
||||
if not needle:
|
||||
raise ToolError("Requête vide", code="invalid_arguments")
|
||||
|
||||
titles = _sheet_titles(file_path)
|
||||
if params.sheet:
|
||||
if params.sheet not in titles:
|
||||
raise ToolError(f"Feuille introuvable: {params.sheet}", code="not_found")
|
||||
titles = [params.sheet]
|
||||
|
||||
hay = needle if params.case_sensitive else needle.lower()
|
||||
matches: list[dict[str, Any]] = []
|
||||
by_sheet: dict[str, int] = {}
|
||||
total = 0
|
||||
for title in titles:
|
||||
sheet_title, rows, _ = _read_grid(
|
||||
file_path, title, MAX_SCAN_ROWS, MAX_SCAN_COLS
|
||||
)
|
||||
label = sheet_title or file_path.stem
|
||||
for r_i, row in enumerate(rows, start=1):
|
||||
for c_i, value in enumerate(row, start=1):
|
||||
if not value:
|
||||
continue
|
||||
haystack = value if params.case_sensitive else value.lower()
|
||||
if hay not in haystack:
|
||||
continue
|
||||
total += 1
|
||||
by_sheet[label] = by_sheet.get(label, 0) + 1
|
||||
if len(matches) < MAX_SEARCH_RESULTS:
|
||||
matches.append(
|
||||
{
|
||||
"sheet": label,
|
||||
"cell": f"{get_column_letter(c_i)}{r_i}",
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"query": needle,
|
||||
"total": total,
|
||||
"truncated": total > MAX_SEARCH_RESULTS,
|
||||
"by_sheet": by_sheet,
|
||||
"matches": matches,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="analyze_range",
|
||||
description=(
|
||||
"Aggregate an A1 range of a sheet (.xlsx, .xlsm or .csv): count, sum, "
|
||||
"mean, min and max of the numeric cells, plus a bounded sample of the "
|
||||
"values. Use it to answer a question about a column without reading "
|
||||
"the whole sheet."
|
||||
),
|
||||
input_model=AnalyzeRangeInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def analyze_range(ctx: ToolContext, params: AnalyzeRangeInput) -> dict[str, Any]:
|
||||
"""Numeric aggregates + value sample over an A1 range of the sheet."""
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, _ = _read_grid(file_path, params.sheet, MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
|
||||
label = (params.range or "").strip()
|
||||
if label:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(label.upper())
|
||||
except Exception as e:
|
||||
raise ToolError(f"Plage invalide: {label}", code="invalid_arguments") from e
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_RANGE_CELLS:
|
||||
raise ToolError(
|
||||
f"Plage trop grande (max {MAX_RANGE_CELLS} cellules)",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
selected = [row[min_col - 1 : max_col] for row in rows[min_row - 1 : max_row]]
|
||||
else:
|
||||
selected = rows
|
||||
|
||||
values = [v for row in selected for v in row if isinstance(v, str) and v.strip()]
|
||||
numbers = [n for n in (_to_number(v) for v in values) if n is not None]
|
||||
|
||||
stats: dict[str, Any] = {"count": len(numbers)}
|
||||
if numbers:
|
||||
stats.update(
|
||||
{
|
||||
"sum": round(sum(numbers), 6),
|
||||
"mean": round(sum(numbers) / len(numbers), 6),
|
||||
"min": min(numbers),
|
||||
"max": max(numbers),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"sheet": title,
|
||||
"range": params.range or "",
|
||||
"rows": len(selected),
|
||||
"cols": max((len(r) for r in selected), default=0),
|
||||
"cells": len(values),
|
||||
"numeric": stats,
|
||||
"values": values[:MAX_RANGE_VALUES],
|
||||
"truncated": len(values) > MAX_RANGE_VALUES,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="update_xlsx_cells",
|
||||
description=(
|
||||
"Edit cells of an existing .xlsx workbook. ``cells`` maps A1 "
|
||||
"references to new values (max 500). A value starting with '=' or "
|
||||
"'@' is stored as TEXT unless allow_formula is set (DDE guard). "
|
||||
"Editing a workbook carrying features openpyxl cannot rewrite "
|
||||
"requires force=true (cached formula results, slicers…)."
|
||||
"Edit cells of an existing spreadsheet (.xlsx, .xlsm or .csv). "
|
||||
"``cells`` maps A1 references to new values (max 500). A value "
|
||||
"starting with '=' or '@' is stored as TEXT unless allow_formula is "
|
||||
"set (DDE guard). Editing a workbook carrying features openpyxl "
|
||||
"cannot rewrite requires force=true (cached formula results, "
|
||||
"slicers…). A .csv has no sheet: any ``sheet`` value is ignored."
|
||||
),
|
||||
input_model=UpdateXlsxCellsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[str, Any]:
|
||||
"""Wrap the guarded cell-edit service."""
|
||||
from backend.services.mutations import edit_xlsx_cells
|
||||
"""Wrap the guarded cell-edit service (``.xlsx``/``.xlsm`` or ``.csv``)."""
|
||||
from backend.services.mutations import edit_xlsx_cells, save_csv_cells
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not params.cells:
|
||||
raise ToolError("Aucune cellule fournie", code="invalid_arguments")
|
||||
if not _is_csv(file_path) and not params.sheet:
|
||||
raise ToolError("Feuille requise pour un classeur", code="invalid_arguments")
|
||||
try:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
if _is_csv(file_path):
|
||||
result = save_csv_cells(params.vault, params.path, dict(params.cells))
|
||||
else:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
@@ -216,9 +453,10 @@ def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[st
|
||||
@tool(
|
||||
name="append_xlsx_rows",
|
||||
description=(
|
||||
"Append rows at the end of a sheet of an existing .xlsx workbook. "
|
||||
"Values are typed like in the viewer (numbers, TRUE/FALSE, FR dates "
|
||||
"JJ/MM/AAAA). The workbook is rewritten atomically with a backup."
|
||||
"Append rows at the end of a sheet of an existing spreadsheet "
|
||||
"(.xlsx or .xlsm). Values are typed like in the viewer (numbers, "
|
||||
"TRUE/FALSE, FR dates JJ/MM/AAAA). The workbook is rewritten "
|
||||
"atomically with a backup. Not available for .csv."
|
||||
),
|
||||
input_model=AppendXlsxRowsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
@@ -236,7 +474,13 @@ def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str,
|
||||
if len(params.rows) > 500:
|
||||
raise ToolError("Trop de lignes (max 500)", code="invalid_arguments")
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if _is_csv(file_path):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de notion de fin de feuille : utilisez "
|
||||
"update_xlsx_cells avec des références A1",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
@@ -284,3 +528,46 @@ def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str,
|
||||
"rows": len(params.rows),
|
||||
"first_row": first_free,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="edit_xlsx_structure",
|
||||
description=(
|
||||
"Change the structure of an existing .xlsx/.xlsm workbook: add, "
|
||||
"rename, duplicate or delete a sheet, or insert/delete rows and "
|
||||
"columns. ``actions`` is an ordered list of "
|
||||
'{"op": "sheet_add"|"sheet_rename"|"sheet_duplicate"|"sheet_delete"|'
|
||||
'"row_insert"|"row_delete"|"col_insert"|"col_delete", …} '
|
||||
"(1 to 50). Deletions drop data and cannot be undone from the "
|
||||
"assistant — confirm with the user first."
|
||||
),
|
||||
input_model=EditXlsxStructureInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def edit_xlsx_structure(
|
||||
ctx: ToolContext, params: EditXlsxStructureInput
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of structural changes through the guarded service."""
|
||||
from backend.services.mutations import mutate_xlsx_structure
|
||||
|
||||
if not params.actions:
|
||||
raise ToolError("Aucune action fournie", code="invalid_arguments")
|
||||
if len(params.actions) > 50:
|
||||
raise ToolError("Trop d'actions (max 50)", code="invalid_arguments")
|
||||
if str(params.path or "").lower().endswith(".csv"):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de structure modifiable", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
result = mutate_xlsx_structure(
|
||||
params.vault, params.path, [dict(a) for a in params.actions], force=params.force
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"actions": len(params.actions),
|
||||
}
|
||||
|
||||
+157
-14
@@ -14,9 +14,11 @@ read-only; :func:`render_csv_table` turns a CSV into the same table shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import zipfile
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
@@ -32,6 +34,13 @@ logger = logging.getLogger("obsigate.xlsx_reader")
|
||||
MAX_ROWS = 500
|
||||
MAX_COLS = 40
|
||||
|
||||
# BUG-094 — an empty sheet used to render as a bare "Feuille vide" paragraph
|
||||
# with no cell at all, so a freshly added sheet had nothing to click and no way
|
||||
# to insert a row/column. Render a small blank grid instead (Excel-like), with
|
||||
# real A1 coordinates, so the cells are editable and the structure actions work.
|
||||
EMPTY_SHEET_ROWS = 20
|
||||
EMPTY_SHEET_COLS = 8
|
||||
|
||||
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
|
||||
# loading). The endpoint is bounded so a single request can never ask for the
|
||||
# whole workbook back in one JSON payload; the UI pages through the rest.
|
||||
@@ -58,8 +67,22 @@ LOSSY_PARTS: dict[str, tuple[str, ...]] = {
|
||||
# Excel recalculates.
|
||||
_CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
|
||||
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||
_MAX_PROBE_BYTES = 8_000_000
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like
|
||||
# MAX_REPLACE_FILE_BYTES). BUG-099 — the allowance is PER SHEET: a single huge
|
||||
# first sheet used to eat the whole budget and hide a cached formula sitting in
|
||||
# the next one. The total ceiling still bounds the work on a many-sheet archive.
|
||||
# Running out of budget is reported as *unverified* (never as "nothing to lose")
|
||||
# so the write guard stays cautious instead of silently dropping the values.
|
||||
_MAX_PROBE_BYTES_PER_SHEET = 4_000_000
|
||||
_MAX_PROBE_BYTES_TOTAL = 32_000_000
|
||||
|
||||
# #156-A13 — metadata cache. `read_sheet_window()` serves one window at a time
|
||||
# and used to reload the whole workbook (normal mode, data_only=False) for every
|
||||
# window, just to read the style/merge/freeze maps of one sheet. The result is
|
||||
# keyed by (path, mtime_ns, size): any write replaces the file, hence the key.
|
||||
_META_CACHE_MAX = 8
|
||||
_meta_cache: dict[str, tuple[tuple[int, int], dict[str, dict[str, Any]]]] = {}
|
||||
_meta_cache_lock = threading.Lock()
|
||||
|
||||
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
|
||||
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
|
||||
@@ -104,6 +127,10 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
fragments.append("font-weight:600")
|
||||
if font and font.italic:
|
||||
fragments.append("font-style:italic")
|
||||
# #156-A8 — underline is written from the viewer too, so it is read back
|
||||
# (the toggle in the formatting menu needs to see its own effect).
|
||||
if font and font.underline:
|
||||
fragments.append("text-decoration:underline")
|
||||
fmt = cell.number_format
|
||||
if fmt and fmt not in ("General", "@"):
|
||||
# A custom number format is signalled typographically (mono font)
|
||||
@@ -313,24 +340,65 @@ def _table(
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value."""
|
||||
budget = _MAX_PROBE_BYTES
|
||||
def _is_sheet_xml(name: str) -> bool:
|
||||
"""True for the worksheet XML parts the cached-formula probe scans."""
|
||||
return name.startswith("xl/worksheets/sheet") and name.endswith(".xml")
|
||||
|
||||
|
||||
def _scan_cached_formulas(zf: zipfile.ZipFile) -> tuple[bool, bool]:
|
||||
"""Scan the sheet XML for a formula carrying a non-empty cached result.
|
||||
|
||||
Returns ``(found, unverified)``. ``unverified`` is True when the byte
|
||||
budget stopped the scan before every sheet could be read to the end: a
|
||||
negative result is then **not** proof that the workbook holds no cached
|
||||
value (BUG-099), so callers must not treat it as a licence to write.
|
||||
|
||||
Each sheet gets its own :data:`_MAX_PROBE_BYTES_PER_SHEET` allowance (a
|
||||
single huge sheet can no longer starve the others) while
|
||||
:data:`_MAX_PROBE_BYTES_TOTAL` bounds the whole archive. A hit short-
|
||||
circuits the scan: the answer is already known.
|
||||
"""
|
||||
budget = _MAX_PROBE_BYTES_TOTAL
|
||||
unverified = False
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"):
|
||||
if not _is_sheet_xml(name):
|
||||
continue
|
||||
sheet_budget = min(_MAX_PROBE_BYTES_PER_SHEET, budget)
|
||||
exhausted = False
|
||||
try:
|
||||
with zf.open(name) as fh:
|
||||
while budget > 0:
|
||||
chunk = fh.read(65536)
|
||||
while sheet_budget > 0:
|
||||
# Read at most what the sheet's allowance has left, so one
|
||||
# large chunk can never consume the whole total budget.
|
||||
chunk = fh.read(min(65536, sheet_budget))
|
||||
if not chunk:
|
||||
break
|
||||
break # read to the end: this sheet is verified clean
|
||||
sheet_budget -= len(chunk)
|
||||
budget -= len(chunk)
|
||||
if _CACHED_FORMULA_RE.search(chunk):
|
||||
return True
|
||||
return True, False
|
||||
else:
|
||||
# Left the loop on the budget, not on EOF.
|
||||
exhausted = True
|
||||
except (KeyError, OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
return False
|
||||
if exhausted:
|
||||
unverified = True
|
||||
if budget <= 0:
|
||||
unverified = True
|
||||
break
|
||||
return False, unverified
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value.
|
||||
|
||||
Boolean view of :func:`_scan_cached_formulas` for the display path (the
|
||||
second ``data_only=True`` read): a truncated scan simply skips the shadow
|
||||
grid, it never claims the workbook is lossless.
|
||||
"""
|
||||
found, _ = _scan_cached_formulas(zf)
|
||||
return found
|
||||
|
||||
|
||||
def inspect_workbook(file_path: Path) -> list[str]:
|
||||
@@ -342,6 +410,10 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
|
||||
``cached_values`` is a synthetic key: openpyxl keeps the formula but drops
|
||||
the cached result, so the workbook stays correct once Excel recalculates it.
|
||||
``cached_values_unverified`` (BUG-099) is the other synthetic key: the
|
||||
cached-value probe ran out of budget, so a negative result is not proof —
|
||||
the entry keeps the write guard cautious (409 + confirmation) rather than
|
||||
promising a lossless round-trip it cannot vouch for.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
@@ -351,13 +423,27 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
for key, prefixes in LOSSY_PARTS.items()
|
||||
if any(name.startswith(prefix) for name in names for prefix in prefixes)
|
||||
}
|
||||
if _has_cached_formulas(zf):
|
||||
cached_found, cached_unverified = _scan_cached_formulas(zf)
|
||||
if cached_found:
|
||||
found.add("cached_values")
|
||||
elif cached_unverified:
|
||||
found.add("cached_values_unverified")
|
||||
return sorted(found)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return []
|
||||
|
||||
|
||||
def invalidate_workbook_meta(file_path: Path | str) -> None:
|
||||
"""Drop the cached metadata of one workbook (#156-A13).
|
||||
|
||||
Called by the write path right after the atomic replace: the (mtime, size)
|
||||
key already changes on a rewrite, this only closes the theoretical window
|
||||
where a same-size write lands on the same timestamp tick.
|
||||
"""
|
||||
with _meta_cache_lock:
|
||||
_meta_cache.pop(str(file_path), None)
|
||||
|
||||
|
||||
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
|
||||
|
||||
@@ -365,7 +451,31 @@ def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
fragments are the workbook's own values, a failure yields ``{}`` per sheet
|
||||
so the viewer keeps its plain rendering. Styles are read with
|
||||
``data_only=False`` — the edited value is the formula, not its result.
|
||||
|
||||
#156-A13 — the result is cached on ``(path, mtime_ns, size)``: a truncated
|
||||
sheet is fetched window by window, and each window used to pay a full
|
||||
workbook load for these maps alone. Callers only read from the mapping.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return {}
|
||||
key = str(file_path)
|
||||
stamp = (st.st_mtime_ns, st.st_size)
|
||||
with _meta_cache_lock:
|
||||
hit = _meta_cache.get(key)
|
||||
if hit and hit[0] == stamp:
|
||||
return hit[1]
|
||||
out = _read_workbook_meta_uncached(file_path)
|
||||
with _meta_cache_lock:
|
||||
_meta_cache[key] = (stamp, out)
|
||||
while len(_meta_cache) > _META_CACHE_MAX:
|
||||
_meta_cache.pop(next(iter(_meta_cache)))
|
||||
return out
|
||||
|
||||
|
||||
def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Load the workbook once and build the per-sheet metadata maps."""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), data_only=False)
|
||||
except Exception:
|
||||
@@ -424,6 +534,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||
sheets = []
|
||||
for i, title in enumerate(titles):
|
||||
grid = _trim(formulas[i])
|
||||
# BUG-094 — a blank sheet still needs an editable grid (see constants):
|
||||
# the viewer's cell editing and structure actions all hang off a cell.
|
||||
if not grid:
|
||||
grid = [[""] * EMPTY_SHEET_COLS for _ in range(EMPTY_SHEET_ROWS)]
|
||||
# The shadow grid is NOT trimmed independently: _trim drops the
|
||||
# trailing empty columns of each grid on its own width, which would
|
||||
# shift every cached value left of its formula. Indexing it
|
||||
@@ -770,17 +884,46 @@ def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
|
||||
# ── #153 A16 — additional spreadsheet formats ───────────────────────────────
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str = ",") -> str:
|
||||
# BUG-098 — a CSV written by a French office suite is `;`-separated, and the
|
||||
# delimiter must be *detected* (then reused on write-back), not assumed.
|
||||
CSV_SNIFF_BYTES = 4096
|
||||
_CSV_DELIMITERS = (",", ";", "\t", "|")
|
||||
|
||||
|
||||
def sniff_csv_delimiter(raw: str) -> str:
|
||||
"""Return the most likely field delimiter of *raw* (``,`` as fallback).
|
||||
|
||||
:class:`csv.Sniffer` handles quoted fields and multi-line values; it is
|
||||
unreliable on short or single-column samples, so the first non-empty line
|
||||
is counted as a tie-breaker and a comma remains the last resort.
|
||||
"""
|
||||
sample = raw[:CSV_SNIFF_BYTES]
|
||||
try:
|
||||
return csv.Sniffer().sniff(sample, delimiters="".join(_CSV_DELIMITERS)).delimiter
|
||||
except csv.Error:
|
||||
pass
|
||||
first = next((line for line in sample.splitlines() if line.strip()), "")
|
||||
counts = {delim: first.count(delim) for delim in _CSV_DELIMITERS}
|
||||
best = max(counts, key=lambda delim: counts[delim])
|
||||
return best if counts[best] else ","
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str | None = None) -> str:
|
||||
"""Render CSV text as the same HTML table shape the xlsx viewer consumes.
|
||||
|
||||
Row numbers replace the A1 column: a CSV has no fixed column count, so
|
||||
the first row is a plain data row like the others (the viewer offers the
|
||||
toolbar either way). Every cell is HTML-escaped at render time.
|
||||
|
||||
``delimiter`` defaults to the sniffed one (:func:`sniff_csv_delimiter`,
|
||||
BUG-098): a `;`-separated file used to render as a single column.
|
||||
"""
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter)
|
||||
reader = csv_mod.reader(
|
||||
io_mod.StringIO(raw), delimiter=delimiter or sniff_csv_delimiter(raw)
|
||||
)
|
||||
try:
|
||||
rows = [row for row in reader]
|
||||
except csv_mod.Error:
|
||||
|
||||
@@ -115,7 +115,9 @@ $env:VERSION = $Version
|
||||
Write-Info "Version : $Version"
|
||||
|
||||
# ----- Build the image -----
|
||||
$BuildArgs = @("-f", $ComposeFile)
|
||||
# NOTE : `--no-cache` est un flag de `docker compose build` (après `build`),
|
||||
# pas un flag global (avant) — sinon `unknown flag: --no-cache`.
|
||||
$BuildArgs = @("-f", $ComposeFile, "build")
|
||||
if (-not $UseCache) {
|
||||
$BuildArgs += "--no-cache"
|
||||
Write-Info "Construction de l'image Docker (sans cache)..."
|
||||
@@ -123,7 +125,7 @@ if (-not $UseCache) {
|
||||
Write-Info "Construction de l'image Docker (avec cache)..."
|
||||
}
|
||||
|
||||
docker compose @BuildArgs build
|
||||
docker compose @BuildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Échec de la construction de l'image Docker."
|
||||
exit $LASTEXITCODE
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.43.1"
|
||||
version = "2.53.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.43.1"
|
||||
version = "2.53.2"
|
||||
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.43.1",
|
||||
"version": "2.53.2",
|
||||
"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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ avec :
|
||||
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
|
||||
- **Stemming français** — les variantes des mots sont rapprochées.
|
||||
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
|
||||
- **Facettes** — compteurs par vault et par tag sur les résultats.
|
||||
- **Facettes** — compteurs par vault, par tag et par extension sur les résultats, panneau
|
||||
repliable d'un clic (chevron, état mémorisé).
|
||||
- **Pagination** — 50 résultats par page.
|
||||
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
|
||||
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
|
||||
@@ -164,9 +165,11 @@ l'application. Si vous refusez, rien n'est écrit.
|
||||
|
||||
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
|
||||
|
||||
Si le classeur est modifié ailleurs entre-temps (verrou concurrent), ObsiGate
|
||||
n'interrompt pas votre travail : un bandeau vous propose de **réessayer**
|
||||
l'enregistrement, vos modifications restant en place.
|
||||
Si le classeur est modifié ailleurs entre-temps (autre poste, Excel,
|
||||
synchronisation…), ObsiGate n'interrompt pas votre travail : un bandeau vous
|
||||
propose de **réessayer**. Le bouton **Réessayer** relit d'abord le fichier pour
|
||||
récupérer la version courante, puis rejoue l'enregistrement — vos
|
||||
modifications restent en place pendant tout ce temps.
|
||||
|
||||
### Formules
|
||||
|
||||
@@ -182,8 +185,14 @@ curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx"
|
||||
- `allow_formula` : `true` pour écrire une vraie formule (`=B1*2`).
|
||||
- `force` : `true` pour enregistrer malgré les éléments non préservés
|
||||
(sinon l'API répond **409** `xlsx_lossy_content`).
|
||||
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
|
||||
**409** `conflict` au lieu d'écraser la première.
|
||||
- `if_match` (facultatif) : la **version** du fichier attendue, telle que la
|
||||
lecture la renvoie (`xlsx_revision` ou `revision`). Si le fichier a changé
|
||||
depuis, l'écriture est refusée (**409** `conflict`, `details.reason =
|
||||
"stale_revision"`) au lieu d'écraser le travail de l'autre écrivain ;
|
||||
relisez le fichier puis renvoyez la nouvelle version. Les trois routes
|
||||
d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête
|
||||
`If-Match` ou le champ `if_match` et renvoient la version à jour dans
|
||||
`revision`.
|
||||
|
||||
### Feuilles volumineuses et lecture par fenêtres
|
||||
|
||||
@@ -206,38 +215,102 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
|
||||
lignes (1 à 1 000 par requête).
|
||||
- La réponse porte `total_rows`, `truncated` et `has_more` pour paginer.
|
||||
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
|
||||
n'est pas un `.xlsx`.
|
||||
n'est ni un `.xlsx` ni un `.xlsm`.
|
||||
|
||||
### Fonctions avancées
|
||||
|
||||
**Barre de formule et navigation clavier** — la cellule active est nommée en
|
||||
A1 au-dessus du tableau ; `Tab`/`Maj+Tab` et les flèches circulent entre les
|
||||
cellules, `Entrée` valide, `Maj+Entrée` insère un retour à la ligne, une
|
||||
plage se copie telle quelle vers un tableur.
|
||||
**Barre de formule, zone Nom et navigation clavier** — au-dessus du tableau, la
|
||||
**zone Nom** affiche l'adresse de la cellule active (`B12`) ou de la plage
|
||||
sélectionnée (`A1:B3`) et elle est **éditable** (« Atteindre ») : saisissez une
|
||||
référence puis `Entrée` pour y aller (`B12`, `A1:B3`, `$A$1`, ou `Feuille2!A1`
|
||||
pour changer d'onglet) ; une référence inconnue est refusée avec un message et
|
||||
l'adresse précédente est restaurée. La barre de formule reflète la cellule
|
||||
active et propose les **noms de fonctions** courants pendant la saisie.
|
||||
|
||||
`Tab`/`Maj+Tab` et les flèches circulent entre les cellules, `Maj+flèches` étend
|
||||
la sélection, `Entrée` valide, `Maj+Entrée` insère un saut de ligne **dans** la
|
||||
cellule, `F2` ouvre la cellule en édition, `Suppr` vide la sélection,
|
||||
`Échap` restaure la valeur d'origine ; `Origine`/`Fin` vont au bord de la ligne,
|
||||
`Ctrl+Origine`/`Ctrl+Fin` aux coins de la feuille affichée,
|
||||
`PgPréc`/`PgSuiv` font défiler d'un écran, `Ctrl+flèches` saute au bout de la
|
||||
plage de données, `Ctrl+A` sélectionne toute la feuille affichée et `Ctrl+S`
|
||||
enregistre. Tant que la cellule n'est pas en cours d'édition, `Suppr`
|
||||
efface la sélection plutôt qu'un caractère.
|
||||
|
||||
**Presse-papiers de plage** — copier, couper et coller un **bloc** de cellules
|
||||
(`Ctrl+C`, `Ctrl+X`, `Ctrl+V`, ou les entrées correspondantes du menu
|
||||
contextuel) : coller un bloc copié ici **ou depuis Excel** remplit la plage à
|
||||
partir de la cellule active et la laisse sélectionnée. Le collage est du
|
||||
**texte** (formules et valeurs recopiées telles quelles) et reste **annulable** ;
|
||||
« couper » efface la source après le collage (un collage sur place ne l'efface
|
||||
pas). Un bloc plus large que la grille affichée est tronqué, avec un message.
|
||||
|
||||
**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient
|
||||
sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
|
||||
|
||||
**Tri, filtre, recherche, export** — chaque colonne se trie (ascendant /
|
||||
descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes
|
||||
se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en
|
||||
correspondance, et la feuille s'exporte en CSV. Rien de tout cela ne
|
||||
modifie le classeur.
|
||||
**Sélection et menu contextuel** — cliquer une cellule l'active, **glisser**
|
||||
ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
|
||||
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
|
||||
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
|
||||
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
|
||||
contenu. 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
|
||||
la recherche (`Ctrl+F` du panneau) parcourt **toutes les feuilles** : le compteur
|
||||
indique le nombre de feuilles concernées et passer sur une correspondance
|
||||
**active l'onglet** qui la contient.
|
||||
|
||||
La sortie propose quatre formats, toujours sur le **contenu affiché** (et jamais
|
||||
sur les valeurs calculées en cache) :
|
||||
|
||||
- **CSV** (bouton `CSV`) — exporte la **sélection** quand une plage de plusieurs
|
||||
cellules est active (le nom du fichier reprend la plage, ex.
|
||||
`Fruits-A1B2.csv`), sinon la feuille entière ;
|
||||
- **Markdown** et **HTML** (menu **Exporter**) — tableau markdown ou document
|
||||
HTML autonome, mêmes règles de sélection ;
|
||||
- **Imprimer** (menu **Exporter**) — imprime la feuille ou la sélection seule,
|
||||
sans le ruban ni les panneaux de l'application.
|
||||
|
||||
Rien de tout cela ne modifie le classeur.
|
||||
|
||||
**Structure** — le menu **Structure** de la barre d'outils ajoute,
|
||||
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
|
||||
colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
|
||||
automatique et confirmation, comme pour l'édition des cellules).
|
||||
|
||||
**Styles et mise en page** — la lecture restitue couleurs de police et de
|
||||
fond, gras/italique/souligné, alignements, cellules fusionnées et volets
|
||||
figés ; l'ancrage de la zone figée est conservé au défilement.
|
||||
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
|
||||
**sélection courante** (une cellule ou une plage) :
|
||||
|
||||
- **caractère** — gras, italique, souligné, effacer la mise en forme ;
|
||||
- **alignement** — gauche, centré, droite ;
|
||||
- **couleurs** — couleur de police et couleur de fond (sélecteur natif, aucune
|
||||
palette imposée) ;
|
||||
- **format de nombre** — général, nombre, pourcentage, devise, date, texte ;
|
||||
- **structure** — fusionner / défusionner les cellules, figer / libérer les
|
||||
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
|
||||
plage).
|
||||
|
||||
L'écriture passe par `PUT …/xlsx/style`, avec les mêmes garanties que l'édition
|
||||
des cellules : backup automatique, écriture atomique, confirmation si
|
||||
l'opération détruirait des éléments non préservables (graphiques, valeurs
|
||||
calculées en cache…) et **détection d'un écrivain externe** (`If-Match` →
|
||||
message « Réessayer »). Un `.csv` ne portant pas de mise en forme, le bouton
|
||||
n'y est pas proposé (il est également absent d'une grille en lecture seule).
|
||||
La lecture restitue par ailleurs les couleurs, polices, cellules fusionnées et
|
||||
volets figés du fichier ; l'ancrage de la zone figée est conservé au défilement.
|
||||
Les **commentaires**, liens hypertexte, validation de données, mise en forme
|
||||
conditionnelle et bordures restent hors périmètre.
|
||||
|
||||
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
|
||||
**macros sont préservées** à l'enregistrement ; `.xls` et `.ods` s'affichent
|
||||
en **lecture seule** ; un `.csv` s'ouvre dans la même grille et se réécrit
|
||||
conformément à la RFC 4180 (les guillemets et séparateurs sont
|
||||
échappés).
|
||||
**macros sont préservées** à l'enregistrement (y compris le chargement des
|
||||
lignes au-delà du plafond) ; `.xls` et `.ods` s'affichent en **lecture
|
||||
seule** ; un `.csv` s'ouvre dans la même grille et se réécrit conformément à
|
||||
la RFC 4180 (les guillemets et séparateurs sont échappés). Le **séparateur
|
||||
du CSV est détecté** (`;`, `,` ou tabulation) à la lecture et **réutilisé à
|
||||
l'enregistrement** : un fichier exporté par Excel en français (point-virgule)
|
||||
s'affiche donc en colonnes distinctes et le reste après édition.
|
||||
|
||||
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
|
||||
latéral droit** (la grille reste visible à côté) qui liste les plages
|
||||
@@ -248,8 +321,13 @@ numériques) avec quelques chiffres clés. Cliquer une **plage nommée**
|
||||
sélectionne sa première cellule dans la grille, et le panneau est
|
||||
**redimensionnable**. L'en-tête de l'inspecteur offre
|
||||
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
|
||||
plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
`append_xlsx_rows`).
|
||||
plages. Ses outils couvrent désormais les **trois formats édités**
|
||||
(`.xlsx`, `.xlsm`, `.csv`) : `list_xlsx_sheets` et `xlsx_to_markdown` pour lire,
|
||||
`search_workbook` (recherche dans toutes les feuilles, comptée par feuille),
|
||||
`analyze_range` (agrégats — nombre, somme, moyenne, min, max — d'une plage A1),
|
||||
`update_xlsx_cells` et `append_xlsx_rows` pour modifier, et
|
||||
`edit_xlsx_structure` pour la structure (ajouter/renommer/dupliquer/supprimer
|
||||
une feuille, insérer/supprimer des lignes ou des colonnes).
|
||||
|
||||
### Limites
|
||||
|
||||
@@ -257,14 +335,33 @@ plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
sous une feuille plus grande, le bouton **« Charger la suite »** (ou le
|
||||
défilement vers le bas du tableau) ajoute les lignes suivantes par
|
||||
fenêtres de 500 — elles deviennent aussitôt éditables et
|
||||
sauvegardables.
|
||||
sauvegardables. Le chargement paresseux est **vertical uniquement** :
|
||||
l'axe des colonnes reste tronqué à 40 (les colonnes au-delà ne sont ni
|
||||
affichées ni exportées).
|
||||
- Un **format de nombre personnalisé** (devise, pourcentage…) est signalé
|
||||
par une police à chasse fixe, mais la valeur reste affichée brute.
|
||||
- L'application de **styles** depuis la visionneuse (mettre en gras,
|
||||
colorer) n'est pas proposée — seuls les styles existants sont rendus.
|
||||
par une police à chasse fixe à la lecture ; depuis le bouton **Mise en
|
||||
forme**, appliquer un format ne change que le format de la cellule, **pas**
|
||||
la valeur affichée (aucun recalcul n'est fait, cf. les limites d'export
|
||||
ci-dessous).
|
||||
- `.xls` et `.ods` restent en lecture seule (convertir vers `.xlsx` pour
|
||||
éditer) ; les macros d'un `.xlsm` sont conservées mais ne s'exécutent
|
||||
pas dans ObsiGate.
|
||||
- La **poignée de recopie** (fill), la multi-sélection `Ctrl+clic` et le
|
||||
glisser-déposer de lignes/colonnes ne sont pas proposés ; un collage de
|
||||
plusieurs cellules s'annule **cellule par cellule** (`Ctrl+Z` répété).
|
||||
- Les exports (CSV, Markdown, HTML, impression) reflètent ce qui est **affiché** :
|
||||
une feuille tronquée s'exporte tronquée, et les formules sortent telles
|
||||
qu'enregistrées (aucune valeur calculée n'est recalculée). Un classeur reste
|
||||
la source de vérité : utilisez **Charger la suite** pour exporter au-delà du
|
||||
plafond.
|
||||
- **Aucun moteur de formule** : ObsiGate lit et écrit les formules telles
|
||||
qu'Excel les a enregistrées, sans jamais les recalculer. Une saisie
|
||||
commençant par `=` ou `@` est stockée comme **texte** (garde anti-DDE)
|
||||
tant que le bouton `f(x)` n'est pas activé ; l'enregistrement vous le
|
||||
signale par un message. Excel reste la référence pour les valeurs calculées.
|
||||
- L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les
|
||||
actions de structure ; en revanche une **suppression** (feuille, ligne,
|
||||
colonne) n'est pas annulable, faute d'inverse.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -201,6 +201,21 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| *BUG-092* | Les tests réseau dépendent du DNS réel du runner : `test_worker_failure_maps_to_tool_error` échoue en `dns_error` au lieu d'atteindre le worker Playwright mocké, et le job CI `test` rougit de façon intermittente | 🟢 corrigé | P1 | CI / tests | IA | `tests/test_webrender.py`, `tests/test_web_tools.py` | Sur un runner au DNS instable : `pytest tests/test_webrender.py -k test_worker_failure_maps_to_tool_error` → `assert 'dns_error' == 'render_unavailable'` | Fixture `no_dns` mockant les **deux** références du garde SSRF `_assert_public_http_url` (celle de `backend/tools/web.py` et celle importée dans le namespace de `backend/tools/webrender.py`, ligne 30 — la seconde avait d'abord échappé au correctif). Les tests de garde SSRF n'utilisent pas la fixture et continuent de traverser le vrai garde | Le garde est appelé par `fetch_url` **avant** le traitement ; seule la couche httpx était mockée. Contre-preuve : DNS coupé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** |
|
||||
| *BUG-093* | Le job CI `security` échoue : `pip-audit` bloque sur deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910, PYSEC-2026-3911) — et le plancher `pypdf>=4.0` ne les corrigeait pas, car l'image Act du runner embarque 6.16.0 *préinstallé* dans sa toolcache Python (`Requirement already satisfied` ⇒ jamais mis à niveau) | 🟢 corrigé | P0 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | Run Gitea #1660, job `security` : `Found 2 known vulnerabilities, ignored 2 in 1 package` → `pypdf 6.16.0 PYSEC-2026-3910 6.16.1` / `PYSEC-2026-3911 6.16.1` | Plancher `pypdf>=6.16.1` (correctif des deux advisories), commenté pour expliquer la contrainte de la toolcache. Ajout de `tests/test_ci_workflow.py::TestDependencySecurityFloors`, qui verrouille les planchers de sécurité (`pypdf`, `pyjwt`) et interdit qu'ils retombent sous le correctif | Les deux advisories sont des **consommations de ressources non contrôlées** (PDF à outlines multiples ou à nombreux XForm réutilisés) et sont donc **atteignables** par ObsiGate, dont `backend/pdf_reader.py` extrait le texte et parcourt les outlines de PDF fournis par l'utilisateur. Contre-preuve : plancher remis à `>=4.0` → le garde-fou échoue. pip-audit local : 6.16.1, 6.16.2 et 6.19.0 sans vulnérabilité connue. Correction découverte en lisant le log du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log de l'étape Semgrep collé précédemment datait d'un run antérieur |
|
||||
| *BUG-094* | Feuille `.xlsx` vide ou nouvellement ajoutée : impossible d'y saisir une valeur et d'y insérer une ligne/colonne — la feuille s'affiche « Feuille vide » sans aucune cellule | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `frontend/js/viewer.js::renderXlsxViewer` | Ajouter une feuille (`PUT …/xlsx/structure` `sheet_add`) puis tenter de saisir A1 ou d'insérer une ligne/colonne | `render_sheets()` remplace une grille vide par un quadrillage vierge 20×8 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) aux vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`) | Le classeur n'était pas en cause : seule la **représentation HTML** était vide, donc aucun `td` à sélectionner → aucune cellule active → aucune action de structure possible. Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52 |
|
||||
| *BUG-095* | Le job CI `security` échoue : `pip-audit` bloque sur CVE-2026-102274 dans `pyjwt` 2.13.0 (correctif 2.14.0) — le plancher `>=2.13.0` (BUG-091) est désormais sous le dernier correctif | 🟢 corrigé | P1 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | Run Gitea du push v2.44.0, job `security` : `Found 1 known vulnerability, ignored 2 in 1 package` → `pyjwt 2.13.0 CVE-2026-102274 2.14.0` | Plancher `pyjwt[crypto]>=2.14.0` (au-dessus de la version préinstallée de la toolcache du runner, sinon `pip` répond « already satisfied »). Garde-fou `TestDependencySecurityFloors` : `FLOORS["pyjwt"]` porté à `(2, 14, 0)` — contre-preuve : plancher remis à `2.13.0` → test rouge. Commentaire de la job `security` mis à jour | Le plancher de BUG-091 (2.13.0) corrigeait PYSEC-2026-178 mais est lui-même vulnérable depuis. Même mécanisme que BUG-093 (pypdf) : un plancher de sécurité doit rester au-dessus du dernier correctif. Vérifié : `test_ci_workflow.py` 9 passed ; pip-audit local OK (pyjwt 2.15.1 ≥ 2.14.0) |
|
||||
| *BUG-096* | [🟡 IMPORTANT] Enregistrer un `.csv` depuis la visionneuse échoue : `TypeError` sur `sheets[…].name` (aucun `PUT …/csv/save` émis) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`renderXlsxViewer`, construction des jobs de sauvegarde) | Ouvrir un `.csv` dans ObsiGate, modifier une cellule, cliquer **Enregistrer** | `frontend/js/viewer.js` : le job de sauvegarde ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV — l'origine du `TypeError`) — repli feuille → `data.title`/`data.path`. Test JSDOM « saving an edited csv PUTs /csv/save (payload without xlsx_sheets) ». Vérifié : suite 1490 passed / 6 skipped, `xlsx-viewer.test.mjs` 61/61 | Défaut #156 A1. En mode CSV la réponse de lecture n'a pas de `xlsx_sheets` (`backend/routers/files_read.py`) : `sheets` est vide et `sheets[Number(panel.dataset.sheet)].name` lève un `TypeError` **avant** le `try`, donc aucun `PUT /api/file/{vault}/csv/save` n'est émis (le service `save_csv_cells()` et l'endpoint sont, eux, corrects). **Reproduit en JSDOM le 2026-09-29** (harnais de `tests/frontend/xlsx-viewer.test.mjs`) : `Cannot read properties of undefined (reading 'name')`, 0 requête d'API enregistrée. Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-097* | [🟡 IMPORTANT] Feuille `.xlsm` tronquée : « Charger la suite » échoue en **415** (`GET …/xlsx/sheet` n'accepte que `.xlsx`) alors que le `.xlsm` est éditable | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/routers/files_read.py` (`api_file_xlsx_sheet`), `frontend/js/viewer.js` (`wireLazyRows`) | Ouvrir un `.xlsm` de plus de 500 lignes puis cliquer le pied « Charger la suite » (ou approcher du bas du tableau) | `backend/routers/files_read.py` : `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (autres formats → 415 inchangé). Tests `TestXlsmEditable::test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats` | Défaut #156 A2. `.xlsm` est servi éditable (`files_read.py:491-512`, pas de `xlsx_readonly`) donc la visionneuse câble le chargement paresseux, mais `api_file_xlsx_sheet` refuse tout ce qui n'est pas `.xlsx` (`files_read.py:272`). Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *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)
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
@@ -296,6 +311,21 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-27 | BUG-083 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-083** : job `security` rouge — le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée (`unexpected EOF while looking for matching '"'"`, `/var/run/act/workflow/4` ligne 2). Seul `run:` du workflow avec un `#` (les `#` des noms d'étapes Bandit/Npm audit sont inoffensifs, ces étapes passent). Correctif : echo sans `#` (réf `#87` en commentaire YAML). Garde-fou `test_ci_workflow.py` (aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM — BUG-082) + contre-preuve sur l'ancien `ci.yml`. Vérifié : 56 passed. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-081 | Correction | `backend/auth/router.py`, `tests/test_mfa.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-081** : `GET /api/auth/mfa/status` répondait 500 quand l'auth est désactivée — le pseudo-user `anonymous` n'a aucune entrée en store (`get_user` → `None`, `AttributeError` sur `user.get`). Garde `user is None` → payload « MFA désactivé ». Test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-094 | Correction | `backend/xlsx_reader.py`, `frontend/js/viewer.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-094 — une feuille vide ou nouvellement ajoutée devient éditable et manipulable.** `render_sheets()` substitue une grille vierge 20×8 (`EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) quand la feuille ne porte aucune cellule, avec de vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`). Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-095 | Correction CI / sécurité | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Le job `security` repasse au vert** : `pyjwt` 2.13.0 est vulnérable (CVE-2026-102274, correctif 2.14.0) et le plancher de BUG-091 (`>=2.13.0`) était donc sous le dernier correctif. Plancher porté à `pyjwt[crypto]>=2.14.0` (au-dessus de la toolcache du runner, sinon `pip` répond « already satisfied »), garde-fou `TestDependencySecurityFloors` mis à jour (contre-preuve : plancher remis à 2.13.0 → test rouge), commentaire de la job `security` aligné. Vérifié : `test_ci_workflow.py` 9 passed, pip-audit local OK (pyjwt 2.15.1). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-096 → BUG-099 (#156 A1-A4) | Correction | `frontend/js/viewer.js`, `frontend/locales/{fr,en}.json`, `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/test_xlsx_viewer.py`, `tests/test_xlsx_formats.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `docs/ISSUES_TODOLIST.md` | **Les 4 défauts P0 du tableur sont corrigés.** (BUG-096) l'enregistrement d'un `.csv` depuis la visionneuse ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV) : le `TypeError` partait **avant** tout appel réseau, aucun `PUT …/csv/save` n'était émis. (BUG-097) `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` — le chargement des lignes au-delà du plafond fonctionne pour les classeurs macro. (BUG-098) `sniff_csv_delimiter()` détecte `;`/`,`/tabulation et `save_csv_cells()` réutilise le délimiteur : un CSV français s'affiche en colonnes distinctes et **le reste** après édition. (BUG-099) sonde de perte **par feuille** (4 Mo) + plafond global (32 Mo) et clé `cached_values_unverified` quand le budget est épuisé — plus de perte silencieuse possible des valeurs calculées. Tests : `TestXlsxCachedValueProbe` (3, contre-preuve : budget épuisé signalé au lieu de `[]`), `TestCsvDelimiter` + 3 tests CSV, fenêtre `.xlsm` (+ refus `.csv`), 1 test JSDOM CSV. Vérifié : suite **1490 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), `xlsx-viewer.test.mjs` 61/61, validate-imports 40 modules, unit 12/12, i18n parity. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | #156 A5-A7 (P1) | Fonctionnalité (sans nouveau défaut) | `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | **P1 livré** — (A5) presse-papiers de plage : copier/couper/coller un bloc TSV au clavier et au menu contextuel, presse-papiers interne + miroir système, remplissage multi-cellules, insertion **texte** échappée, débordement signalé, annulable ; (A6) clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) ; (A7) zone Nom éditable (« Atteindre ») + liste de fonctions. Vérifié : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux), E2E Playwright. | ✅ livré |
|
||||
| 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré |
|
||||
| 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) |
|
||||
| 2026-10-02 | #158 (A1-A3), BUG-100 | Fonctionnalité + correction | `frontend/js/navfacets.js` (nouveau), `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `frontend/js/legacy.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/services/vaults.py`, `backend/schemas.py`, `tests/test_nav_files.py` (nouveau), `tests/frontend/navfacets.test.mjs` (nouveau), `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **A1** : `TabManager.openNav(vault, dir)` — un onglet de navigation par vault, ouvert à chaque clic répertoire de l'arbre (mode focus et mode All) sans fermer les onglets de fichiers ; la ligne vault reste un expandeur. **A2** : la vue liste les sous-répertoires cliquables et embarque le panneau de facettes **Vaults · Tags · Extensions** (mêmes classes que la page de recherche), les tris **Pertinence / Date** et le bouton **Sauver** ; `GET /api/vault/{v}/files` expose désormais les `tags` indexés (facettes calculées côté client). **A3 / BUG-100** : `showWelcome()` réinjecte `#quick-help` et `goHome()` efface recherche globale + filtre sidebar. Tests : `test_nav_files.py` 3 passed, `navfacets.test.mjs` 10/10, `unit.test.mjs` 12/12, `validate-imports` 41 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 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é |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+334
-4
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.43.1 | **Dernière mise à jour :** 2026-09-29
|
||||
> **Version :** 2.53.2 | **Dernière mise à jour :** 2026-10-07
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -40,6 +40,26 @@
|
||||
- [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
|
||||
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
|
||||
|
||||
### 159. Gestion desktop des vaults & dossiers — retrait par menu contextuel + ajout en configuration
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** commandes Rust existantes (`add/remove/list_vaults|dirs`) + `ContextMenuManager`
|
||||
- **Statut :** ✅ livré le 2026-10-02 (v2.50.0) — ouvert le 2026-10-02 (prérequis : BUG-104/BUG-105 livrés)
|
||||
- **Reste à faire :**
|
||||
- [x] Retrait d'un vault **ou dossier racine** par menu contextuel (entrée « Retirer de l'application », desktop uniquement, confirmation, déregistration seule)
|
||||
- [x] Section Configuration : liste des vaults/dossiers desktop + ajout (bouton vault existant `pickAndAddVault` + nouveau sélecteur `pick_folder` sans effet de bord pour les dossiers)
|
||||
- [x] Rafraîchissement de la jump list après chaque ajout/retrait
|
||||
- [x] i18n FR/EN, tests (garde-fou ACL + unit), E2E, fiche features, release
|
||||
|
||||
### 160. Premier lancement professionnel + section Configuration harmonisée
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** config Rust + CSS classes existantes
|
||||
- **Statut :** ✅ livré le 2026-10-02 (v2.51.0) — ouvert le 2026-10-02
|
||||
- **Reste à faire :**
|
||||
- [x] Premier lancement : monter `%USERPROFILE%\ObsiGate` comme vault par défaut (remplace `voute_obsidian`), `vault_path` dessus, nom de dossier racine home dérivé du chemin (fin du hardcode « bruno »)
|
||||
- [x] Document `Prise en main.md` (contenu embarqué `include_str!`) écrit dans ce dossier si absent — jamais d'écrasement
|
||||
- [x] Refonte visuelle de la section « 🖥️ Vaults & dossiers (Desktop) » : classes CSS (motif `webauthn-key-item`), boutons `.config-btn-sm`, zéro style inline, override mobile 44px
|
||||
- [x] Tests Rust (config par défaut + doc embarqué), suites frontend, E2E, docs, release
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2)
|
||||
@@ -119,12 +139,170 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Ergonomie tableur
|
||||
|
||||
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
|
||||
|
||||
- **Effort :** 2-4 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré** — 2026-09-29 (menu contextuel clic droit + appui long, sélection type Excel)
|
||||
- **Analyse, conception et critères :** [features/xlsx-context-menu.md](./features/xlsx-context-menu.md)
|
||||
- **Description :** retours utilisateur après #154 — ajouter un **menu contextuel** (clic droit +
|
||||
appui long tactile) sur les cellules et en-têtes, et aligner la **sélection/curseur** sur le
|
||||
comportement d'Excel (plage par glisser, `Maj`, sélection de ligne/colonne par en-tête).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Menu contextuel thémé (clic droit + appui long) : insérer/supprimer ligne & colonne,
|
||||
trier, effacer le contenu
|
||||
- [x] **A2** Sélection type Excel : plage (glisser / `Maj+clic` / `Maj+flèches`), en-têtes
|
||||
ligne/colonne, curseur croix, zone Nom affichant la plage
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Polish menus tableur
|
||||
|
||||
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** 🔵 en cours depuis 2026-10-04
|
||||
- **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md)
|
||||
- **Description :** la grille (#155), les menus Structure/Mise en forme/Export
|
||||
(#156-A8/A11/A14) et les boutons du ruban (#154) sont textuels et restent
|
||||
ouverts quand le focus quitte le menu. Ajouter des icônes Lucide
|
||||
représentatives, fermer tout menu à la perte de focus (clic extérieur,
|
||||
`focusout`, `Échap`, défilement), naviguer au clavier dans le menu grille, et
|
||||
garantir des cibles tactiles ≥ 44 px + menus dans le viewport en mobile.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Icônes des entrées du menu contextuel de la grille (+ `item.icon`)
|
||||
- [x] **A2** Icônes des menus Structure / Mise en forme / Export + boutons
|
||||
Enregistrer / « + » onglet
|
||||
- [x] **A3** Fermeture à la perte de focus (helper partageable : extérieur,
|
||||
`focusout`, `Échap`, scroll/resize) + navigation clavier du menu grille
|
||||
- [x] **A4** Polish mobile (44 px tactile, menus bornés au viewport) + tests
|
||||
JSDOM + E2E `xlsx-viewer.spec.js`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Assistant IA
|
||||
|
||||
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
- **Statut :** ✅ **livré le 2026-10-06** — ouvert le 2026-10-06
|
||||
- **Description :** le bouton « mode agent » du panneau (toggle persisté en
|
||||
localStorage, défaut OFF) n'était plus protecteur : Deep Research et les
|
||||
quick actions `agent: true` basculaient déjà le mode en silence. Retiré au
|
||||
profit d'un agent toujours actif : toute requête texte part sur
|
||||
`/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les mutations
|
||||
gardent la confirmation two-step) ; les images seules restent sur
|
||||
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant textuelle).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
|
||||
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
|
||||
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
|
||||
- [x] **A2** Optimisations requêtes AI : `/agent` résout le provider comme
|
||||
`/chat` (avant : `req.provider` brut transmis au provider adapter →
|
||||
moteur réel ≠ étiquette SSE) ; schémas d'outils mis en cache (~27 ms de
|
||||
pydantic économisés par requête agent/MCP)
|
||||
- [x] **A3** Tests : `ai.test.mjs` adapté (100/100) ; non-régression pytest
|
||||
(`test_bookslm.py` provider résolu, `test_tools.py` cache sûr) ;
|
||||
ruff/mypy 0 erreur ; build + instance Docker locale à jour
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Navigation
|
||||
|
||||
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré** — 2026-10-02 (A1 onglet, A2 contenu/filtres/tris, A3 retour Home +
|
||||
**BUG-100**)
|
||||
- **Détail de conception :** [features/navigation-tab-158.md](./features/navigation-tab-158.md)
|
||||
- **Description :** la vue « chemin + Récents + fichiers du répertoire » (vault home) n'apparaît
|
||||
qu'en mode focus vault, disparaît dès qu'un fichier est ouvert, n'expose ni sous-répertoires ni
|
||||
filtres, et le clic sur le titre ne redonne pas la vraie page d'accueil (section
|
||||
**Raccourcis & Astuces** manquante, recherche/filtres non effacés).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1 — onglet de navigation.** Tout clic sur un **répertoire** de l'arbre (en mode focus
|
||||
vault **et** en mode All) ouvre/actualise un **onglet de navigation** dédié, qui
|
||||
coexiste avec les onglets de fichiers ouverts. La ligne d'un vault dans l'arbre garde son
|
||||
rôle d'expansion ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette
|
||||
Vaults) ou le sélecteur de vault.
|
||||
- [x] **A2 — contenu de la vue.** Liste des **sous-répertoires cliquables**, panneau de
|
||||
**facettes Vaults · Tags · Extensions** (même mécanisme/visuel que la page de recherche),
|
||||
boutons de tri **Pertinence / Date** et bouton **Sauver**.
|
||||
- [x] **A3 — retour Home complet.** Clic sur le titre = page d'accueil d'un refresh
|
||||
(section **Raccourcis & Astuces** restaurée) + effacement de la recherche globale et de
|
||||
la barre de filtre de la sidebar gauche.
|
||||
- [x] **A4 — onglet Accueil.** Le clic sur le titre affiche la page d'accueil dans un onglet
|
||||
(icône Maison) épinglé **en tête de barre**, réactivable à tout moment.
|
||||
- [x] **A5 — menus contextuels de la page de navigation.** Clic droit identique à la sidebar
|
||||
sur les répertoires **et** les fichiers de la vue ; la ligne d'un vault dans l'arbre
|
||||
ouvre la page de navigation (racine du vault).
|
||||
- [x] **A6 — icône des répertoires** de la section Répertoires à la couleur de l'arbre
|
||||
(`var(--accent)`).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Tableur Excel (complétude)
|
||||
|
||||
### 156. Éditeur Excel — complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
- **Effort :** 15-22 jours (P0 2-3 j ✅ · P1 4-6 j ✅ · P2 5-7 j ✅ · P3 4-6 j ✅) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-09-30** — **P0** (4 défauts **BUG-096 → BUG-099** corrigés),
|
||||
**P1** (**A5-A7** : presse-papiers de plage, clavier complet, zone Nom éditable), **P2**
|
||||
(**A8** mise en forme en écriture, **A9** décision « pas de moteur de formule, annoncée dans
|
||||
l'UI », **A10** undo/redo unifié conservé au re-rendu) et **P3** (**A11** export sélection/
|
||||
Markdown/HTML/impression + recherche multi-feuilles, **A12** concurrence optimiste `If-Match`,
|
||||
**A13** cache des métadonnées, **A14** outils IA `.xlsm`/`.csv` + recherche/analyse/structure)
|
||||
- **Analyse, défauts, risques et critères d'acceptation :** [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md)
|
||||
- **Description :** #153 a rendu l'éditeur **correct** sur la grille de valeurs, #154/#155 l'ont
|
||||
rendu **convivial** (ruban, inspecteur, undo/redo des cellules, sélection et menu contextuel
|
||||
type Excel). Il reste l'écart avec un vrai éditeur tableur : coller une **plage**, écrire la
|
||||
**mise en forme**, **calculer**, **sortir** le résultat, et détecter un écrivain concurrent.
|
||||
La fiche porte l'audit ; les cases ci-dessous sont le **suivi de référence**.
|
||||
- **Défauts corrigés le 2026-09-29 (cf. [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md)) :**
|
||||
- ✅ BUG-096 — enregistrer un `.csv` depuis la visionneuse levait un `TypeError`
|
||||
(`sheet: sheets[…].name` alors qu'un CSV n'a pas de `xlsx_sheets`) : aucun `PUT …/csv/save`
|
||||
n'était émis (reproduit en JSDOM, corrigé + test) ;
|
||||
- ✅ BUG-097 — feuille `.xlsm` tronquée : « Charger la suite » échouait en **415** —
|
||||
`GET …/xlsx/sheet` accepte désormais `.xlsx` **et** `.xlsm` ;
|
||||
- ✅ BUG-098 — délimiteur CSV détecté (`;`/`,`/tabulation) et réutilisé à l'écriture :
|
||||
un CSV français s'affiche en colonnes et le reste après édition ;
|
||||
- ✅ BUG-099 — sonde de perte **par feuille** + signal `cached_values_unverified` quand le
|
||||
budget est épuisé : plus de perte silencieuse possible des valeurs calculées.
|
||||
- **Sous-tâches :**
|
||||
- **P0 — défauts (🔴, 2-3 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A1** Sauvegarde `.csv` depuis la visionneuse — BUG-096 (test JSDOM : `PUT …/csv/save` émis)
|
||||
- [x] **A2** Chargement paresseux des `.xlsm` — BUG-097 (`GET …/xlsx/sheet` accepte `.xlsx`/`.xlsm`)
|
||||
- [x] **A3** Délimiteur CSV détecté et réutilisé à l'écriture — BUG-098 (`sniff_csv_delimiter`)
|
||||
- [x] **A4** Sonde de perte par feuille + signal `cached_values_unverified` — BUG-099
|
||||
- **P1 — presse-papiers & clavier (🟡, 4-6 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A5** Presse-papiers de plage — copier/couper/coller un bloc TSV (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`, menu contextuel), presse-papiers interne + système, remplissage multi-cellules (tests JSDOM : `A1:B2` → `D5:E6`)
|
||||
- [x] **A6** Clavier complet — `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` (saut de ligne en cellule)
|
||||
- [x] **A7** Zone Nom éditable (« Atteindre » : `B12`, `A1:B3`, `Feuille!A1`) + aide à la saisie des fonctions
|
||||
- **P2 — mise en forme, calcul, undo (🟡, 5-7 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A8** Mise en forme en écriture — bouton **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur) via `PUT …/xlsx/style` (verrou, backup, garde de perte, `If-Match`)
|
||||
- [x] **A9** Calcul — **décision documentée** : pas de moteur de formule, l'enregistrement annonce « enregistrée comme texte » (la garde anti-DDE reste)
|
||||
- [x] **A10** Undo/redo unifié (cellules, effacement, tri/filtre, structure) et conservé au re-rendu (`_xlsxHistory` par fichier)
|
||||
- **P3 — sortie, robustesse, performances (🟢, 4-6 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A11** Sortie & recherche : export de la sélection / Markdown / HTML / impression, recherche sur toutes les feuilles (compteur `n/m · k feuilles`)
|
||||
- [x] **A12** Concurrence optimiste inter-processus (`ETag`/`If-Match`, **409** réparable, bouton « Réessayer » qui relit)
|
||||
- [x] **A13** Performances : cache des métadonnées par `(chemin, mtime, taille)` (LRU 8), invalidé à chaque écriture (axe des colonnes > `MAX_COLS` : hors périmètre)
|
||||
- [x] **A14** Outils IA étendus — `.xlsm`/`.csv` acceptés, `search_workbook`, `analyze_range`, `edit_xlsx_structure`
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
|
||||
- **Effort :** 6-8 jours | **Impact :** 🟢
|
||||
- **Décision 2026-09-26 : reporté (P4)** — axe prioritaire = dette & sécurité (#85/#87) ; #73 hors chemin critique. Si réactivé : partir d'un MVP export/hash/LWW adossé à #59 (PWA offline) + #62 (collab Yjs/CRDT) plutôt qu'un protocole parallèle.
|
||||
- **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
|
||||
@@ -135,6 +313,9 @@
|
||||
- [ ] Conflits : UI de résolution manuelle (diff côte à côte entre version locale et distante)
|
||||
- [ ] Chiffrement : optionnel, chiffrement AES-256-GCM avant transmission
|
||||
- [ ] Pairing : échange de clé publique + code QR pour appairage des appareils
|
||||
- [ ] Périmètre v1.2 : sync des **favoris** (#161) et des **préférences** (thème, langue,
|
||||
historique, recherches sauvegardées) avec résolution de conflits (priorité à la dernière
|
||||
modification) + indicateur « préférences synchronisées »
|
||||
|
||||
---
|
||||
|
||||
@@ -158,6 +339,130 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Améliorations produit & évolutions agent IA (liste v1.2) — P1/P2
|
||||
|
||||
> **Ouvert le 2026-10-04** à partir de la liste « Roadmap ObsiGate – Développement des
|
||||
> Fonctionnalités » v1.2 (phases 1-3 + suggestions agent IA numérotées 161-180 dans la liste
|
||||
> source). Évaluation croisée avec le code réel (2026-10-04) : **7 propositions sont déjà
|
||||
> livrées** et n'ouvrent donc **pas** d'ID ; seul le périmètre manquant reçoit un ID ObsiGate
|
||||
> stable `#161` → `#178`. Les numéros de la liste source ne sont **pas** repris tels quels
|
||||
> (règle du dépôt : un ID n'est jamais attribué à du déjà-fait ni réutilisé).
|
||||
> Chaque item doit passer par [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md) (DoD) à son ouverture.
|
||||
|
||||
### Table de correspondance (liste v1.2 → ObsiGate)
|
||||
|
||||
| Proposition v1.2 | Verdict 2026-10-04 (vérifié dans le code) | ID ObsiGate |
|
||||
|---|---|---|
|
||||
| Ph.1 — Sauvegarde des recherches fréquentes | Sauvegarde **déjà livrée** (`backend/saved_searches.py`, store `data/{user}_saved_searches.json`, bouton « Sauver » #158) ; fréquence & rappels manquants | ⚪ #162 |
|
||||
| Ph.1 — Liens de répertoires aux favoris | Marque-page existant sur **fichiers** uniquement (dashboard) ; répertoires absents | ⚪ #161 |
|
||||
| Ph.1 — Recherche dans la page | **Déjà livrée** en vue lecture (`FindInPageManager`, Ctrl+F + navigation + regex, tableur via #153-A13) ; reste l'intégration **Forge** | ⚪ #163 |
|
||||
| Ph.2 — Auto-complétion intelligente | **Déjà livrée** (`frontend/js/autocomplete.js` : Mermaid, code fence, frontmatter, wikilinks, table — partagé Forge/CodeMirror) | ✅ |
|
||||
| Ph.2 — Suggestions contextuelles (historique & préférences) | Non livré | ⚪ #164 |
|
||||
| Ph.3 — Synchronisation favoris + préférences multiplateforme | Fusionné dans le périmètre de **#73** (reporté P4, décision 2026-09-26) | ⚪ #73 |
|
||||
| 161 — Synchronisation Git automatique des vaults | Non livré (les outils `git_*` #92 ciblent Gitea/GitHub pour l'IA, pas la sync de vault) | ⚪ #165 |
|
||||
| 162 — Génération de résumés automatiques | **Déjà livrée** (`POST /api/ai/summarize`, actions instantanées #106, skills `backend/skills.py`) | ✅ |
|
||||
| 163 — Détection & fusion de doublons | Non livré (brique embeddings/TF-IDF #70 disponible) | ⚪ #166 |
|
||||
| 164 — Traduction automatique | **Déjà livrée** (toolbar IA « Traduire », #106) | ✅ |
|
||||
| 165 — Analyse de ton/style | **Déjà livrée** (toolbar IA « Ton », #106) | ✅ |
|
||||
| 166 — APIs tierces (Google Drive, Notion) | Partiel : sources connectées **Gitea/GitHub** livrées (#103, `backend/tools/connected.py`) ; Drive/Notion manquants | ⚪ #167 |
|
||||
| 167 — Notifications externes (Slack, Discord, Email) | Partiel : webhooks #9 + Push API #67 livrés ; canaux Slack/Discord/SMTP et choix des déclencheurs à câbler | ⚪ #168 |
|
||||
| 168 — Édition collaborative temps réel | **Déjà livrée** (#62 — Yjs/CRDT, WebSocket, awareness, v2.3.0) | ✅ |
|
||||
| 169 — Scripts personnalisés sandboxés | **Déjà livrée** (#61 — plugins en sandbox Web Worker, hooks, 9 endpoints, v2.2.0) | ✅ |
|
||||
| 170 — Planification de tâches automatiques (cron) | Non livré | ⚪ #170 |
|
||||
| 171 — Audit des accès | Partiel : `backend/audit.py` + `/api/admin/audit` + dashboard admin #71 existent ; historique **par fichier**, filtres avancés et export CSV manquants | ⚪ #171 |
|
||||
| 172 — Chiffrement/déchiffrement de fichiers | Non livré (AES-GCM interne réservé aux tokens) | ⚪ #172 |
|
||||
| 173 — Vues personnalisées (Kanban, Calendrier) | Non livré | ⚪ #173 |
|
||||
| 174 — Optimisation mobile | Base **largement livrée** (#69 éditeur mobile, #83 ruban mobile, #114 config responsive 44 px) ; reste un audit du solde | ⚪ #178 |
|
||||
| 175 — Chat intégré | Non livré (transport WebSocket/rooms de #62 réutilisable) | ⚪ #169 |
|
||||
| 176 — Thèmes dynamiques (règles heure/date) | Base livrée (#65 thèmes + import/export) ; règles automatiques manquantes | ⚪ #174 |
|
||||
| 177 — Export formats propriétaires (OneNote, Evernote) | Base livrée (#66 HTML/MD bundle/ePub + PDF #74) ; formats tiers manquants | ⚪ #175 |
|
||||
| 178 — Webhooks pour événements | **Déjà livrée** (#9 — `backend/webhooks.py`, événements + secrets + validation d'URL SSRF-safe) | ✅ |
|
||||
| 179 — Gestion fine des permissions | Non livré (ACLs par vault/dossier racine seulement) | ⚪ #176 |
|
||||
| 180 — Historique des révisions visuel | Partiel : gestion des backups **avec diff** livrée (#40-46, outil IA `diff_backup`) ; UI côte à côte manquante | ⚪ #177 |
|
||||
|
||||
---
|
||||
|
||||
### 161. Favoris — répertoires et liens de vault (menu contextuel)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Description :** le marque-page actuel ne couvre que les fichiers ; ajouter les **répertoires**
|
||||
aux favoris (proposition « liens de répertoires », liste v1.2 Ph.1).
|
||||
- **Sous-tâches :**
|
||||
- [ ] Étendre le modèle de favoris aux répertoires (vérification d'accès backend via
|
||||
`_resolve_safe_path()`, permissions)
|
||||
- [ ] Entrée « Ajouter aux favoris » dans le menu contextuel de l'arbre **et** de la page de
|
||||
navigation (#158-A5)
|
||||
- [ ] Section « Favoris » dans la sidebar (répertoires + fichiers)
|
||||
- [ ] i18n FR/EN, tests (unit + E2E si UI), fiche `docs/features/`
|
||||
|
||||
### 162. Recherches fréquentes — fréquence d'utilisation & rappels
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Cadrage :** la **sauvegarde** des recherches existe déjà (`backend/saved_searches.py`, bouton
|
||||
« Sauver » de #158). Cet item n'ouvre que l'incrément manquant de la liste v1.2 Ph.1.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Compteur d'utilisation par recherche sauvegardée (horodatage + fréquence)
|
||||
- [ ] Section dédiée « Recherches fréquentes » (page de résultats + sidebar) et épinglage
|
||||
- [ ] Rappel optionnel (« Vous avez souvent cherché X ») via notifications #67, interruptible
|
||||
en Configuration
|
||||
- [ ] i18n FR/EN + tests
|
||||
|
||||
### 163. Recherche dans la page — éditeur Forge
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Cadrage :** `FindInPageManager` (Ctrl+F, surbrillance, navigation ↑/↓, casse / mot entier /
|
||||
regex) est livré pour la vue lecture ; le tableur a sa propre recherche (#153-A13). Reste à
|
||||
raccorder la recherche à l'éditeur **Forge** et à l'exposer en commande `/`.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Raccorder `FindInPageManager` à Forge (textarea **et** CodeMirror) : surbrillance dans le
|
||||
contenu édité, navigation avec boucle, conservation du curseur
|
||||
- [ ] Commande `/recherche-dans-page` (mécanisme de commandes #81)
|
||||
- [ ] Non-régression des raccourcis existants (Ctrl+F vs recherche globale, Échap) ; i18n ;
|
||||
tests JSDOM + E2E
|
||||
|
||||
### 164. Suggestions contextuelles dans l'éditeur (historique & préférences)
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Cadrage :** l'auto-complétion contextuelle (Mermaid, code, Markdown, wikilinks, frontmatter)
|
||||
est **déjà livrée** (`frontend/js/autocomplete.js`) — la proposition v1.2 Ph.2
|
||||
« Auto-complétion intelligente » est couverte. Cet item n'ouvre que les suggestions apprises
|
||||
du contexte utilisateur.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Mesure locale des snippets les plus utilisés par l'utilisateur (fréquence, sans cloud)
|
||||
- [ ] Suggestions basées sur l'historique de modification (termes récurrents, patterns du type
|
||||
« souvent inséré après … ») — option locale, IA légère si disponible
|
||||
- [ ] Bascule on/off en Configuration ; intégration à la complétion existante
|
||||
- [ ] i18n FR/EN + tests
|
||||
|
||||
---
|
||||
|
||||
### ⚪ Évolutions agent IA (liste v1.2 « Phase 4 », IDs ObsiGate #165 → #178)
|
||||
|
||||
| ID | Fonctionnalité | Effort | Impact | Dépendances / socle à étendre |
|
||||
|---|---|---|---|---|
|
||||
| 165 | Synchronisation automatique des vaults avec Git (add/commit/push/pull, résolution de conflits, auth SSH/token, option planification X heures) | 4-6 j | 🟡 | subprocess/`gitpython` ; UI dans Configuration (#160) ; distinct des outils `git_*` #92 (IA, Gitea/GitHub) |
|
||||
| 166 | Détection & fusion de doublons (score de similarité, liste des paires potentielles, outil de fusion) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md)) | 3-4 j | 🟢 | embeddings/TF-IDF #70 ; **jamais de fusion sans confirmation explicite** (+ backup préalable) |
|
||||
| 167 | Connexions tierces — Google Drive & Notion (import/export, OAuth2, respect des quotas) | 5-7 j | 🟡 | page « sources connectées » #103 ; rate-limit existant |
|
||||
| 168 | Notifications externes — canaux Slack/Discord (webhooks) et Email (SMTP) + choix des déclencheurs — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; périmètre : Discord, Telegram, SMTP, webhook générique — Slack via webhook générique) | 3-4 j | 🟢 | `backend/webhooks.py` #9 ; Push API #67 |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) | 3-4 j | 🟢 | transport WebSocket/rooms de la collab #62 |
|
||||
| 170 | Planification de tâches automatiques (type cron ; actions = outils existants `create_file`/`append_to_file` ; notifications d'échec) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; tick asyncio 60 s, API + outils, sans UI dédiée) | 3-4 j | 🟢 | scheduler backend (APScheduler ou asyncio) ; UI de création de tâches |
|
||||
| 171 | Audit des accès — extension : historique lecture/édition par fichier, filtres (utilisateur/fichier/date), export CSV | 2-3 j | 🟡 | `backend/audit.py` + `/api/admin/audit` (#71) |
|
||||
| 172 | Chiffrement/déchiffrement de fichiers sensibles (AES-256-GCM, gestion de clés, confirmation) | 3-4 j | 🟡 | **backup automatique préalable** ; clés hors dépôt (jamais de secret committé) |
|
||||
| 173 | Vues personnalisées Kanban & Calendrier (glisser-déposer, colonnes/date par tags & métadonnées) | 5-6 j | 🟢 | facettes/tags #157-#158 ; store JSON par utilisateur (pattern `data/*.json`) |
|
||||
| 174 | Thèmes dynamiques (règles heure/date, ex. mode sombre 18h-8h, règles personnalisables) | 2-3 j | 🟢 | thèmes #65 ; préférence persistée (candidate à la sync #73) |
|
||||
| 175 | Export vers formats propriétaires (OneNote, Evernote) avec conservation des métadonnées (tags, liens, images) | 3-4 j | 🟢 | `export.py` #66 (HTML/MD bundle/ePub) + PDF #74 |
|
||||
| 176 | Permissions fines par fichier/dossier (utilisateur/groupe + actions, héritage, journal des changements) | 4-5 j | 🟡 | ACLs vaults/dossiers existantes ; passerelle obligatoire par `_resolve_safe_path()` |
|
||||
| 177 | Historique des révisions visuel (diff côte à côte, surlignage ajouts/suppressions, navigation Précédent/Suivant) | 3-4 j | 🟢 | backups + diff #40-46 (`diff_backup`) ; `difflib` backend |
|
||||
| 178 | Optimisation mobile — solde (audit cibles tactiles ≥ 44 px, lazy-loading images/médias, raccourcis clavier virtuel) | 4-5 j | 🟡 | base livrée #69/#83/#114 ; lazy-loading déjà partiel (#153-A9) |
|
||||
|
||||
- **Critères d'acceptation transverses (liste v1.2 harmonisée avec la DoD du dépôt) :** test
|
||||
desktop **et** mobile ; documentation utilisateur FR/EN dans `docs/GUIDES/` (l'appel
|
||||
« Guide_MCP_ObsiGate.md » de la liste source est ramené à la cartographie documentaire du
|
||||
dépôt) ; **backup automatique avant toute modification destructive** ; i18n FR/EN systématique ;
|
||||
passage de l'ID à « en cours » avant codage (AGENTS.md, « Avant de commencer »).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Complété — index
|
||||
|
||||
> Détail complet dans [docs/archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) et
|
||||
@@ -167,7 +472,11 @@
|
||||
| # | Domaine / fonctionnalité | Version | Détails |
|
||||
|---|---|---|---|
|
||||
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.44.0 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
|
||||
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
|
||||
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
|
||||
| 156 | Tableur — Complétude éditeur : presse-papiers/clavier (A5-A7), mise en forme (A8), décision calcul (A9), undo unifié (A10), export + recherche multi-feuilles (A11), concurrence optimiste (A12), cache méta (A13), outils IA `.xlsm`/`.csv` (A14) + BUG-096 → BUG-099 | 2.45.0 | [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md) |
|
||||
| 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) |
|
||||
@@ -235,6 +544,11 @@
|
||||
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -244,10 +558,14 @@
|
||||
|---|---|---|
|
||||
| ✅ 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.44.0) | 0 jour restant |
|
||||
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
|
||||
| ✅ Terminé | #155 Ergonomie tableur — menu contextuel & sélection type Excel | 0 jour restant |
|
||||
| ✅ Terminé | #156 Éditeur Excel — complétude — **P0-P3 ✅ livrés le 2026-09-30** (BUG-096 → BUG-099, A5-A14) | 0 jour restant |
|
||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||
|
||||
---
|
||||
@@ -257,6 +575,18 @@
|
||||
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
|
||||
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
|
||||
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
|
||||
- **Ajout 2026-09-29 :** #155 livré — menu contextuel (clic droit / appui long) et sélection type Excel sur la grille ([features/xlsx-context-menu.md](./features/xlsx-context-menu.md)) ; au passage **BUG-094** corrigé (feuille vide/nouvelle désormais éditable, quadrillage vierge 20×8).
|
||||
- **Clôture 2026-09-30 :** #156 **livré (P0 → P3)** — **A8** mise en forme en écriture (bouton **Mise en forme** : gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — via `PUT …/xlsx/style`), **A9** décision « pas de moteur de formule, annoncée dans l'UI », **A10** undo/redo unifié conservé au re-rendu, **A11** export de la sélection / Markdown / HTML / impression + recherche sur toutes les feuilles, **A12** concurrence optimiste (`If-Match`, **409** réparable), **A13** cache des métadonnées par `mtime`, **A14** outils IA `.xlsm`/`.csv` + `search_workbook`/`analyze_range`/`edit_xlsx_structure` — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Ajout 2026-09-29 :** #156 **P1 livré** — **A5** presse-papiers de plage (copier/couper/coller un bloc, presse-papiers interne + système, entrées du menu contextuel, remplissage multi-cellules), **A6** clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) et **A7** zone Nom éditable + aide à la saisie ; restent P2 (mise en forme, calcul, undo unifié) et P3 (sortie, concurrence optimiste, performances, outils IA).
|
||||
- **Ajout 2026-09-29 :** #156 ouvert — audit de complétude de l'éditeur Excel : **4 défauts recensés** (BUG-096 enregistrement `.csv`, BUG-097 lazy-load `.xlsm`, BUG-098 délimiteur CSV, BUG-099 sonde de perte), **corrigés le jour même (P0 ✅)** avec tests de non-régression ; restent P1-P3 (presse-papiers, clavier, mise en forme, calcul, export, concurrence optimiste) puis presse-papiers de plage, clavier complet, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **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,87 @@
|
||||
// ObsiGate AI Agent — flux de traitement d'une demande (b89af91)
|
||||
digraph ObsiGateAgent {
|
||||
rankdir=TB;
|
||||
splines=polyline;
|
||||
nodesep=0.35; ranksep=0.45;
|
||||
graph [fontname="DejaVu Sans", fontsize=13, bgcolor="white", pad=0.3];
|
||||
node [fontname="DejaVu Sans", fontsize=12, style=filled, shape=box, color="#333333", margin="0.16,0.09"];
|
||||
edge [fontname="DejaVu Sans", fontsize=11, color="#555555", arrowsize=0.8];
|
||||
|
||||
// ── Entrée ──
|
||||
user [label="Demande utilisateur\n(frontend bookslm.js)", fillcolor="#dbeafe"];
|
||||
deep [label="Chip « Deep Research »\n= agent mode + prompt injecté :\n« décomposer les étapes, chercher\ndans le vault, croiser,\nsynthétiser avec sources »",
|
||||
shape=note, fillcolor="#fef3c7"];
|
||||
api [label="POST /api/bookslm/agent\n→ réponse SSE (thought · tool ·\nconfirmation · message · done)", fillcolor="#e0f2fe"];
|
||||
prompt [label="Construction du system prompt\nmode directory / documents / general\ncontexte vault pré-chargé (BooksLM :\n≤200 fichiers, ≤200k chars, cache 5 min)\nprotocole agent = function calling natif", fillcolor="#f1f5f9"];
|
||||
|
||||
user -> api; deep -> user [style=dashed, label="active", fontsize=10];
|
||||
api -> prompt;
|
||||
|
||||
// ── Boucle agent ──
|
||||
subgraph cluster_loop {
|
||||
label="BOUCLE AGENT — backend/agent/loop.py · run_agent()";
|
||||
style=rounded; color="#e11d48"; penwidth=2; fontsize=13; fontcolor="#e11d48";
|
||||
|
||||
llm [label="Appel LLM — chat_completion()\nfournisseur OpenAI-compatible / Gemini\ntemp=0.3 · max_tokens 8192\nschemas des 50 outils exposés\n(si modèle sans tools → retombe chat simple)", fillcolor="#fee2e2"];
|
||||
decide [label="Le LLM demande des\noutils (tool_calls) ?", shape=diamond, fillcolor="#fff7ed"];
|
||||
thought [label="note de raisonnement\nintermédiaire → step visible « pensée »", fillcolor="#fef9c3"];
|
||||
tools [label="Exécution de chaque tool call\nvia call_tool() (voir pipeline)", fillcolor="#dcfce7"];
|
||||
back [label="résultat (JSON, tronqué ≤100k chars)\najouté à la conversation →\nitération suivante", shape=plaintext];
|
||||
|
||||
llm -> decide;
|
||||
decide -> thought [label="oui", color="#e11d48"];
|
||||
thought -> tools;
|
||||
tools -> llm [label="itération ≤ 10\nquota tools ≤ 25\n(BOOKSLM_MAX_TOOL_CALLS)", color="#e11d48"];
|
||||
}
|
||||
prompt -> llm;
|
||||
|
||||
// ── Pipeline call_tool ──
|
||||
subgraph cluster_pipe {
|
||||
label="PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là";
|
||||
style=rounded; color="#7c3aed"; fontsize=13; fontcolor="#7c3aed";
|
||||
|
||||
p1 [label="1 · Schéma Pydantic\n+ validation arguments", fillcolor="#ede9fe"];
|
||||
p2 [label="2 · Rate limit\npar identité + outil", fillcolor="#ede9fe"];
|
||||
p3 [label="3 · Permission vault\n(+ déstructif si risque\nDANGEROUS)", fillcolor="#ede9fe"];
|
||||
p4 [label="4 · Risque READ ?\n→ exécution directe", shape=diamond, fillcolor="#f5f3ff"];
|
||||
p5 [label="PAUSE CONFIRMATION\noutil WRITE/DANGEROUS → event SSE\n« confirmation » avec le plan complet\nbatché (BUG-075) : une seule\napprobation applique toutes les mutations\n(resume via confirm / confirm_all)", fillcolor="#fde68a"];
|
||||
p6 [label="5 · Handler exécuté\n6 · Audit JSON-lines\n7 · Redaction secrets (redact_payload)", fillcolor="#dcfce7"];
|
||||
p1 -> p2 -> p3 -> p4;
|
||||
p4 -> p6 [label="read"];
|
||||
p4 -> p5 [label="mutation", color="#d97706"];
|
||||
p5 -> p6 [label="approuvé", style=dashed];
|
||||
}
|
||||
tools -> p1;
|
||||
p6 -> back [label="tool result", style=invis];
|
||||
p6 -> tools [style=invis];
|
||||
back -> p1 [style=invis];
|
||||
|
||||
// ── Sorties ──
|
||||
done [label="Réponse finale\nréponse sans tool_calls\n→ SSE « message »\n(Markdown + sources citées)", fillcolor="#bbf7d0"];
|
||||
final [label="Budget épuisé (10 itérations\nou 25 appels) → _finalize_answer :\ndernier appel SANS outils pour forcer\nune synthèse, sinon liste des sources\ndéterministe (jamais de réponse vide)", fillcolor="#fed7aa"];
|
||||
decide -> done [label="non", color="#059669", penwidth=2];
|
||||
decide -> final [label="budget atteint", color="#ea580c"];
|
||||
answer [label="Affichage UI : bloc\n« N étapes » Notion-style\n(pensées + outils dépliables)", fillcolor="#dbeafe"];
|
||||
done -> answer; final -> answer;
|
||||
|
||||
// ── Boîte à outils ──
|
||||
subgraph cluster_tools {
|
||||
label="50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS";
|
||||
style=rounded; color="#0369a1"; fontsize=13; fontcolor="#0369a1";
|
||||
node [fillcolor="#f0f9ff", fontsize=10];
|
||||
|
||||
fam_vault [label="VAULT · service.py (22)\nlecture : list_vaults, list_directory,\nlist_all_files, read_file, read_file_raw,\nsearch_fulltext, search_advanced,\nsearch_paths, get_graph, get_backlinks,\nlist_tags, suggest_tags, list_recent,\nlist_backups, diff_backup\nécriture : create_file, create_directory,\nappend_to_file, edit_file, restore_backup\nDANGEROUS : delete_file, delete_directory,\nmove_path, rename_file, rename_directory,\nreplace_in_files"];
|
||||
fam_web [label="WEB · web.py + crawler.py\nweb_search · fetch_url\ncrawl_site (≤20 pages, même hôte)"];
|
||||
fam_doc [label="DOCUMENTS · documents.py\ncreate_pdf · create_docx\ncreate_xlsx · create_csv"];
|
||||
fam_xlsx [label="TABLEURS · spreadsheets.py\nlist_xlsx_sheets · xlsx_to_markdown\nsearch_workbook · analyze_range\nupdate_xlsx_cells · append_xlsx_rows\nedit_xlsx_structure"];
|
||||
fam_dup [label="DOUBLONS · duplicates.py\nfind_duplicates\nmerge_duplicate_notes (DANG.)"];
|
||||
fam_git [label="GIT CONNECTÉ · connected.py\ngit_list_repos · git_get_file\ngit_search_issues (Gitea/GitHub)"];
|
||||
fam_sched [label="SCHEDULER · scheduled.py\nlist/create/delete/run_scheduled_task"];
|
||||
fam_notify [label="NOTIFICATIONS · notify.py\nnotify_external (Discord,\nTelegram, SMTP, webhook)"];
|
||||
}
|
||||
p6 -> cluster_tools [style=invis];
|
||||
fam_vault -> fam_web [style=invis]; fam_web -> fam_doc [style=invis];
|
||||
fam_doc -> fam_xlsx [style=invis]; fam_xlsx -> fam_dup [style=invis];
|
||||
fam_dup -> fam_git [style=invis]; fam_git -> fam_sched [style=invis];
|
||||
fam_sched -> fam_notify [style=invis];
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.42.4 (0)
|
||||
-->
|
||||
<!-- Title: ObsiGateAgent Pages: 1 -->
|
||||
<svg width="2130pt" height="1034pt"
|
||||
viewBox="0.00 0.00 2130.20 1033.70" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(21.6 1012.1)">
|
||||
<title>ObsiGateAgent</title>
|
||||
<polygon fill="white" stroke="transparent" points="-21.6,21.6 -21.6,-1012.1 2108.6,-1012.1 2108.6,21.6 -21.6,21.6"/>
|
||||
<g id="clust1" class="cluster">
|
||||
<title>cluster_loop</title>
|
||||
<path fill="white" stroke="#e11d48" stroke-width="2" d="M493,-11.5C493,-11.5 1052,-11.5 1052,-11.5 1058,-11.5 1064,-17.5 1064,-23.5 1064,-23.5 1064,-471 1064,-471 1064,-477 1058,-483 1052,-483 1052,-483 493,-483 493,-483 487,-483 481,-477 481,-471 481,-471 481,-23.5 481,-23.5 481,-17.5 487,-11.5 493,-11.5"/>
|
||||
<text text-anchor="middle" x="772.5" y="-468.6" font-family="DejaVu Sans" font-size="13.00" fill="#e11d48">BOUCLE AGENT — backend/agent/loop.py · run_agent()</text>
|
||||
</g>
|
||||
<g id="clust2" class="cluster">
|
||||
<title>cluster_pipe</title>
|
||||
<path fill="white" stroke="#7c3aed" d="M1084,-102C1084,-102 1580,-102 1580,-102 1586,-102 1592,-108 1592,-114 1592,-114 1592,-761.5 1592,-761.5 1592,-767.5 1586,-773.5 1580,-773.5 1580,-773.5 1084,-773.5 1084,-773.5 1078,-773.5 1072,-767.5 1072,-761.5 1072,-761.5 1072,-114 1072,-114 1072,-108 1078,-102 1084,-102"/>
|
||||
<text text-anchor="middle" x="1332" y="-759.1" font-family="DejaVu Sans" font-size="13.00" fill="#7c3aed">PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là</text>
|
||||
</g>
|
||||
<g id="clust3" class="cluster">
|
||||
<title>cluster_tools</title>
|
||||
<path fill="white" stroke="#0369a1" d="M1612,-8C1612,-8 2067,-8 2067,-8 2073,-8 2079,-14 2079,-20 2079,-20 2079,-970.5 2079,-970.5 2079,-976.5 2073,-982.5 2067,-982.5 2067,-982.5 1612,-982.5 1612,-982.5 1606,-982.5 1600,-976.5 1600,-970.5 1600,-970.5 1600,-20 1600,-20 1600,-14 1606,-8 1612,-8"/>
|
||||
<text text-anchor="middle" x="1839.5" y="-968.1" font-family="DejaVu Sans" font-size="13.00" fill="#0369a1">50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS</text>
|
||||
</g>
|
||||
<!-- user -->
|
||||
<g id="node1" class="node">
|
||||
<title>user</title>
|
||||
<polygon fill="#dbeafe" stroke="#333333" points="712.5,-743.5 561.5,-743.5 561.5,-705.5 712.5,-705.5 712.5,-743.5"/>
|
||||
<text text-anchor="middle" x="637" y="-727.9" font-family="DejaVu Sans" font-size="12.00">Demande utilisateur</text>
|
||||
<text text-anchor="middle" x="637" y="-714.9" font-family="DejaVu Sans" font-size="12.00">(frontend bookslm.js)</text>
|
||||
</g>
|
||||
<!-- api -->
|
||||
<g id="node3" class="node">
|
||||
<title>api</title>
|
||||
<polygon fill="#e0f2fe" stroke="#333333" points="742.5,-669 531.5,-669 531.5,-618 742.5,-618 742.5,-669"/>
|
||||
<text text-anchor="middle" x="637" y="-653.4" font-family="DejaVu Sans" font-size="12.00">POST /api/bookslm/agent</text>
|
||||
<text text-anchor="middle" x="637" y="-640.4" font-family="DejaVu Sans" font-size="12.00">→ réponse SSE (thought · tool ·</text>
|
||||
<text text-anchor="middle" x="637" y="-627.4" font-family="DejaVu Sans" font-size="12.00">confirmation · message · done)</text>
|
||||
</g>
|
||||
<!-- user->api -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>user->api</title>
|
||||
<path fill="none" stroke="#555555" d="M637,-705.18C637,-696.86 637,-686.76 637,-677.2"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="639.8,-677.01 637,-669.01 634.2,-677.01 639.8,-677.01"/>
|
||||
</g>
|
||||
<!-- deep -->
|
||||
<g id="node2" class="node">
|
||||
<title>deep</title>
|
||||
<polygon fill="#fef3c7" stroke="#333333" points="749,-919 519,-919 519,-842 755,-842 755,-913 749,-919"/>
|
||||
<polyline fill="none" stroke="#333333" points="749,-919 749,-913 "/>
|
||||
<polyline fill="none" stroke="#333333" points="755,-913 749,-913 "/>
|
||||
<text text-anchor="middle" x="637" y="-903.4" font-family="DejaVu Sans" font-size="12.00">Chip « Deep Research »</text>
|
||||
<text text-anchor="middle" x="637" y="-890.4" font-family="DejaVu Sans" font-size="12.00">= agent mode + prompt injecté :</text>
|
||||
<text text-anchor="middle" x="637" y="-877.4" font-family="DejaVu Sans" font-size="12.00">« décomposer les étapes, chercher</text>
|
||||
<text text-anchor="middle" x="637" y="-864.4" font-family="DejaVu Sans" font-size="12.00">dans le vault, croiser,</text>
|
||||
<text text-anchor="middle" x="637" y="-851.4" font-family="DejaVu Sans" font-size="12.00">synthétiser avec sources »</text>
|
||||
</g>
|
||||
<!-- deep->user -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>deep->user</title>
|
||||
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M637,-841.83C637,-813.95 637,-776.67 637,-751.91"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="639.8,-751.71 637,-743.71 634.2,-751.71 639.8,-751.71"/>
|
||||
<text text-anchor="middle" x="652.5" y="-784.5" font-family="DejaVu Sans" font-size="10.00">active</text>
|
||||
</g>
|
||||
<!-- prompt -->
|
||||
<g id="node4" class="node">
|
||||
<title>prompt</title>
|
||||
<polygon fill="#f1f5f9" stroke="#333333" points="773.5,-585 500.5,-585 500.5,-508 773.5,-508 773.5,-585"/>
|
||||
<text text-anchor="middle" x="637" y="-569.4" font-family="DejaVu Sans" font-size="12.00">Construction du system prompt</text>
|
||||
<text text-anchor="middle" x="637" y="-556.4" font-family="DejaVu Sans" font-size="12.00">mode directory / documents / general</text>
|
||||
<text text-anchor="middle" x="637" y="-543.4" font-family="DejaVu Sans" font-size="12.00">contexte vault pré-chargé (BooksLM :</text>
|
||||
<text text-anchor="middle" x="637" y="-530.4" font-family="DejaVu Sans" font-size="12.00">≤200 fichiers, ≤200k chars, cache 5 min)</text>
|
||||
<text text-anchor="middle" x="637" y="-517.4" font-family="DejaVu Sans" font-size="12.00">protocole agent = function calling natif</text>
|
||||
</g>
|
||||
<!-- api->prompt -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>api->prompt</title>
|
||||
<path fill="none" stroke="#555555" d="M637,-617.54C637,-610.12 637,-601.74 637,-593.43"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="639.8,-593.17 637,-585.17 634.2,-593.17 639.8,-593.17"/>
|
||||
</g>
|
||||
<!-- llm -->
|
||||
<g id="node5" class="node">
|
||||
<title>llm</title>
|
||||
<polygon fill="#fee2e2" stroke="#333333" points="784.5,-453 489.5,-453 489.5,-376 784.5,-376 784.5,-453"/>
|
||||
<text text-anchor="middle" x="637" y="-437.4" font-family="DejaVu Sans" font-size="12.00">Appel LLM — chat_completion()</text>
|
||||
<text text-anchor="middle" x="637" y="-424.4" font-family="DejaVu Sans" font-size="12.00">fournisseur OpenAI-compatible / Gemini</text>
|
||||
<text text-anchor="middle" x="637" y="-411.4" font-family="DejaVu Sans" font-size="12.00">temp=0.3 · max_tokens 8192</text>
|
||||
<text text-anchor="middle" x="637" y="-398.4" font-family="DejaVu Sans" font-size="12.00">schemas des 50 outils exposés</text>
|
||||
<text text-anchor="middle" x="637" y="-385.4" font-family="DejaVu Sans" font-size="12.00">(si modèle sans tools → retombe chat simple)</text>
|
||||
</g>
|
||||
<!-- prompt->llm -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>prompt->llm</title>
|
||||
<path fill="none" stroke="#555555" d="M637,-507.9C637,-493.35 637,-476.55 637,-461.3"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="639.8,-461.02 637,-453.02 634.2,-461.02 639.8,-461.02"/>
|
||||
</g>
|
||||
<!-- decide -->
|
||||
<g id="node6" class="node">
|
||||
<title>decide</title>
|
||||
<polygon fill="#fff7ed" stroke="#333333" points="641,-325 489,-287 641,-249 793,-287 641,-325"/>
|
||||
<text text-anchor="middle" x="641" y="-290.4" font-family="DejaVu Sans" font-size="12.00">Le LLM demande des</text>
|
||||
<text text-anchor="middle" x="641" y="-277.4" font-family="DejaVu Sans" font-size="12.00">outils (tool_calls) ?</text>
|
||||
</g>
|
||||
<!-- llm->decide -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>llm->decide</title>
|
||||
<path fill="none" stroke="#555555" d="M638.2,-375.85C638.63,-362.33 639.12,-346.95 639.57,-332.87"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="642.37,-332.85 639.83,-324.76 636.77,-332.67 642.37,-332.85"/>
|
||||
</g>
|
||||
<!-- thought -->
|
||||
<g id="node7" class="node">
|
||||
<title>thought</title>
|
||||
<polygon fill="#fef9c3" stroke="#333333" points="786.5,-154.5 529.5,-154.5 529.5,-116.5 786.5,-116.5 786.5,-154.5"/>
|
||||
<text text-anchor="middle" x="658" y="-138.9" font-family="DejaVu Sans" font-size="12.00">note de raisonnement</text>
|
||||
<text text-anchor="middle" x="658" y="-125.9" font-family="DejaVu Sans" font-size="12.00">intermédiaire → step visible « pensée »</text>
|
||||
</g>
|
||||
<!-- decide->thought -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>decide->thought</title>
|
||||
<path fill="none" stroke="#e11d48" d="M645.12,-249.81C648.17,-222.91 652.28,-186.83 655.02,-162.68"/>
|
||||
<polygon fill="#e11d48" stroke="#e11d48" points="657.81,-162.94 655.93,-154.67 652.25,-162.31 657.81,-162.94"/>
|
||||
<text text-anchor="middle" x="659.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">oui</text>
|
||||
</g>
|
||||
<!-- done -->
|
||||
<g id="node16" class="node">
|
||||
<title>done</title>
|
||||
<polygon fill="#bbf7d0" stroke="#333333" points="473,-167.5 275,-167.5 275,-103.5 473,-103.5 473,-167.5"/>
|
||||
<text text-anchor="middle" x="374" y="-151.9" font-family="DejaVu Sans" font-size="12.00">Réponse finale</text>
|
||||
<text text-anchor="middle" x="374" y="-138.9" font-family="DejaVu Sans" font-size="12.00">réponse sans tool_calls</text>
|
||||
<text text-anchor="middle" x="374" y="-125.9" font-family="DejaVu Sans" font-size="12.00">→ SSE « message »</text>
|
||||
<text text-anchor="middle" x="374" y="-112.9" font-family="DejaVu Sans" font-size="12.00">(Markdown + sources citées)</text>
|
||||
</g>
|
||||
<!-- decide->done -->
|
||||
<g id="edge19" class="edge">
|
||||
<title>decide->done</title>
|
||||
<path fill="none" stroke="#059669" stroke-width="2" d="M595.1,-260.3C551.33,-235.79 485.07,-198.69 436.49,-171.49"/>
|
||||
<polygon fill="#059669" stroke="#059669" stroke-width="2" points="437.81,-169.02 429.46,-167.55 435.07,-173.9 437.81,-169.02"/>
|
||||
<text text-anchor="middle" x="541.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">non</text>
|
||||
</g>
|
||||
<!-- final -->
|
||||
<g id="node17" class="node">
|
||||
<title>final</title>
|
||||
<polygon fill="#fed7aa" stroke="#333333" points="250,-174 0,-174 0,-97 250,-97 250,-174"/>
|
||||
<text text-anchor="middle" x="125" y="-158.4" font-family="DejaVu Sans" font-size="12.00">Budget épuisé (10 itérations</text>
|
||||
<text text-anchor="middle" x="125" y="-145.4" font-family="DejaVu Sans" font-size="12.00">ou 25 appels) → _finalize_answer :</text>
|
||||
<text text-anchor="middle" x="125" y="-132.4" font-family="DejaVu Sans" font-size="12.00">dernier appel SANS outils pour forcer</text>
|
||||
<text text-anchor="middle" x="125" y="-119.4" font-family="DejaVu Sans" font-size="12.00">une synthèse, sinon liste des sources</text>
|
||||
<text text-anchor="middle" x="125" y="-106.4" font-family="DejaVu Sans" font-size="12.00">déterministe (jamais de réponse vide)</text>
|
||||
</g>
|
||||
<!-- decide->final -->
|
||||
<g id="edge20" class="edge">
|
||||
<title>decide->final</title>
|
||||
<path fill="none" stroke="#ea580c" d="M565.01,-267.84C491.13,-250.18 390,-226 390,-226 390,-226 314.36,-200.46 243.91,-176.66"/>
|
||||
<polygon fill="#ea580c" stroke="#ea580c" points="244.71,-173.97 236.23,-174.07 242.92,-179.28 244.71,-173.97"/>
|
||||
<text text-anchor="middle" x="429.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">budget atteint</text>
|
||||
</g>
|
||||
<!-- tools -->
|
||||
<g id="node8" class="node">
|
||||
<title>tools</title>
|
||||
<polygon fill="#dcfce7" stroke="#333333" points="987,-57.5 787,-57.5 787,-19.5 987,-19.5 987,-57.5"/>
|
||||
<text text-anchor="middle" x="887" y="-41.9" font-family="DejaVu Sans" font-size="12.00">Exécution de chaque tool call</text>
|
||||
<text text-anchor="middle" x="887" y="-28.9" font-family="DejaVu Sans" font-size="12.00">via call_tool() (voir pipeline)</text>
|
||||
</g>
|
||||
<!-- thought->tools -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>thought->tools</title>
|
||||
<path fill="none" stroke="#555555" d="M701.61,-116.41C739.89,-100.53 795.6,-77.42 835.95,-60.68"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="837.23,-63.18 843.55,-57.53 835.09,-58 837.23,-63.18"/>
|
||||
</g>
|
||||
<!-- tools->llm -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>tools->llm</title>
|
||||
<path fill="none" stroke="#e11d48" d="M881.93,-57.8C864.2,-121.83 806,-332 806,-332 806,-332 764.31,-352.1 722.35,-372.34"/>
|
||||
<polygon fill="#e11d48" stroke="#e11d48" points="721.06,-369.85 715.07,-375.85 723.49,-374.9 721.06,-369.85"/>
|
||||
<text text-anchor="middle" x="930" y="-217.2" font-family="DejaVu Sans" font-size="11.00">itération ≤ 10</text>
|
||||
<text text-anchor="middle" x="930" y="-205.2" font-family="DejaVu Sans" font-size="11.00">quota tools ≤ 25</text>
|
||||
<text text-anchor="middle" x="930" y="-193.2" font-family="DejaVu Sans" font-size="11.00">(BOOKSLM_MAX_TOOL_CALLS)</text>
|
||||
</g>
|
||||
<!-- p1 -->
|
||||
<g id="node10" class="node">
|
||||
<title>p1</title>
|
||||
<polygon fill="#ede9fe" stroke="#333333" points="1244,-743.5 1080,-743.5 1080,-705.5 1244,-705.5 1244,-743.5"/>
|
||||
<text text-anchor="middle" x="1162" y="-727.9" font-family="DejaVu Sans" font-size="12.00">1 · Schéma Pydantic</text>
|
||||
<text text-anchor="middle" x="1162" y="-714.9" font-family="DejaVu Sans" font-size="12.00">+ validation arguments</text>
|
||||
</g>
|
||||
<!-- tools->p1 -->
|
||||
<g id="edge15" class="edge">
|
||||
<title>tools->p1</title>
|
||||
<path fill="none" stroke="#555555" d="M927.18,-57.59C988.4,-85.16 1098,-134.5 1098,-134.5 1098,-644.5 1098,-644.5 1098,-644.5 1098,-644.5 1123.27,-675.7 1142.06,-698.88"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1139.94,-700.72 1147.16,-705.18 1144.3,-697.2 1139.94,-700.72"/>
|
||||
</g>
|
||||
<!-- back -->
|
||||
<g id="node9" class="node">
|
||||
<title>back</title>
|
||||
<polygon fill="#333333" stroke="transparent" points="1056,-440 810,-440 810,-389 1056,-389 1056,-440"/>
|
||||
<text text-anchor="middle" x="933" y="-424.4" font-family="DejaVu Sans" font-size="12.00">résultat (JSON, tronqué ≤100k chars)</text>
|
||||
<text text-anchor="middle" x="933" y="-411.4" font-family="DejaVu Sans" font-size="12.00">ajouté à la conversation →</text>
|
||||
<text text-anchor="middle" x="933" y="-398.4" font-family="DejaVu Sans" font-size="12.00">itération suivante</text>
|
||||
</g>
|
||||
<!-- back->p1 -->
|
||||
<!-- p2 -->
|
||||
<g id="node11" class="node">
|
||||
<title>p2</title>
|
||||
<polygon fill="#ede9fe" stroke="#333333" points="1282,-662.5 1146,-662.5 1146,-624.5 1282,-624.5 1282,-662.5"/>
|
||||
<text text-anchor="middle" x="1214" y="-646.9" font-family="DejaVu Sans" font-size="12.00">2 · Rate limit</text>
|
||||
<text text-anchor="middle" x="1214" y="-633.9" font-family="DejaVu Sans" font-size="12.00">par identité + outil</text>
|
||||
</g>
|
||||
<!-- p1->p2 -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>p1->p2</title>
|
||||
<path fill="none" stroke="#555555" d="M1174.06,-705.18C1181.02,-694.61 1189.86,-681.17 1197.46,-669.62"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1199.95,-670.95 1202,-662.72 1195.27,-667.87 1199.95,-670.95"/>
|
||||
</g>
|
||||
<!-- p3 -->
|
||||
<g id="node12" class="node">
|
||||
<title>p3</title>
|
||||
<polygon fill="#ede9fe" stroke="#333333" points="1321.5,-572 1168.5,-572 1168.5,-521 1321.5,-521 1321.5,-572"/>
|
||||
<text text-anchor="middle" x="1245" y="-556.4" font-family="DejaVu Sans" font-size="12.00">3 · Permission vault</text>
|
||||
<text text-anchor="middle" x="1245" y="-543.4" font-family="DejaVu Sans" font-size="12.00">(+ déstructif si risque</text>
|
||||
<text text-anchor="middle" x="1245" y="-530.4" font-family="DejaVu Sans" font-size="12.00">DANGEROUS)</text>
|
||||
</g>
|
||||
<!-- p2->p3 -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>p2->p3</title>
|
||||
<path fill="none" stroke="#555555" d="M1219.98,-624.18C1224.04,-611.72 1229.54,-594.88 1234.35,-580.15"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1237.11,-580.7 1236.93,-572.23 1231.79,-578.96 1237.11,-580.7"/>
|
||||
</g>
|
||||
<!-- p4 -->
|
||||
<g id="node13" class="node">
|
||||
<title>p4</title>
|
||||
<polygon fill="#f5f3ff" stroke="#333333" points="1278,-452.5 1136,-414.5 1278,-376.5 1420,-414.5 1278,-452.5"/>
|
||||
<text text-anchor="middle" x="1278" y="-417.9" font-family="DejaVu Sans" font-size="12.00">4 · Risque READ ?</text>
|
||||
<text text-anchor="middle" x="1278" y="-404.9" font-family="DejaVu Sans" font-size="12.00">→ exécution directe</text>
|
||||
</g>
|
||||
<!-- p3->p4 -->
|
||||
<g id="edge11" class="edge">
|
||||
<title>p3->p4</title>
|
||||
<path fill="none" stroke="#555555" d="M1251.28,-520.74C1255.74,-503.18 1261.84,-479.16 1267.08,-458.5"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1269.87,-458.92 1269.12,-450.48 1264.44,-457.54 1269.87,-458.92"/>
|
||||
</g>
|
||||
<!-- p5 -->
|
||||
<g id="node14" class="node">
|
||||
<title>p5</title>
|
||||
<polygon fill="#fde68a" stroke="#333333" points="1413.5,-332 1136.5,-332 1136.5,-242 1413.5,-242 1413.5,-332"/>
|
||||
<text text-anchor="middle" x="1275" y="-316.4" font-family="DejaVu Sans" font-size="12.00">PAUSE CONFIRMATION</text>
|
||||
<text text-anchor="middle" x="1275" y="-303.4" font-family="DejaVu Sans" font-size="12.00">outil WRITE/DANGEROUS → event SSE</text>
|
||||
<text text-anchor="middle" x="1275" y="-290.4" font-family="DejaVu Sans" font-size="12.00">« confirmation » avec le plan complet</text>
|
||||
<text text-anchor="middle" x="1275" y="-277.4" font-family="DejaVu Sans" font-size="12.00">batché (BUG-075) : une seule</text>
|
||||
<text text-anchor="middle" x="1275" y="-264.4" font-family="DejaVu Sans" font-size="12.00">approbation applique toutes les mutations</text>
|
||||
<text text-anchor="middle" x="1275" y="-251.4" font-family="DejaVu Sans" font-size="12.00">(resume via confirm / confirm_all)</text>
|
||||
</g>
|
||||
<!-- p4->p5 -->
|
||||
<g id="edge13" class="edge">
|
||||
<title>p4->p5</title>
|
||||
<path fill="none" stroke="#d97706" d="M1277.12,-376.52C1276.85,-365.25 1276.54,-352.62 1276.26,-340.52"/>
|
||||
<polygon fill="#d97706" stroke="#d97706" points="1279.05,-340.18 1276.06,-332.25 1273.45,-340.31 1279.05,-340.18"/>
|
||||
<text text-anchor="middle" x="1301" y="-351.2" font-family="DejaVu Sans" font-size="11.00">mutation</text>
|
||||
</g>
|
||||
<!-- p6 -->
|
||||
<g id="node15" class="node">
|
||||
<title>p6</title>
|
||||
<polygon fill="#dcfce7" stroke="#333333" points="1395.5,-161 1142.5,-161 1142.5,-110 1395.5,-110 1395.5,-161"/>
|
||||
<text text-anchor="middle" x="1269" y="-145.4" font-family="DejaVu Sans" font-size="12.00">5 · Handler exécuté</text>
|
||||
<text text-anchor="middle" x="1269" y="-132.4" font-family="DejaVu Sans" font-size="12.00">6 · Audit JSON-lines</text>
|
||||
<text text-anchor="middle" x="1269" y="-119.4" font-family="DejaVu Sans" font-size="12.00">7 · Redaction secrets (redact_payload)</text>
|
||||
</g>
|
||||
<!-- p4->p6 -->
|
||||
<g id="edge12" class="edge">
|
||||
<title>p4->p6</title>
|
||||
<path fill="none" stroke="#555555" d="M1323.61,-388.38C1367.1,-364.44 1426,-332 1426,-332 1426,-332 1426,-242 1426,-242 1426,-242 1358.97,-196.96 1312.51,-165.73"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1314.01,-163.37 1305.81,-161.24 1310.89,-168.02 1314.01,-163.37"/>
|
||||
<text text-anchor="middle" x="1438.5" y="-284.2" font-family="DejaVu Sans" font-size="11.00">read</text>
|
||||
</g>
|
||||
<!-- p5->p6 -->
|
||||
<g id="edge14" class="edge">
|
||||
<title>p5->p6</title>
|
||||
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M1273.23,-241.95C1272.3,-218.69 1271.17,-190.62 1270.32,-169.29"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="1273.11,-169.06 1269.99,-161.18 1267.51,-169.28 1273.11,-169.06"/>
|
||||
<text text-anchor="middle" x="1298" y="-205.2" font-family="DejaVu Sans" font-size="11.00">approuvé</text>
|
||||
</g>
|
||||
<!-- p6->tools -->
|
||||
<!-- p6->back -->
|
||||
<!-- cluster_tools -->
|
||||
<g id="node27" class="node">
|
||||
<title>cluster_tools</title>
|
||||
<polygon fill="#333333" stroke="#333333" points="1319,-56.5 1219,-56.5 1219,-20.5 1319,-20.5 1319,-56.5"/>
|
||||
<text text-anchor="middle" x="1269" y="-35.4" font-family="DejaVu Sans" font-size="12.00">cluster_tools</text>
|
||||
</g>
|
||||
<!-- p6->cluster_tools -->
|
||||
<!-- answer -->
|
||||
<g id="node18" class="node">
|
||||
<title>answer</title>
|
||||
<polygon fill="#dbeafe" stroke="#333333" points="347,-64 151,-64 151,-13 347,-13 347,-64"/>
|
||||
<text text-anchor="middle" x="249" y="-48.4" font-family="DejaVu Sans" font-size="12.00">Affichage UI : bloc</text>
|
||||
<text text-anchor="middle" x="249" y="-35.4" font-family="DejaVu Sans" font-size="12.00">« N étapes » Notion-style</text>
|
||||
<text text-anchor="middle" x="249" y="-22.4" font-family="DejaVu Sans" font-size="12.00">(pensées + outils dépliables)</text>
|
||||
</g>
|
||||
<!-- done->answer -->
|
||||
<g id="edge21" class="edge">
|
||||
<title>done->answer</title>
|
||||
<path fill="none" stroke="#555555" d="M333.07,-103.39C318.5,-92.32 302.14,-79.89 287.76,-68.96"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="289.37,-66.66 281.3,-64.05 285.98,-71.12 289.37,-66.66"/>
|
||||
</g>
|
||||
<!-- final->answer -->
|
||||
<g id="edge22" class="edge">
|
||||
<title>final->answer</title>
|
||||
<path fill="none" stroke="#555555" d="M174.01,-96.95C186.01,-87.76 198.68,-78.05 210.13,-69.28"/>
|
||||
<polygon fill="#555555" stroke="#555555" points="212.07,-71.32 216.71,-64.24 208.66,-66.88 212.07,-71.32"/>
|
||||
</g>
|
||||
<!-- fam_vault -->
|
||||
<g id="node19" class="node">
|
||||
<title>fam_vault</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1958,-952.5 1720,-952.5 1720,-808.5 1958,-808.5 1958,-952.5"/>
|
||||
<text text-anchor="middle" x="1839" y="-938.5" font-family="DejaVu Sans" font-size="10.00">VAULT · service.py (22)</text>
|
||||
<text text-anchor="middle" x="1839" y="-927.5" font-family="DejaVu Sans" font-size="10.00">lecture : list_vaults, list_directory,</text>
|
||||
<text text-anchor="middle" x="1839" y="-916.5" font-family="DejaVu Sans" font-size="10.00">list_all_files, read_file, read_file_raw,</text>
|
||||
<text text-anchor="middle" x="1839" y="-905.5" font-family="DejaVu Sans" font-size="10.00">search_fulltext, search_advanced,</text>
|
||||
<text text-anchor="middle" x="1839" y="-894.5" font-family="DejaVu Sans" font-size="10.00">search_paths, get_graph, get_backlinks,</text>
|
||||
<text text-anchor="middle" x="1839" y="-883.5" font-family="DejaVu Sans" font-size="10.00">list_tags, suggest_tags, list_recent,</text>
|
||||
<text text-anchor="middle" x="1839" y="-872.5" font-family="DejaVu Sans" font-size="10.00">list_backups, diff_backup</text>
|
||||
<text text-anchor="middle" x="1839" y="-861.5" font-family="DejaVu Sans" font-size="10.00">écriture : create_file, create_directory,</text>
|
||||
<text text-anchor="middle" x="1839" y="-850.5" font-family="DejaVu Sans" font-size="10.00">append_to_file, edit_file, restore_backup</text>
|
||||
<text text-anchor="middle" x="1839" y="-839.5" font-family="DejaVu Sans" font-size="10.00">DANGEROUS : delete_file, delete_directory,</text>
|
||||
<text text-anchor="middle" x="1839" y="-828.5" font-family="DejaVu Sans" font-size="10.00">move_path, rename_file, rename_directory,</text>
|
||||
<text text-anchor="middle" x="1839" y="-817.5" font-family="DejaVu Sans" font-size="10.00">replace_in_files</text>
|
||||
</g>
|
||||
<!-- fam_web -->
|
||||
<g id="node20" class="node">
|
||||
<title>fam_web</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1938.5,-747 1739.5,-747 1739.5,-702 1938.5,-702 1938.5,-747"/>
|
||||
<text text-anchor="middle" x="1839" y="-733" font-family="DejaVu Sans" font-size="10.00">WEB · web.py + crawler.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-722" font-family="DejaVu Sans" font-size="10.00">web_search · fetch_url</text>
|
||||
<text text-anchor="middle" x="1839" y="-711" font-family="DejaVu Sans" font-size="10.00">crawl_site (≤20 pages, même hôte)</text>
|
||||
</g>
|
||||
<!-- fam_vault->fam_web -->
|
||||
<!-- fam_doc -->
|
||||
<g id="node21" class="node">
|
||||
<title>fam_doc</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1922,-666 1756,-666 1756,-621 1922,-621 1922,-666"/>
|
||||
<text text-anchor="middle" x="1839" y="-652" font-family="DejaVu Sans" font-size="10.00">DOCUMENTS · documents.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-641" font-family="DejaVu Sans" font-size="10.00">create_pdf · create_docx</text>
|
||||
<text text-anchor="middle" x="1839" y="-630" font-family="DejaVu Sans" font-size="10.00">create_xlsx · create_csv</text>
|
||||
</g>
|
||||
<!-- fam_web->fam_doc -->
|
||||
<!-- fam_xlsx -->
|
||||
<g id="node22" class="node">
|
||||
<title>fam_xlsx</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1944.5,-580 1733.5,-580 1733.5,-513 1944.5,-513 1944.5,-580"/>
|
||||
<text text-anchor="middle" x="1839" y="-566" font-family="DejaVu Sans" font-size="10.00">TABLEURS · spreadsheets.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-555" font-family="DejaVu Sans" font-size="10.00">list_xlsx_sheets · xlsx_to_markdown</text>
|
||||
<text text-anchor="middle" x="1839" y="-544" font-family="DejaVu Sans" font-size="10.00">search_workbook · analyze_range</text>
|
||||
<text text-anchor="middle" x="1839" y="-533" font-family="DejaVu Sans" font-size="10.00">update_xlsx_cells · append_xlsx_rows</text>
|
||||
<text text-anchor="middle" x="1839" y="-522" font-family="DejaVu Sans" font-size="10.00">edit_xlsx_structure</text>
|
||||
</g>
|
||||
<!-- fam_doc->fam_xlsx -->
|
||||
<!-- fam_dup -->
|
||||
<g id="node23" class="node">
|
||||
<title>fam_dup</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1929.5,-437 1748.5,-437 1748.5,-392 1929.5,-392 1929.5,-437"/>
|
||||
<text text-anchor="middle" x="1839" y="-423" font-family="DejaVu Sans" font-size="10.00">DOUBLONS · duplicates.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-412" font-family="DejaVu Sans" font-size="10.00">find_duplicates</text>
|
||||
<text text-anchor="middle" x="1839" y="-401" font-family="DejaVu Sans" font-size="10.00">merge_duplicate_notes (DANG.)</text>
|
||||
</g>
|
||||
<!-- fam_xlsx->fam_dup -->
|
||||
<!-- fam_git -->
|
||||
<g id="node24" class="node">
|
||||
<title>fam_git</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1931,-309.5 1747,-309.5 1747,-264.5 1931,-264.5 1931,-309.5"/>
|
||||
<text text-anchor="middle" x="1839" y="-295.5" font-family="DejaVu Sans" font-size="10.00">GIT CONNECTÉ · connected.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-284.5" font-family="DejaVu Sans" font-size="10.00">git_list_repos · git_get_file</text>
|
||||
<text text-anchor="middle" x="1839" y="-273.5" font-family="DejaVu Sans" font-size="10.00">git_search_issues (Gitea/GitHub)</text>
|
||||
</g>
|
||||
<!-- fam_dup->fam_git -->
|
||||
<!-- fam_sched -->
|
||||
<g id="node25" class="node">
|
||||
<title>fam_sched</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1942.5,-153.5 1735.5,-153.5 1735.5,-117.5 1942.5,-117.5 1942.5,-153.5"/>
|
||||
<text text-anchor="middle" x="1839" y="-138.5" font-family="DejaVu Sans" font-size="10.00">SCHEDULER · scheduled.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-127.5" font-family="DejaVu Sans" font-size="10.00">list/create/delete/run_scheduled_task</text>
|
||||
</g>
|
||||
<!-- fam_git->fam_sched -->
|
||||
<!-- fam_notify -->
|
||||
<g id="node26" class="node">
|
||||
<title>fam_notify</title>
|
||||
<polygon fill="#f0f9ff" stroke="#333333" points="1917.5,-61 1760.5,-61 1760.5,-16 1917.5,-16 1917.5,-61"/>
|
||||
<text text-anchor="middle" x="1839" y="-47" font-family="DejaVu Sans" font-size="10.00">NOTIFICATIONS · notify.py</text>
|
||||
<text text-anchor="middle" x="1839" y="-36" font-family="DejaVu Sans" font-size="10.00">notify_external (Discord,</text>
|
||||
<text text-anchor="middle" x="1839" y="-25" font-family="DejaVu Sans" font-size="10.00">Telegram, SMTP, webhook)</text>
|
||||
</g>
|
||||
<!-- fam_sched->fam_notify -->
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -419,6 +419,24 @@ première modification et téléchargement du fichier d'origine.
|
||||
|
||||
---
|
||||
|
||||
## #157 — Recherche : facette « Extensions » ✅ TERMINÉ
|
||||
|
||||
Le panneau de facettes de la page de résultats de recherche affiche désormais **trois** groupes :
|
||||
**Vaults**, **Tags** et **Extensions**. Le troisième liste les extensions de fichiers présents
|
||||
dans le résultat courant (`.md`, `.xlsx`, `.pdf`, …) avec leur compteur ; un clic ajoute
|
||||
l'opérateur `ext:<type>` à la requête (un `ext:` déjà actif est remplacé, jamais dupliqué).
|
||||
L'ensemble du panneau se replie/rouvre d'un clic sur un bouton discret (chevron), état
|
||||
mémorisé dans `localStorage`.
|
||||
|
||||
| Aspect | Détail |
|
||||
|---|---|
|
||||
| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence · **`backend/schemas.py::SearchFacets`** expose le champ (sinon le `response_model` le supprimait — corrigé après livraison de 2.46.0) |
|
||||
| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) ; panneau repliable via `.search-facets__toggle` + `data-collapsed` (état `obsigate_facets_collapsed`) |
|
||||
| i18n / aide | `help.desc_facet_extensions`, `search.facets_collapse`/`_expand` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` |
|
||||
| Tests | `tests/test_search.py::test_facets` (route réelle `/api/search/advanced`, assertion stricte `facets.extensions`), `tests/frontend/search-facets.test.mjs` (5 tests JSDOM, ajoutés au CI), e2e `obsigate.spec.ts` (facette visible + repli/rouverture) |
|
||||
|
||||
---
|
||||
|
||||
## Grosses fonctionnalités — fiches dédiées
|
||||
|
||||
| # | Feature | Version | Fiche |
|
||||
|
||||
@@ -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 @@
|
||||
# #158 — Navigation : vue répertoire en onglet, filtres & tris, retour Home complet
|
||||
|
||||
> **Statut :** livré le 2026-10-02 · **ID :** `#158` (correctif associé : `BUG-100`)
|
||||
> **Fichiers :** `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`,
|
||||
> `frontend/js/navfacets.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/legacy.js`,
|
||||
> `backend/services/vaults.py`, `backend/schemas.py`, `frontend/style.css`,
|
||||
> `frontend/locales/{fr,en}.json`
|
||||
|
||||
## Problème
|
||||
|
||||
La vue « chemin + Récents + fichiers du répertoire » (vault home) ne s'affichait qu'en mode
|
||||
focus vault, disparaissait dès qu'un fichier était ouvert (aucun onglet), ne listait pas les
|
||||
sous-répertoires, n'avait ni filtre ni tri, et le clic sur le titre ne rendait pas la vraie
|
||||
page d'accueil.
|
||||
|
||||
## Conception
|
||||
|
||||
**Un onglet de navigation par vault.** `TabManager.openNav(vault, dir)` (`frontend/js/ui.js`)
|
||||
crée/rétarget l'onglet `nav::<vault>` (icône dossier) ; `TabManager.activate()` branche sur
|
||||
`cache.nav` et rend `showVaultHome(vault, dir)` au lieu de fetcher un fichier. Les onglets de
|
||||
fichiers ne sont jamais fermés. Le dashboard appelle `TabManager.deactivate()` : aucun onglet
|
||||
ne reste « actif », donc un clic suivant ré-éffectue réellement l'onglet (avant : early-return
|
||||
et vue figée).
|
||||
|
||||
Points d'entrée des clics (`frontend/js/sidebar.js`) : clic **répertoire** dans l'arbre (2 sites,
|
||||
mode focus et mode All), clic sur la **racine d'un vault** (2 sites, expansion + navigation),
|
||||
changement de contexte vault (sélecteur), facettes/filtres et fil d'Ariane de la vue
|
||||
(`vaulthome.js` route tout par `openNav`, plus aucune délégation sur l'arbre).
|
||||
|
||||
**Menus contextuels** : les répertoires et les fichiers de la vue appellent le même
|
||||
`ContextMenuManager.show()` que la sidebar, avec `stopPropagation()` — le handler global de
|
||||
`ContextMenuManager` referme le menu sur tout clic droit hors `.tree-item`.
|
||||
|
||||
**Onglet Accueil** (`ui.js::openHome` + `pane-manager.js::openHome`) : le clic sur le titre
|
||||
crée (ou remonte en position 0) l'onglet `home` — icône Maison, libellé i18n `common.home` —
|
||||
et l'active ; `activate()` rend la page d'accueil via `showWelcome()` et `deactivate()`
|
||||
conserve l'onglet Accueil actif (il *est* la page d'accueil). En mode split, l'onglet vit dans
|
||||
le panneau actif (délégation `getActiveTabManager()`, libellé transmis par l'appelant pour ne
|
||||
pas d'importer `t` dans `pane-manager.js`, qui déclare déjà des variables locales `t`).
|
||||
|
||||
**Contenu de la vue** (`vaulthome.js`) : fil d'Ariane · barre **Pertinence / Date** + **Sauver**
|
||||
· panneau de facettes · **Répertoires** cliquables (`GET /api/browse`) · Récents (racine) ·
|
||||
fichiers (`GET /api/vault/{v}/files?recursive=false`). Les facettes réutilisent les classes de
|
||||
la page de recherche (`search-facets`, `search-sort`, `search-save-btn`) — même visuel, même
|
||||
repli `obsigate_facets_collapsed`, filtrage **local** (pas de nouvelle requête).
|
||||
|
||||
**Facettes** (`frontend/js/navfacets.js`, pur, sans DOM) : `buildNavFacets`,
|
||||
`filterNavFiles`, `sortNavFiles`. Le listing embarque les tags indexés :
|
||||
`GET /api/vault/{v}/files` renvoie `tags` (`_indexed_tags()` dans
|
||||
`backend/services/vaults.py`, lookup `vault::path` dans l'index inversé, `[]` si non indexé).
|
||||
|
||||
## Décisions / limites connues
|
||||
|
||||
- **Un seul onglet de navigation par vault** : cliquer un autre répertoire re-cible le même
|
||||
onglet (pas d'empilement d'onglets). En mode split, la vue s'affiche dans la zone principale.
|
||||
- **« Pertinence » = tri alphabétique** (`ponytail:` commenté) : un listing de répertoire n'a
|
||||
aucun signal de pertinence tant que la vue n'a pas sa propre requête — voir
|
||||
`navfacets.js::sortNavFiles`.
|
||||
- Facettes calculées sur la liste courante (répertoire non récursif, 200 fichiers max) : les
|
||||
sous-répertoires non listés n'apportent aucune compte.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_nav_files.py` (3) : `tags` exposés + survivent au `response_model`, tags en
|
||||
sous-répertoire, miss d'index → `[]`.
|
||||
- `tests/frontend/navfacets.test.mjs` (10) : comptages, filtres tag/ext (insensible au point),
|
||||
combinaison ET, tris, non-mutation.
|
||||
- `tests/frontend/unit.test.mjs` : `navfacets.js` ajouté à la liste des modules purs.
|
||||
- `tests/e2e/nav-tab.spec.js` (4) : racine vault → page de navigation, icône + menus
|
||||
contextuels identiques à la sidebar, onglet Accueil en tête de barre (avec `#quick-help`).
|
||||
- Vérification manuelle Playwright (jetable) : 22/22 puis 16/16 checks sur les lots A1-A3 et
|
||||
A4-A6.
|
||||
@@ -0,0 +1,72 @@
|
||||
# #155 — Ergonomie tableur : menu contextuel, sélection type Excel
|
||||
|
||||
> **Item de roadmap :** [#155 — Ergonomie tableur (menu contextuel + sélection)](../ROADMAP.md)
|
||||
> **Origine :** retours utilisateur après #154 (refonte UI/UX tableur)
|
||||
> **Statut :** ✅ **livré** — 2026-09-29
|
||||
> **Effort estimé :** 2-4 jours (réalisé en 1 lot)
|
||||
|
||||
---
|
||||
|
||||
## 1. Demande
|
||||
|
||||
1. **Menu contextuel** au clic droit (et **appui long** sur mobile/tactile) dans la grille, sur une
|
||||
cellule, un numéro de ligne ou un en-tête de colonne.
|
||||
2. **Comportement du curseur / de la sélection comme Excel** : le curseur en forme de croix
|
||||
au-dessus des cellules, sélection d'une **plage** (glisser, `Maj+clic`, `Maj+flèches`), clic sur
|
||||
un en-tête de ligne/colonne pour sélectionner toute la ligne/colonne.
|
||||
3. (BUG-094, traité séparément) une **feuille vide/nouvelle** doit rester éditable et permettre
|
||||
d'ajouter lignes/colonnes.
|
||||
|
||||
## 2. Conception
|
||||
|
||||
### Menu contextuel
|
||||
- Positionné au **pointeur** (souris) ou au point de contact (tactile), borné au viewport.
|
||||
- Thème de l'application (classes `.xlsx-context-menu`, réutilise `.btn-action`).
|
||||
- Contenu selon la cible et le mode :
|
||||
- **Insérer** : ligne au-dessus / en dessous, colonne à gauche / à droite (sur la plage
|
||||
sélectionnée, via `PUT …/xlsx/structure`, `count` = taille de la plage).
|
||||
- **Supprimer** : ligne(s) / colonne(s) — confirmation modale (destructif).
|
||||
- **Trier** : A→Z / Z→A sur la colonne de gauche de la sélection.
|
||||
- **Effacer le contenu** : vide les cellules sélectionnées (modification locale, non
|
||||
sauvegardée tant que l'utilisateur n'enregistre pas).
|
||||
- Masqué pour les formats en lecture seule (`.xls`/`.ods`) ; sans les actions de structure pour un
|
||||
`.csv`.
|
||||
- **Appui long** (tactile) : minuteur ~500 ms annulé au déplacement / relâchement.
|
||||
|
||||
### Sélection type Excel
|
||||
- **Clic** : cellule active + sélection simple ; la barre de formule (zone Nom) affiche la plage
|
||||
(`A1` ou `A1:B3`).
|
||||
- **Glisser** : sélection rectangulaire (`mousedown` → `mouseover` sur une autre cellule →
|
||||
`mouseup`) ; le texte de la cellule n'est plus sélectionné pendant le glisser.
|
||||
- **`Maj+clic`** et **`Maj+flèches`** : étendent la sélection depuis l'ancre.
|
||||
- **En-têtes** : clic sur un numéro de ligne / lettre de colonne sélectionne la ligne/colonne
|
||||
entière. Le tri n'est plus déclenché par simple clic sur l'en-tête (il passe au menu contextuel),
|
||||
conformément au comportement Excel.
|
||||
- **Curseur** : `cursor: cell` (croix) sur les cellules, `pointer` sur les en-têtes.
|
||||
|
||||
## 3. Fichiers
|
||||
|
||||
| Couche | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Menu | `frontend/js/xlsx/context-menu.js` (nouveau) | Menu contextuel générique thémé (build + position + dismiss) |
|
||||
| Sélection + câblage | `frontend/js/viewer.js` | État de sélection, drag, `Maj`, en-têtes, ouverture du menu, actions |
|
||||
| Styles | `frontend/style.css` | `.xlsx-selected`, `.xlsx-context-menu`, curseurs |
|
||||
| i18n | `frontend/locales/{fr,en}.json` | Libellés du menu |
|
||||
| Tests | `tests/frontend/xlsx-viewer.test.mjs` | Sélection, menu contextuel, tri via menu |
|
||||
|
||||
## 4. Critères d'acceptation
|
||||
|
||||
- Clic droit sur une cellule ouvre un menu thémé aux coordonnées du pointeur ; `Échap` ou clic
|
||||
ailleurs le ferme.
|
||||
- Insérer/supprimer ligne/colonne via le menu envoie la bonne action `PUT …/xlsx/structure`
|
||||
(avec `count` pour une plage) puis re-rend.
|
||||
- Glisser de A1 vers B2 surligne A1:B2 et la zone Nom affiche `A1:B2`.
|
||||
- `Maj+clic` étend la sélection ; cliquer un en-tête sélectionne la ligne/colonne entière.
|
||||
- Aucune régression des tests #154 (édition, save, undo/redo, dashboard).
|
||||
|
||||
## 5. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Ouverture #155 (menu contextuel + sélection type Excel) suite aux retours utilisateur |
|
||||
| 2026-09-29 | **Livré** : menu contextuel thémé (`frontend/js/xlsx/context-menu.js`) ouvert au clic droit et à l'appui long, actions résultat de `PUT …/xlsx/structure` (insérer/supprimer ligne & colonne avec `count`), tri et effacement ; sélection type Excel (glisser, `Maj+clic`, `Maj+flèches`, en-têtes ligne/colonne, curseur croix, zone Nom affichant la plage) |
|
||||
@@ -0,0 +1,322 @@
|
||||
# #156 — Éditeur Excel : complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
> **Item de roadmap :** [#156 — Éditeur Excel — complétude fonctionnelle](../ROADMAP.md)
|
||||
> **Origine :** audit de complétude demandé le 2026-09-29, après #153 (backlog tableur),
|
||||
> #154 (refonte UI/UX) et #155 (menu contextuel & sélection)
|
||||
> **Statut :** ✅ **livré le 2026-09-30** — P0 ✅ (BUG-096 → BUG-099), P1 ✅ (A5, A6, A7),
|
||||
> P2 ✅ (A8, A9, A10) et P3 ✅ (A11, A12, A13, A14) ; ouverture 2026-09-29 (audit statique,
|
||||
> A1 reproduit en JSDOM)
|
||||
> **Effort estimé :** 15-22 jours (P0 2-3 j · P1 4-6 j · P2 5-7 j · P3 4-6 j) — **réalisé dans
|
||||
> l'enveloppe annoncée**
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, les défauts, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
#153 a rendu l'éditeur XLSX **correct sur le périmètre « grille de valeurs »** (lecture/écriture
|
||||
gardées, styles en lecture, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation, outils IA,
|
||||
tableau de bord). #154/#155 l'ont rendu **convivial** (ruban, inspecteur, undo/redo des cellules,
|
||||
sélection et menu contextuel type Excel).
|
||||
|
||||
Reste l'écart avec ce qu'un utilisateur attend d'un **éditeur tableur** : coller une plage,
|
||||
penser la mise en forme, calculer, sortir le résultat, et se protéger d'un autre écrivain.
|
||||
Cet item recense **4 défauts** (A1 reproduit, A4 théorique) et les **manques fonctionnels**
|
||||
correspondants.
|
||||
|
||||
## 2. Périmètre (couches touchées)
|
||||
|
||||
| Couche | Fichier | Rôle dans cet item |
|
||||
|---|---|---|
|
||||
| Lecture XLSX | `backend/xlsx_reader.py` | Fenêtres de lignes, délimiteur CSV, sonde de perte, métadonnées |
|
||||
| Écriture XLSX | `backend/services/mutations.py` | Cellules, structure, **style (A8)**, coercion, `revision` (A12) |
|
||||
| Endpoints | `backend/routers/files_read.py`, `files_write.py` | `xlsx/sheet`, `xlsx/save`, `xlsx/structure`, **`xlsx/style` (A8)**, `csv/save` |
|
||||
| Outils IA | `backend/tools/spreadsheets.py` | Lecture/édition par l'assistant (`.xlsx`/`.xlsm`/`.csv`, recherche, analyse, structure — A14) |
|
||||
| Visionneuse | `frontend/js/viewer.js` + `frontend/js/xlsx/*` | Presse-papiers, clavier, barre de formule, undo/redo, export |
|
||||
| Styles | `frontend/style.css` | Affordances presse-papiers/mise en forme (variables CSS) |
|
||||
| i18n | `frontend/locales/{fr,en}.json` | Tout libellé nouveau, FR **et** EN |
|
||||
| Tests | `tests/test_xlsx_*.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js` | Non-régression par sous-tâche |
|
||||
|
||||
## 3. Audit
|
||||
|
||||
### 3.1 Défauts recensés (P0) — ✅ corrigés le 2026-09-29
|
||||
|
||||
#### ✅ A1 → BUG-096 — Enregistrer un `.csv` depuis la visionneuse échoue
|
||||
|
||||
`frontend/js/viewer.js` construit l'unique job de sauvegarde ainsi :
|
||||
|
||||
```js
|
||||
const jobs = panelEls.map((panel) => {
|
||||
const cells = {};
|
||||
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
|
||||
return { sheet: sheets[Number(panel.dataset.sheet)].name, cells }; // ← ligne 1757
|
||||
}).filter((job) => Object.keys(job.cells).length);
|
||||
```
|
||||
|
||||
En mode CSV, la réponse de lecture (`backend/routers/files_read.py:548-556`) **ne contient pas
|
||||
`xlsx_sheets`** : `sheets` est donc vide et `sheets[...].name` lève un `TypeError`. Vérifié le
|
||||
2026-09-29 avec le harnais JSDOM (`tests/frontend/xlsx-viewer.test.mjs`) : après édition d'une
|
||||
cellule CSV et clic sur **Enregistrer**, `TypeError: Cannot read properties of undefined
|
||||
(reading 'name')` et **aucun** `PUT /api/file/{vault}/csv/save` n'est émis. Le service backend
|
||||
`save_csv_cells()` et l'endpoint sont pourtant corrects : le défaut est purement côté visionneuse.
|
||||
|
||||
*Cause racine :* le mode CSV réutilise un panneau unique (`data-sheet="0"`) sans feuille
|
||||
correspondante dans `sheets` ; le chemin d'enregistrement n'a pas été adapté comme l'ont été les
|
||||
onglets, la structure et le tableau de bord (qui, eux, testent `isCsv`).
|
||||
|
||||
**Correctif (2026-09-29) :** le nom de feuille est résolu avec repli
|
||||
(`sheets[idx]?.name || sheets[0]?.name || data.title || data.path`). Test JSDOM : un CSV monté
|
||||
**sans** `xlsx_sheets` émet un unique `PUT …/csv/save`.
|
||||
|
||||
#### ✅ A2 → BUG-097 — `.xlsm` éditable : « Charger la suite » renvoie 415 au-delà de 500 lignes
|
||||
|
||||
Un `.xlsm` est servi **éditable** (`backend/routers/files_read.py:491-512`, pas de
|
||||
`xlsx_readonly`) : la visionneuse câble donc le chargement paresseux. Mais
|
||||
`GET /api/file/{vault}/xlsx/sheet` refuse **tout ce qui n'est pas `.xlsx`** (`files_read.py:272`,
|
||||
**415**). Une feuille `.xlsm` de plus de 500 lignes affiche donc le pied « Charger la suite » dont
|
||||
chaque clic échoue (toast d'erreur), alors que la limitation n'est pas annoncée comme telle pour
|
||||
ce format.
|
||||
|
||||
**Correctif (2026-09-29) :** `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (les autres
|
||||
formats restent en 415). Tests : fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`.
|
||||
|
||||
#### ✅ A3 → BUG-098 — Délimiteur CSV figé : un `.csv` français s'ouvre en une seule colonne
|
||||
|
||||
`render_csv_table(raw)` utilise le délimiteur par défaut `,` (`backend/xlsx_reader.py:784`),
|
||||
appelé sans argument (`backend/routers/files_read.py:551`) — tandis que l'**export** CSV de la
|
||||
visionneuse écrit du `;` et un BOM UTF-8. Un CSV produit par Excel/Sheets en locale française
|
||||
(`;`) s'affiche donc en une colonne. Le même `,` est utilisé par `save_csv_cells()` pour re-parser
|
||||
et réécrire le fichier : l'édition peut re-sérialiser dans un format différent de la source.
|
||||
|
||||
**Correctif (2026-09-29) :** `sniff_csv_delimiter()` (Sniffer, repli par décompte sur la
|
||||
première ligne, puis `,`) est partagé par le rendu et la réécriture — `save_csv_cells()`
|
||||
réutilise le délimiteur détecté. Tests : lecture, écriture et champs entre guillemets contenant
|
||||
le séparateur.
|
||||
|
||||
#### ✅ A4 → BUG-099 — Sonde de perte plafonnée à 8 Mo : perte silencieuse possible
|
||||
|
||||
`inspect_workbook()` détecte les valeurs calculées en cache via un budget global
|
||||
`_MAX_PROBE_BYTES = 8_000_000` (`backend/xlsx_reader.py:69`) partagé entre toutes les feuilles
|
||||
(`_has_cached_formulas`). Sur un classeur dont le XML des feuilles dépasse ce budget **avant** la
|
||||
première formule cachée, `xlsx_lossy_features` ne contient pas `cached_values` : le `409`
|
||||
`xlsx_lossy_content` n'est pas déclenché et l'enregistrement détruit ces valeurs **sans
|
||||
avertissement** — exactement le risque n°1 de #153. À ce stade, risque **théorique** (non
|
||||
reproduit).
|
||||
|
||||
**Correctif (2026-09-29) :** budget **par feuille** (4 Mo) + plafond global (32 Mo) ;
|
||||
`_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute
|
||||
`cached_values_unverified` (libellé i18n FR/EN) : la sauvegarde demande une confirmation au lieu
|
||||
de passer en silence. Trucs de test : budgets monkeypatchés. Contre-preuve : budget épuisé →
|
||||
signalé au lieu de `[]` (comportement antérieur).
|
||||
|
||||
### 3.2 Fonctions manquantes (écart vs Excel / Google Sheets)
|
||||
|
||||
**Presse-papiers et sélection** — ✅ traité en P1 (A5)
|
||||
|
||||
- ✅ Coller une **plage** : le handler forcait tout sur une ligne
|
||||
(`viewer.js` : `replace(/\r?\n/g, " ")`) — coller un bloc TSV/CSV depuis Excel écrasait une seule
|
||||
cellule. Le collage découpe désormais le bloc (`parseMatrix`) et remplit la plage à partir de la
|
||||
cellule active (`fillFrom`), 1×1 restant un remplacement de cellule.
|
||||
- ✅ Copier / Couper / Coller de plage côté application : `Ctrl+C`/`Ctrl+X`/`Ctrl+V` (événements
|
||||
`copy`/`cut`/`paste`) **et** entrées du menu contextuel ; une sélection de texte dans la cellule
|
||||
éditée garde le comportement natif.
|
||||
- Toujours absents : poignée de recopie (fill), multi-sélection `Ctrl+clic`, glisser-déposer de
|
||||
lignes/colonnes, copie d'une image/du HTML.
|
||||
|
||||
**Clavier / navigation** — ✅ traité en P1 (A6, A7)
|
||||
|
||||
- ✅ `Ctrl+S`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Ctrl+A`
|
||||
sont câblés dans un handler unique de la grille ; une cellule reste en mode « navigation »
|
||||
jusqu'à la saisie (ou `F2`), sinon `Suppr`/`Home`/`End` voleraient une touche du curseur.
|
||||
- ✅ `Maj+Entrée` insère un saut de ligne **dans** la cellule (texte uniquement).
|
||||
- ✅ La **zone Nom** est un champ éditable (« Atteindre ») : `B12`, `A1:B3`, `Feuille!A1`.
|
||||
- ✅ Aide à la saisie : la barre de formule propose les noms de fonctions courants (`<datalist>`,
|
||||
liste localisée FR/EN). L'autocomplétion contextuelle reste à faire si un moteur de formules
|
||||
arrive (A9).
|
||||
|
||||
**Mise en forme en écriture** — ✅ traitée (A8, 2026-09-30)
|
||||
|
||||
- ✅ Un bouton **Mise en forme** (`#xlsx-format-btn`, classeurs éditables non-CSV) agit sur la
|
||||
**sélection** via `PUT …/xlsx/style` : gras / italique / souligné / effacer, alignement
|
||||
gauche-centré-droite, couleur de police et de fond (`<input type="color">`, donc aucune
|
||||
palette codée en dur), formats de nombre (général, nombre, pourcentage, devise, date, texte),
|
||||
fusion/défusion, volets figés/libérés, largeur de colonne et hauteur de ligne. La fusion exige
|
||||
une vraie plage (`xlsx.format_merge_needs_range`).
|
||||
- Écriture sur le modèle de `xlsx/save` / `xlsx/structure` : verrou par fichier, backup, écriture
|
||||
atomique (`.tmp` + `os.replace`), garde de perte (refus sans `force`), `If-Match` (A12), garde
|
||||
anti-formule de BUG-088, plafond `MAX_STYLE_CELLS = 10 000`, invalidation du cache méta (A13).
|
||||
- Toujours **hors périmètre** : commentaires, liens hypertexte, validation de données, mise en
|
||||
forme conditionnelle, protection de cellules (lecture seule), bordures et palette Excel.
|
||||
|
||||
**Calcul** — ✅ décision documentée (A9, 2026-09-30)
|
||||
|
||||
- Aucun moteur de formule n'est livré : ObsiGate lit et écrit les formules telles qu'Excel les a
|
||||
enregistrées, sans recalcul (pas de références inter-feuilles, pas d'agrégats). Le choix est
|
||||
**annoncé à l'utilisateur** : un enregistrement contenant une valeur commençant par `=` ou `@`
|
||||
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé ; la garde
|
||||
anti-DDE de BUG-088 reste en place.
|
||||
|
||||
**Sortie et recherche** — ✅ traité (A11, 2026-09-30)
|
||||
|
||||
- ✅ Export de la **sélection** (CSV, nom `Feuille-A1B2.csv`) quand une plage de plusieurs cellules
|
||||
est active, sinon la feuille entière ; l'écart doc/code de #153-A13 est tranché dans ce sens.
|
||||
- ✅ Menu **Exporter** : Markdown (tableau), HTML (document autonome) et **impression** (iframe
|
||||
hors écran, cadre précédent détruit). Les quatre formats suivent le **contenu affiché**.
|
||||
- ✅ La recherche (`Ctrl+F`) parcourt **toutes les feuilles** : compteur `n/m · k feuilles` et
|
||||
activation automatique de l'onglet contenant la correspondance.
|
||||
|
||||
**Robustesse / multi-postes** — ✅ traitée (A10, A12, 2026-09-30)
|
||||
|
||||
- ✅ Concurrence optimiste : `revision` (`mtime_ns` + taille) renvoyée à la lecture et acceptée en
|
||||
`If-Match` sur `xlsx/save`, `xlsx/structure`, `xlsx/style` et `csv/save` ; un écrivain externe
|
||||
détecté donne un **409** `conflict` (`details.reason = "stale_revision"`) au lieu d'un
|
||||
écrasement, et le bandeau « Réessayer » **relit** le fichier avant de rejouer.
|
||||
- ✅ Piles undo/redo **unifiées et conservées au re-rendu** (`_xlsxHistory` par fichier) : édition,
|
||||
effacement, tri/filtre et structure ; action destructive (suppression) non annulable faute
|
||||
d'inverse, et lecture du filtre **coalescée** (une entrée par session de frappe).
|
||||
- Le tri/filtre restent **d'affichage** : après un tri, un enregistrement ne persiste pas l'ordre
|
||||
(seules les cellules sales sont écrites) — le libellé `xlsx.sort_applied` le dit.
|
||||
|
||||
**Outils IA** — ✅ traités (A14, 2026-09-30)
|
||||
|
||||
- ✅ `_spreadsheet_path()` accepte `.xlsx`, `.xlsm` (macros préservées) et `.csv` (délimiteur
|
||||
conservé) ; `update_xlsx_cells` délègue à `save_csv_cells` pour un CSV.
|
||||
- ✅ Nouveaux outils : `search_workbook` (recherche multi-feuilles, comptée par feuille),
|
||||
`analyze_range` (nombre, somme, moyenne, min, max d'une plage A1), `edit_xlsx_structure`
|
||||
(feuilles, lignes, colonnes — risque WRITE, **confirmation conservée**).
|
||||
- Bornes explicites : `MAX_SCAN_ROWS/MAX_SCAN_COLS`, `MAX_SEARCH_RESULTS = 100`,
|
||||
`MAX_RANGE_VALUES = 200`, `MAX_RANGE_CELLS = 10 000`.
|
||||
|
||||
### 3.3 Limites techniques relevées
|
||||
|
||||
- `MAX_COLS = 40` sans pagination de colonnes : le chargement paresseux ne concerne que les
|
||||
lignes (`read_sheet_window`), l'axe des colonnes reste tronqué.
|
||||
- Coût de lecture : `render_sheets()` charge le classeur pour les formules, une seconde fois si
|
||||
des valeurs cachées existent, puis `read_workbook_meta()` (troisième chargement, scan
|
||||
`500 × 40` par feuille). ✅ **A13** : `read_workbook_meta()` est mémoïsé par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8) et invalidé à chaque écriture — le reload complet à chaque
|
||||
fenêtre demandée a disparu ; `render_sheets()` reste hors cache (il doit relire les valeurs).
|
||||
- `allow_formula` (bouton `f(x)`) reste actif pour toute la session une fois activé : rien ne le
|
||||
remet à zéro après un enregistrement.
|
||||
- `role="grid"` posé (#154-A4) mais sans `aria-rowcount`/`aria-colcount`/`aria-selected`/
|
||||
`aria-activedescendant` ni annonce des changements de cellule active.
|
||||
|
||||
## 4. Backlog #156 — sous-tâches
|
||||
|
||||
Légende : 🔴 P0 (défaut) · 🟡 P1 (productivité immédiate) · 🟢 P2 (fidélité/finitions) ·
|
||||
effort en jours-homme (développement + tests).
|
||||
|
||||
### P0 — défauts (2-3 j)
|
||||
|
||||
- [x] **A1 — Sauvegarde `.csv` depuis la visionneuse (BUG-096).** Nom de feuille résolu avec
|
||||
repli (feuille → `data.title`/`data.path`), le chemin d'enregistrement ne dépend plus de la
|
||||
structure du classeur. *Vérifié :* test JSDOM « saving an edited csv PUTs /csv/save
|
||||
(payload without xlsx_sheets) » (contre-preuve : échouait avec le `TypeError`).
|
||||
- [x] **A2 — Chargement paresseux des `.xlsm` (BUG-097).** `GET …/xlsx/sheet` accepte `.xlsx`
|
||||
**et** `.xlsm` ; les autres formats restent refusés (415). *Vérifié :*
|
||||
`test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats`.
|
||||
- [x] **A3 — Détection du délimiteur CSV (BUG-098).** `sniff_csv_delimiter()` partagé par la
|
||||
lecture et la réécriture, qui conserve le délimiteur détecté. *Vérifié :* `TestCsvDelimiter`
|
||||
(3 séparateurs + repli) et 3 tests client (lecture, écriture, guillemets).
|
||||
- [x] **A4 — Sonde de perte par feuille (BUG-099).** Budget par feuille + plafond global ;
|
||||
budget épuisé → clé `cached_values_unverified` (i18n FR/EN) au lieu d'un silence. *Vérifié :*
|
||||
`TestXlsxCachedValueProbe` (3), dont la contre-preuve du silence antérieur.
|
||||
|
||||
### P1 — presse-papiers & clavier (4-6 j)- [x] **A5 — Presse-papiers de plage (livré le 2026-09-29).** Copier / couper / coller un bloc
|
||||
(presse-papiers interne TSV, miroir système best-effort) via `Ctrl+C`/`Ctrl+X`/`Ctrl+V` et les
|
||||
entrées du menu contextuel ; remplissage multi-cellules depuis la cellule active, bloc laissé
|
||||
sélectionné, collage **texte** uniquement (R4) et annulable cellule par cellule ; un bloc plus
|
||||
grand que la grille rendue est tronqué et signalé (`xlsx.paste_out_of_grid`). Couper efface la
|
||||
source au collage, sauf en collage sur place.
|
||||
*Vérifié :* JSDOM — `A1:B2` copié puis collé en `D5` donne `D5:E6`, bloc TSV Excel + retour
|
||||
ligne final, 1×1 qui remplace la cellule, coupe → collage, menu contextuel, débordement
|
||||
signalé, `E2E` (Playwright : copie/collage par le menu contextuel sur la fixture 520 lignes).
|
||||
- [x] **A6 — Clavier complet (livré le 2026-09-29).** `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`,
|
||||
`Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` ; undo/redo déplacés
|
||||
dans le même handler priorisé et `Suppr` passe par `clearRange()` (donc **annulable**).
|
||||
*Vérifié :* JSDOM — un test par raccourci, dont la contre-épreuve « `Suppr` pendant la saisie
|
||||
supprime un caractère, pas la sélection » ; `E2E` (Ctrl+A, Ctrl+Home/End, Suppr).
|
||||
- [x] **A7 — Zone Nom éditable & aide à la saisie (livré le 2026-09-29).** La zone Nom est un
|
||||
`<input>` (`#xlsx-active-cell`) : `B12`, `A1:B3`, `$A$1`, `Feuille!A1` (changement d'onglet) ;
|
||||
référence inconnue → message `xlsx.name_box_invalid` et adresse précédente restaurée. Le champ
|
||||
de formule porte la liste des fonctions (`<datalist id="xlsx-function-list">`, `xlsx.fn_suggestions`).
|
||||
*Vérifié :* JSDOM (atteindre, plage normalisée, refus d'une référence inconnue, bascule
|
||||
d'onglet, liste de fonctions) ; `E2E` (zone Nom → `B12`).
|
||||
|
||||
### P2 — mise en forme, calcul, undo (5-7 j)
|
||||
|
||||
- [x] **A8 — Mise en forme en écriture (livré le 2026-09-30).** Bouton **Mise en forme** :
|
||||
gras/italique/souligné/effacer, alignements, couleurs de police et de fond (sélecteur natif),
|
||||
formats de nombre (général/nombre/pourcentage/devise/date/texte), fusion/défusion, volets
|
||||
figés/libérés, largeur de colonne et hauteur de ligne, sur la sélection courante. Nouvelle route
|
||||
`PUT …/xlsx/style` (`mutate_xlsx_style`) sur le modèle de `PUT …/xlsx/structure` : verrou,
|
||||
`.tmp`/`os.replace`, backup, garde de perte (`force`), `If-Match`, garde anti-formule BUG-088,
|
||||
plafond `MAX_STYLE_CELLS`. i18n FR/EN. *Vérifié :* `tests/test_xlsx_styles.py` (17 : rechargement,
|
||||
fusions/tailles/volets, effacement, entrées invalides, CSV refusé, `if_match`, backup) et JSDOM
|
||||
(application d'un format, refus d'un CSV, garde de perte).
|
||||
- [x] **A9 — Calcul : décision documentée (livré le 2026-09-30).** Pas de moteur de formule ; le
|
||||
gap est **annoncé dans l'UI** (toast « enregistrée comme texte » à l'enregistrement si une valeur
|
||||
commence par `=` ou `@` sans le bouton `f(x)`), la garde anti-DDE reste. *Vérifié :* JSDOM
|
||||
(l'avertissement apparaît, disparaît une fois `f(x)` activé).
|
||||
- [x] **A10 — Undo/redo unifié (livré le 2026-09-30).** Entrées typées `cell|order|hidden|structure`,
|
||||
piles **par fichier** (`_xlsxHistory`, 8 fichiers) conservées au re-rendu, filtre coalescé,
|
||||
rejeu de structure silencieux, action destructive non annulable. *Vérifié :* JSDOM (édition →
|
||||
effacement → tri annulés successivement, insertion de ligne rejouée, filtre annulé en un `Ctrl+Z`).
|
||||
|
||||
### P3 — sortie, robustesse, performances (4-6 j)
|
||||
|
||||
- [x] **A11 — Sortie & recherche (livré le 2026-09-30).** Bouton **Exporter** : CSV de la
|
||||
**sélection** (sinon de la feuille, nom `Feuille-A1B2.csv`), Markdown, HTML et impression
|
||||
(iframe hors écran) ; écart #153-A13 tranché. `runFind` parcourt **toutes** les feuilles avec
|
||||
compteur `n/m · k feuilles` et activation de l'onglet correspondant. *Vérifié :* JSDOM
|
||||
(sélection multi-cellules, feuille entière, 4 formats, compteur multi-feuilles) et E2E.
|
||||
- [x] **A12 — Concurrence optimiste (livré le 2026-09-30).** `revision` (`mtime_ns` + taille)
|
||||
renvoyée à la lecture (`xlsx_revision`) et acceptée en `If-Match`/`if_match` sur `xlsx/save`,
|
||||
`xlsx/structure`, `xlsx/style` et `csv/save` ; unconflict produit un **409** `conflict`
|
||||
(`details.reason = "stale_revision"`) et le bandeau « Réessayer » relit avant de rejouer.
|
||||
*Vérifié :* `TestOptimisticConcurrency` (6) + JSDOM (version rafraîchie par la réponse, relecture
|
||||
avant le retry).
|
||||
- [x] **A13 — Performances & plafonds (livré le 2026-09-30).** Cache de `read_workbook_meta()` par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8), invalidé à chaque écriture : charger une fenêtre ne
|
||||
rescanne plus le classeur. *Hors périmètre :* la pagination de l'axe des colonnes au-delà de
|
||||
`MAX_COLS = 40` (documenté dans les limites du guide). *Vérifié :* `TestMetaCache` (4).
|
||||
- [x] **A14 — Outils IA étendus (livré le 2026-09-30).** `SPREADSHEET_EXTENSIONS` =
|
||||
`.xlsx`/`.xlsm`/`.csv` (`_spreadsheet_path`, `_read_grid`), `update_xlsx_cells`/`append_xlsx_rows`
|
||||
adaptés, nouveaux outils `search_workbook` (READ), `analyze_range` (READ) et `edit_xlsx_structure`
|
||||
(WRITE, **confirmation conservée**, refus d'un CSV). *Vérifié :*
|
||||
`tests/test_spreadsheet_tools.py` (34, dont `TestXlsmSupport`, `TestCsvSupport`,
|
||||
`TestSearchWorkbook`, `TestAnalyzeRange`, `TestEditXlsxStructure`).
|
||||
|
||||
## 5. Risques et sécurité
|
||||
|
||||
| # | Risque | Où | Traitement |
|
||||
|---|---|---|---|
|
||||
| R1 | Perte silencieuse de valeurs calculées (sonde plafonnée) | `xlsx_reader.inspect_workbook` | ✅ A4 — budget par feuille + clé `cached_values_unverified` (BUG-099) |
|
||||
| R2 | Écrasement par un écrivain externe | `mutations`, routes write | ✅ A12 — `If-Match` + **409** `conflict`, retry qui relit |
|
||||
| R3 | Réécriture CSV dans un délimiteur différent de la source | `xlsx_reader`, `mutations.save_csv_cells` | ✅ A3 — `sniff_csv_delimiter` partagé (BUG-098) |
|
||||
| R4 | Collage d'un bloc : ne jamais injecter de HTML/`contenteditable` brut | `viewer.js` | ✅ A5 — insertion **texte** uniquement, échappée |
|
||||
| R5 | Mise en forme en écriture : garder la garde anti-formule (BUG-088) et la garde de perte (BUG-085) | `mutations` | ✅ A8 — `mutate_xlsx_style` réutilise verrou, backup, swap atomique, garde de perte et `If-Match` |
|
||||
|
||||
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
||||
|
||||
- Chaque sous-tâche démarre par son **ID stable** (`#156-A<n>`) ; un **défaut** est ouvert comme
|
||||
`BUG-NNN` dans `docs/ISSUES_TODOLIST.md` (A1→BUG-096, A2→BUG-097, A3→BUG-098, A4→BUG-099).
|
||||
- Tout correctif arrive avec son **test de non-régression** (contre-preuve quand c'est possible).
|
||||
- Frontend : `vanilla JS`, **zéro build**, `safeCreateIcons()`, **variables CSS**, **i18n FR+EN**
|
||||
(`test_i18n_parity.py` vert).
|
||||
- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans
|
||||
`backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`.
|
||||
- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée + index), cette fiche,
|
||||
guide utilisateur i18n + README si impact utilisateur.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Audit de complétude de l'éditeur Excel → ouverture de #156 et de **BUG-096 → BUG-099** ; défaut A1 reproduit en JSDOM (`TypeError` sur l'enregistrement d'un CSV, aucun `PUT …/csv/save` émis) |
|
||||
| 2026-09-29 | **P1 livré** — A5 (presse-papiers de plage), A6 (clavier complet) et A7 (zone Nom éditable + aide à la saisie) : presse-papiers interne TSV (miroir système best-effort), collage texte/annulable, `Suppr`/`F2`/`Ctrl+flèches`/`PgUp-PgDn`/`Ctrl+A`/`Maj+Entrée`, zone Nom `<input>` et liste de fonctions i18n FR/EN. Tests : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux) + E2E Playwright |
|
||||
| 2026-09-30 | **P2 livré** — A8 (mise en forme en écriture : bouton Mise en forme, `PUT …/xlsx/style`, verrou/backup/garde de perte/`If-Match`), A9 (décision « pas de moteur de formule », annoncée dans l'UI), A10 (undo/redo unifié, piles conservées au re-rendu). Tests : `test_xlsx_styles.py` 17 + JSDOM `xlsx-viewer.test.mjs` |
|
||||
| 2026-09-30 | **P3 livré** — A11 (export sélection / Markdown / HTML / impression, recherche multi-feuilles), A12 (`If-Match` + 409 réparable), A13 (cache des métadonnées par `mtime`), A14 (outils IA `.xlsm`/`.csv`, recherche, analyse de plage, structure). Tests : `test_spreadsheet_tools.py` 34, `test_xlsx_viewer.py` (`TestOptimisticConcurrency`, `TestMetaCache`), JSDOM |
|
||||
| 2026-09-30 | **Backlog #156 clôturé** — P0 → P3 livrés (A1 → A14, dont 4 défauts **BUG-096 → BUG-099**). Suite complète : `pytest tests/` 1525 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 107/107, E2E `xlsx` 10/10 |
|
||||
| 2026-09-29 | **P0 livré** — A1-A4 corrigés et testés (BUG-096 → BUG-099) : sauvegarde `.csv` depuis la visionneuse, fenêtres `.xlsm`, délimiteur CSV détecté/réutilisé, sonde de perte par feuille + signal `cached_values_unverified`. Suite complète : 1490 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 61/61 |
|
||||
@@ -0,0 +1,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).
|
||||
+50
-7
@@ -1196,16 +1196,15 @@
|
||||
>Ctrl+Shift+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de commandes</span
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
<span class="qh-kbd"
|
||||
>Ctrl+Alt+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
<span class="qh-desc">Palette de commandes</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
@@ -1588,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>
|
||||
@@ -1951,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"
|
||||
@@ -3695,6 +3734,10 @@
|
||||
<strong>Facettes vaults</strong><span data-i18n="help.desc_facet_vaults">: Compteurs
|
||||
par vault</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Facettes extensions</strong><span data-i18n="help.desc_facet_extensions">:
|
||||
Compteurs par extension de fichier dans les résultats</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tri par pertinence</strong><span data-i18n="help.desc_9fec424d"> : Score
|
||||
TF-IDF (défaut)</span></li>
|
||||
@@ -4631,9 +4674,9 @@
|
||||
conversation.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_agent">
|
||||
Le bouton « mode agent » active les outils (lire, lister,
|
||||
chercher) ; les actions de modification demandent une
|
||||
confirmation avec aperçu des changements.
|
||||
L'assistant utilise toujours ses outils (lire, lister,
|
||||
chercher, modifier) ; les actions de modification demandent
|
||||
une confirmation avec aperçu des changements.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_agent_run">
|
||||
Les actions s'affichent dans le fil : l'assistant regroupe les
|
||||
|
||||
@@ -46,8 +46,8 @@ export const ACTION_CATALOG = Object.freeze([
|
||||
{ id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' },
|
||||
{ id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' },
|
||||
{ id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' },
|
||||
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true },
|
||||
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true },
|
||||
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt' },
|
||||
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt' },
|
||||
{ id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' },
|
||||
{ id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' },
|
||||
// ── Code & Scripts ───────────────────────────────────────────────────
|
||||
|
||||
+12
-43
@@ -151,9 +151,8 @@ class BooksLM {
|
||||
this._sessions = [];
|
||||
this._currentSessionId = null;
|
||||
this._pendingNewSession = false;
|
||||
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
|
||||
// call read/search tools and propose mutations (confirmation cards).
|
||||
this._agentMode = this._readAgentMode();
|
||||
// #187: the assistant is always in agent mode (tools on /agent endpoint);
|
||||
// the former header toggle was removed — images still use the /chat path.
|
||||
// Ad-hoc context added with `@` (files/directories) and images attached
|
||||
// by paste or by mentioning an image file.
|
||||
this._adhocFiles = [];
|
||||
@@ -188,33 +187,6 @@ class BooksLM {
|
||||
this._extensions = null;
|
||||
}
|
||||
|
||||
_readAgentMode() {
|
||||
try {
|
||||
return localStorage.getItem('obsigate-bookslm-agent') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_toggleAgentMode() {
|
||||
this._agentMode = !this._agentMode;
|
||||
try {
|
||||
localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false');
|
||||
} catch { /* private mode */ }
|
||||
this._updateAgentToggle();
|
||||
}
|
||||
|
||||
_updateAgentToggle() {
|
||||
if (!this._panel) return;
|
||||
const btn = this._panel.querySelector('.bookslm-btn-agent');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('active', this._agentMode);
|
||||
const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off');
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false');
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -966,7 +938,6 @@ class BooksLM {
|
||||
</div>
|
||||
<span class="bookslm-qa-badge hidden" data-qa-context=""></span>
|
||||
<div class="bookslm-header-actions">
|
||||
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
|
||||
@@ -1011,7 +982,6 @@ class BooksLM {
|
||||
|
||||
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
|
||||
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
|
||||
panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode());
|
||||
panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggleHistoryMenu();
|
||||
@@ -1552,10 +1522,9 @@ class BooksLM {
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
/** #97/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */
|
||||
/** #97/#99 — Deep Research: a skill-like chip (the agent tools are always on). */
|
||||
_startDeepResearch() {
|
||||
this._closeExtMenu();
|
||||
if (!this._agentMode) this._toggleAgentMode();
|
||||
this._activeDeepResearch = true;
|
||||
this._renderAttachments();
|
||||
showToast(t('bookslm.deep_research_started'), 'info');
|
||||
@@ -2201,15 +2170,14 @@ class BooksLM {
|
||||
}
|
||||
|
||||
/** Immediate-send the action prompt through the composer (same path as a
|
||||
* typed message: skills, agent mode and images all keep working). Actions
|
||||
* flagged `agent` (they mutate the document) transparently switch the
|
||||
* assistant to agent mode first — same pattern as Deep Research. */
|
||||
* typed message: skills and images all keep working). #187: the assistant
|
||||
* is always agent mode, so the former `agent: true` quick actions need no
|
||||
* mode switch anymore. */
|
||||
_runQuickAction(action) {
|
||||
if (!this._panel || !action) return;
|
||||
const { prompt } = actionTexts(action);
|
||||
if (!prompt) return;
|
||||
this._closeActionDrawer();
|
||||
if (action.agent && !this._agentMode) this._toggleAgentMode();
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
if (!textarea) return;
|
||||
textarea.value = prompt;
|
||||
@@ -3253,14 +3221,15 @@ class BooksLM {
|
||||
this._saveHistory();
|
||||
}
|
||||
|
||||
/** POST to /chat or /agent depending on the agent-mode toggle. */
|
||||
/** POST to /agent (always) or /chat when images are attached. */
|
||||
_postChat(payload) {
|
||||
// Images require the plain multimodal chat endpoint (the agent loop is
|
||||
// #187: the assistant is always agent mode — images are the only case
|
||||
// that still needs the plain multimodal chat endpoint (the agent loop is
|
||||
// text/tool oriented).
|
||||
const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
|
||||
const endpoint = (this._agentMode && !hasImages)
|
||||
? '/api/ai/bookslm/agent'
|
||||
: '/api/ai/bookslm/chat';
|
||||
const endpoint = hasImages
|
||||
? '/api/ai/bookslm/chat'
|
||||
: '/api/ai/bookslm/agent';
|
||||
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-1
@@ -288,6 +288,14 @@ function initSearch() {
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
// #158 — a refresh-equivalent home: wipe the global search (chips, result
|
||||
// bar, filters) and the sidebar filter bar before showing the dashboard.
|
||||
const searchClear = document.getElementById("search-clear-btn");
|
||||
if (searchClear) searchClear.click();
|
||||
const sideInput = document.getElementById("sidebar-filter-input");
|
||||
const sideClear = document.getElementById("sidebar-filter-clear-btn");
|
||||
if (sideClear && sideInput && sideInput.value) sideClear.click();
|
||||
|
||||
const searchInput = document.getElementById("search-input");
|
||||
if (searchInput) searchInput.value = "";
|
||||
|
||||
@@ -299,7 +307,12 @@ function goHome() {
|
||||
state.cachedRawSource = null;
|
||||
|
||||
closeMobileSidebar();
|
||||
showWelcome();
|
||||
// #158 — la page d'accueil s'ouvre dans un onglet épinglé en tête de barre
|
||||
if (window.TabManager && window.TabManager.openHome) {
|
||||
window.TabManager.openHome();
|
||||
} else {
|
||||
showWelcome();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Pure helpers for the navigation page facets / filters (#158).
|
||||
*
|
||||
* No DOM, no imports — usable straight from Node tests.
|
||||
* Mirrors the search page's facet mechanism (Vaults / Tags / Extensions)
|
||||
* over a plain directory listing instead of a search result set.
|
||||
*/
|
||||
|
||||
/** Strip the leading dot and lowercase a file extension. */
|
||||
function normExt(extension) {
|
||||
return String(extension || "").toLowerCase().replace(/^\./, "");
|
||||
}
|
||||
|
||||
/** Display name used for labels and alphabetical sorting (.md removed). */
|
||||
export function navDisplayName(file) {
|
||||
const name = file.name || String(file.path || "").split("/").pop() || "";
|
||||
return name.endsWith(".md") ? name.slice(0, -3) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count vaults / tags / extensions over a file listing.
|
||||
* @param {Array<object>} files
|
||||
* @returns {{vaults: object, tags: object, extensions: object}} counts sorted desc
|
||||
*/
|
||||
export function buildNavFacets(files) {
|
||||
const facets = { vaults: {}, tags: {}, extensions: {} };
|
||||
for (const f of files || []) {
|
||||
if (f.vault) facets.vaults[f.vault] = (facets.vaults[f.vault] || 0) + 1;
|
||||
const ext = normExt(f.extension);
|
||||
if (ext) facets.extensions[ext] = (facets.extensions[ext] || 0) + 1;
|
||||
for (const tag of f.tags || []) {
|
||||
facets.tags[tag] = (facets.tags[tag] || 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(facets)) {
|
||||
facets[key] = Object.fromEntries(
|
||||
Object.entries(facets[key]).sort((a, b) => b[1] - a[1]),
|
||||
);
|
||||
}
|
||||
return facets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the files matching the active tag / extension filters.
|
||||
* @param {Array<object>} files
|
||||
* @param {{tag?: string, ext?: string}} filters
|
||||
*/
|
||||
export function filterNavFiles(files, filters) {
|
||||
const tag = (filters && filters.tag) || "";
|
||||
const ext = normExt((filters && filters.ext) || "");
|
||||
if (!tag && !ext) return files || [];
|
||||
return (files || []).filter((f) => {
|
||||
if (ext && normExt(f.extension) !== ext) return false;
|
||||
if (tag && !(f.tags || []).includes(tag)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the listing: `modified` (newest first, server default) or
|
||||
* `relevance` (A→Z).
|
||||
*
|
||||
* ponytail: a directory listing carries no relevance signal, `relevance` is an
|
||||
* alphabetical stand-in — upgrade path: sort by scoped search score once the
|
||||
* navigation page owns a query.
|
||||
*/
|
||||
export function sortNavFiles(files, mode) {
|
||||
const out = (files || []).slice();
|
||||
if (mode === "relevance") {
|
||||
out.sort((a, b) =>
|
||||
navDisplayName(a).localeCompare(navDisplayName(b), undefined, { sensitivity: "base" }),
|
||||
);
|
||||
} else {
|
||||
out.sort((a, b) => (b.modified || 0) - (a.modified || 0));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+36
-7
@@ -14,17 +14,26 @@ function getCurrentFile() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Query parser: +word = required, -word = exclude, word = required ──
|
||||
// ── Query parser: +word = required, -word = exclude, word = required, ext:xxx, vault:xxx, tag:xxx ──
|
||||
function parseQuery(raw) {
|
||||
const positive = [];
|
||||
const negative = [];
|
||||
const filters = { ext: null, vault: null, tag: null };
|
||||
const parts = (raw || '').split(/\s+/).filter(Boolean);
|
||||
for (const p of parts) {
|
||||
if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
|
||||
const lower = p.toLowerCase();
|
||||
if (lower.startsWith('ext:')) {
|
||||
filters.ext = lower.slice(4).trim().replace(/^\./, '').toLowerCase();
|
||||
} else if (lower.startsWith('vault:')) {
|
||||
filters.vault = lower.slice(6).trim();
|
||||
} else if (lower.startsWith('tag:') || (lower.startsWith('#') && p.length > 1)) {
|
||||
const tag = lower.startsWith('tag:') ? lower.slice(4) : lower.slice(1);
|
||||
if (tag) filters.tag = tag.trim();
|
||||
} else if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
|
||||
else if (p.startsWith('+')) positive.push(p.slice(1).toLowerCase());
|
||||
else positive.push(p.toLowerCase());
|
||||
}
|
||||
return { positive, negative };
|
||||
return { positive, negative, filters };
|
||||
}
|
||||
|
||||
// ── DOM / state ──
|
||||
@@ -407,8 +416,8 @@ function createDOM() {
|
||||
_overlay.className = 'command-palette-overlay';
|
||||
_overlay.tabIndex = -1;
|
||||
_overlay.innerHTML = `<div class="command-palette">
|
||||
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
|
||||
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
|
||||
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure ext:md vault:nom tag:monTag)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
|
||||
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot ext:xxx vault:nom tag:xxx) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
|
||||
<div class="cp-results"></div>
|
||||
<div class="cp-footer"><span>↑↓ naviguer</span><span>↵ ouvrir/entrer</span><span>⌫ monter</span><span>Esc fermer</span></div></div>`;
|
||||
_input = _overlay.querySelector('.cp-input');
|
||||
@@ -488,11 +497,14 @@ async function searchFiles(rawQ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { positive, negative } = parseQuery(rawQ);
|
||||
const { positive, negative, filters } = parseQuery(rawQ);
|
||||
|
||||
try {
|
||||
const q = positive.join(' ');
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=all`);
|
||||
let vaultFilter = 'all';
|
||||
if (filters.vault) vaultFilter = filters.vault;
|
||||
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=${encodeURIComponent(vaultFilter)}`);
|
||||
let sug = d.suggestions || [];
|
||||
|
||||
// Client-side negative filter
|
||||
@@ -506,6 +518,23 @@ async function searchFiles(rawQ) {
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by extension if specified
|
||||
if (filters.ext) {
|
||||
sug = sug.filter(s => {
|
||||
const name = s.path.split('/').pop() || '';
|
||||
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
||||
return ext === filters.ext;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by tag if specified
|
||||
if (filters.tag) {
|
||||
sug = sug.filter(s => {
|
||||
const tags = s.tags || [];
|
||||
return tags.some(t => t.toLowerCase() === filters.tag.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
if (sug.length === 0) {
|
||||
_items = [{ type:'_browse', label:'📂 Parcourir les répertoires...', sublabel:'Aucun résultat — naviguer manuellement', act:()=>startBrowse('open-file') }];
|
||||
} else {
|
||||
|
||||
@@ -105,6 +105,20 @@ function createPaneTabManager(paneId) {
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
async openHome(name = "Home") {
|
||||
const tabId = "home";
|
||||
const idx = this._tabs.findIndex((x) => x.id === tabId);
|
||||
if (idx > 0) {
|
||||
const [tab] = this._tabs.splice(idx, 1);
|
||||
this._tabs.unshift(tab);
|
||||
} else if (idx === -1) {
|
||||
this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true });
|
||||
this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" };
|
||||
}
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
async openPersistent(vault, path) {
|
||||
const tabId = `${vault}::${path}`;
|
||||
const previewTab = this._tabs.find(t => t.id === tabId && t.preview);
|
||||
@@ -166,6 +180,15 @@ function createPaneTabManager(paneId) {
|
||||
const cache = this._tabCache[tabId];
|
||||
if (!cache) return;
|
||||
|
||||
// Onglet Accueil (#158) — la page d'accueil vit dans l'onglet
|
||||
if (cache.home) {
|
||||
state.currentVault = null;
|
||||
state.currentPath = null;
|
||||
this._showDashboard();
|
||||
if (history.pushState) history.pushState(null, '', '#');
|
||||
return;
|
||||
}
|
||||
|
||||
state.currentVault = cache.vault;
|
||||
state.currentPath = cache.path;
|
||||
// syncActiveFileTreeItem is imported below to avoid circular deps
|
||||
|
||||
+50
-4
@@ -1034,8 +1034,27 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
}
|
||||
|
||||
// Facets panel
|
||||
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) {
|
||||
const facetsDiv = el("div", { class: "search-facets" });
|
||||
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0 || Object.keys(data.facets.extensions || {}).length > 0)) {
|
||||
const collapsed = localStorage.getItem("obsigate_facets_collapsed") === "1";
|
||||
const facetsDiv = el("div", { class: "search-facets", "data-collapsed": collapsed ? "true" : "false" });
|
||||
|
||||
// Discreet collapse toggle for the whole panel (vaults + tags + extensions)
|
||||
const toggleBtn = el("button", {
|
||||
class: "search-facets__toggle",
|
||||
type: "button",
|
||||
"aria-expanded": String(!collapsed),
|
||||
title: collapsed ? t("search.facets_expand") : t("search.facets_collapse"),
|
||||
});
|
||||
toggleBtn.innerHTML = '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const nowCollapsed = facetsDiv.getAttribute("data-collapsed") !== "true";
|
||||
facetsDiv.setAttribute("data-collapsed", nowCollapsed ? "true" : "false");
|
||||
toggleBtn.setAttribute("aria-expanded", String(!nowCollapsed));
|
||||
toggleBtn.title = nowCollapsed ? t("search.facets_expand") : t("search.facets_collapse");
|
||||
localStorage.setItem("obsigate_facets_collapsed", nowCollapsed ? "1" : "0");
|
||||
});
|
||||
facetsDiv.appendChild(toggleBtn);
|
||||
const facetsBody = el("div", { class: "search-facets__body" });
|
||||
|
||||
// Vault facets
|
||||
const vaultFacets = data.facets.vaults || {};
|
||||
@@ -1056,7 +1075,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsDiv.appendChild(group);
|
||||
facetsBody.appendChild(group);
|
||||
}
|
||||
|
||||
// Tag facets
|
||||
@@ -1075,9 +1094,36 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsDiv.appendChild(group);
|
||||
facetsBody.appendChild(group);
|
||||
}
|
||||
|
||||
// Extension facets
|
||||
const extFacets = data.facets.extensions || {};
|
||||
if (Object.keys(extFacets).length > 0) {
|
||||
const group = el("div", { class: "search-facets__group", "data-facet": "extensions" });
|
||||
const label = el("span", { class: "search-facets__label" });
|
||||
label.textContent = "Extensions";
|
||||
group.appendChild(label);
|
||||
for (const [extName, count] of Object.entries(extFacets)) {
|
||||
const item = el("span", { class: "search-facets__item" });
|
||||
// textContent: extension suffixes come from filenames (untrusted), never innerHTML
|
||||
item.appendChild(document.createTextNode(`.${extName} `));
|
||||
const facetCount = el("span", { class: "facet-count" });
|
||||
facetCount.textContent = String(count);
|
||||
item.appendChild(facetCount);
|
||||
item.addEventListener("click", () => {
|
||||
const input = document.getElementById("search-input");
|
||||
// Replace any existing ext: operator, then trigger a new search
|
||||
const current = input.value.replace(/ext:\S+\s*/gi, "").trim();
|
||||
input.value = current + " ext:" + extName;
|
||||
_triggerAdvancedSearch(input.value);
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsBody.appendChild(group);
|
||||
}
|
||||
|
||||
facetsDiv.appendChild(facetsBody);
|
||||
area.appendChild(facetsDiv);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,12 @@ async function setSelectedVaultContext(vaultName, options) {
|
||||
loadRecentFiles(vaultName === "all" ? null : vaultName);
|
||||
}
|
||||
|
||||
showWelcome();
|
||||
// If a specific vault is selected, switch to vault home page
|
||||
// Onglet de navigation (#158) si un vault précis est sélectionné,
|
||||
// sinon la page d'accueil. Les onglets de fichiers restent ouverts.
|
||||
if (vaultName !== "all") {
|
||||
import('./vaulthome.js').then(m => m.showVaultHome(vaultName, ''));
|
||||
TabManager.openNav(vaultName, "");
|
||||
} else {
|
||||
showWelcome();
|
||||
}
|
||||
if (options && options.focusVault && vaultName !== "all") {
|
||||
await focusVaultInSidebar(vaultName);
|
||||
@@ -142,7 +144,7 @@ async function refreshSidebarForContext() {
|
||||
|
||||
vaultsToShow.forEach((v) => {
|
||||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
|
||||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||||
vaultItem.addEventListener("click", () => { toggleVault(vaultItem, v.name); TabManager.openNav(v.name, ""); });
|
||||
|
||||
vaultItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -239,7 +241,7 @@ async function loadVaults() {
|
||||
vaults.forEach((v) => {
|
||||
// Sidebar tree entry
|
||||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
|
||||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||||
vaultItem.addEventListener("click", () => { toggleVault(vaultItem, v.name); TabManager.openNav(v.name, ""); });
|
||||
|
||||
vaultItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -341,6 +343,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
fragment.appendChild(subContainer);
|
||||
|
||||
dirItem.addEventListener("click", async () => {
|
||||
TabManager.openNav(vaultName, item.path);
|
||||
scrollTreeItemIntoView(dirItem, false);
|
||||
if (subContainer.classList.contains("collapsed")) {
|
||||
if (subContainer.children.length === 0) {
|
||||
@@ -635,6 +638,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
fragment.appendChild(subContainer);
|
||||
|
||||
dirItem.addEventListener("click", async () => {
|
||||
TabManager.openNav(vaultName, item.path);
|
||||
scrollTreeItemIntoView(dirItem, false);
|
||||
if (subContainer.classList.contains("collapsed")) {
|
||||
if (subContainer.children.length === 0) {
|
||||
|
||||
+104
-29
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1776,7 +1777,12 @@ export const ContextMenuManager = {
|
||||
if (e.key === 'Escape') this.hide();
|
||||
});
|
||||
|
||||
document.addEventListener('scroll', () => this.hide(), true);
|
||||
document.addEventListener('scroll', () => {
|
||||
// Un scroll déclenché juste après l'ouverture vient de la reflow des
|
||||
// icônes (lucide) : on ne referme pas le menu qui vient d'apparaître.
|
||||
if (performance.now() - (this._shownAt || 0) < 250) return;
|
||||
this.hide();
|
||||
}, true);
|
||||
},
|
||||
|
||||
show(x, y, vault, path, type, isReadonly) {
|
||||
@@ -1801,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);
|
||||
@@ -1822,6 +1837,7 @@ export const ContextMenuManager = {
|
||||
}
|
||||
|
||||
this._menu.classList.add('active');
|
||||
this._shownAt = performance.now();
|
||||
|
||||
const rect = this._menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
@@ -2105,6 +2121,70 @@ export const TabManager = {
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Open (or retarget) the navigation tab for a vault directory (#158).
|
||||
* One navigation tab per vault: clicking another directory reuses it and
|
||||
* updates its directory, so open file tabs are never displaced. */
|
||||
async openNav(vault, dir = "") {
|
||||
if (!vault || vault === "all") return;
|
||||
dir = dir || "";
|
||||
const tabId = `nav::${vault}`;
|
||||
const name = dir ? dir.split("/").pop() : vault;
|
||||
|
||||
const existing = this._tabs.find(t => t.id === tabId);
|
||||
if (existing) {
|
||||
existing.path = dir;
|
||||
existing.name = name;
|
||||
this._tabCache[tabId].path = dir;
|
||||
this._tabCache[tabId].title = name;
|
||||
// Force a re-render when it is already the active tab
|
||||
if (this._activeTabId === tabId) this._activeTabId = null;
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
this._tabs.push({ id: tabId, vault, path: dir, name, icon: "folder", nav: true });
|
||||
this._tabCache[tabId] = { vault, path: dir, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "folder", nav: true };
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Onglet Accueil (#158) : la page d'accueil vit dans un onglet épinglé tout
|
||||
* à gauche de la barre. Recréé (et remonté en position 0) à chaque clic sur
|
||||
* le titre. */
|
||||
async openHome() {
|
||||
// En split, l'onglet appartient au panneau actif
|
||||
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
|
||||
return window.getActiveTabManager().openHome(t("common.home"));
|
||||
}
|
||||
const tabId = "home";
|
||||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||||
if (idx > 0) {
|
||||
const [tab] = this._tabs.splice(idx, 1);
|
||||
this._tabs.unshift(tab); // toujours en tête de barre
|
||||
} else if (idx === -1) {
|
||||
const name = t("common.home");
|
||||
this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true });
|
||||
this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" };
|
||||
}
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Drop the active-tab marker — the dashboard/home page is showing.
|
||||
* Keeps the tabs open; clicking one re-activates (and re-renders) it.
|
||||
* L'onglet Accueil, lui, reste l'onglet actif quand la page d'accueil
|
||||
* s'affiche (il EST la page d'accueil). */
|
||||
deactivate() {
|
||||
const active = this._activeTabId && this._tabCache[this._activeTabId];
|
||||
if (active && active.home) {
|
||||
this._renderTabs();
|
||||
return;
|
||||
}
|
||||
this._activeTabId = null;
|
||||
this._renderTabs();
|
||||
},
|
||||
|
||||
/** Activate a specific tab */
|
||||
async activate(tabId) {
|
||||
if (this._activeTabId === tabId && this._tabs.length > 0) return;
|
||||
@@ -2125,6 +2205,29 @@ export const TabManager = {
|
||||
const cache = this._tabCache[tabId];
|
||||
if (!cache) return;
|
||||
|
||||
// Onglet Accueil (#158) — la page d'accueil vit dans l'onglet
|
||||
if (cache.home) {
|
||||
state.currentVault = null;
|
||||
state.currentPath = null;
|
||||
showWelcome(); // conserve l'onglet Accueil actif (deactivate())
|
||||
if (history.pushState) {
|
||||
history.pushState(null, "", "#");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation tab — render the directory view instead of fetching a file
|
||||
if (cache.nav) {
|
||||
state.currentVault = cache.vault;
|
||||
state.currentPath = cache.path || null;
|
||||
const navMod = await import("./vaulthome.js");
|
||||
await navMod.showVaultHome(cache.vault, cache.path);
|
||||
if (history.pushState) {
|
||||
history.pushState(null, "", `#/nav/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update global state
|
||||
state.currentVault = cache.vault;
|
||||
state.currentPath = cache.path;
|
||||
@@ -2509,32 +2612,4 @@ export const TabManager = {
|
||||
|
||||
|
||||
|
||||
// ---- Keyboard shortcuts for tabs ----
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === "w" || e.key === "W") {
|
||||
e.preventDefault();
|
||||
if (TabManager._activeTabId) {
|
||||
TabManager.close(TabManager._activeTabId);
|
||||
}
|
||||
} else if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const tabs = TabManager._tabs;
|
||||
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
|
||||
if (currentIdx >= 0 && tabs.length > 1) {
|
||||
const nextIdx = (currentIdx + 1) % tabs.length;
|
||||
TabManager.activate(tabs[nextIdx].id);
|
||||
}
|
||||
} else if (e.key === "Tab" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const tabs = TabManager._tabs;
|
||||
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
|
||||
if (currentIdx >= 0 && tabs.length > 1) {
|
||||
const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length;
|
||||
TabManager.activate(tabs[prevIdx].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Modify init to include TabManager ----
|
||||
|
||||
+430
-215
@@ -1,24 +1,27 @@
|
||||
/**
|
||||
* Vault Home Page module for ObsiGate.
|
||||
* Vault Home / navigation page module for ObsiGate (#158).
|
||||
*
|
||||
* When a specific vault is selected (not "All"), replaces the dashboard
|
||||
* with a non-recursive file listing sorted by modification time (newest first).
|
||||
* Only files from the selected directory are shown (not subdirectories)
|
||||
* to keep the listing fast and lightweight.
|
||||
* The listing updates automatically when browsing into subdirectories.
|
||||
* The view is mounted by `TabManager.openNav(vault, dir)` as a dedicated
|
||||
* **navigation tab**: it opens on every directory click (vault root included,
|
||||
* vault focus mode *and* "All" mode), coexists with open file tabs, and
|
||||
* re-renders when the user drills into a subdirectory or the breadcrumb.
|
||||
*
|
||||
* The visual style mirrors the search results (.search-result-item) for
|
||||
* consistency.
|
||||
* Content: breadcrumb, sort/save toolbar, facets panel (Vaults · Tags ·
|
||||
* Extensions — same mechanism and look as the search page), subdirectory list,
|
||||
* recent files (root only) and the non-recursive file listing of the current
|
||||
* directory.
|
||||
*
|
||||
* Usage:
|
||||
* import { showVaultHome, updateVaultHomeDir, isVaultHomeActive } from './vaulthome.js';
|
||||
* import { showVaultHome, isVaultHomeActive } from './vaulthome.js';
|
||||
* // …but in practice: TabManager.openNav(vault, dirPath)
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { el } from './viewer.js';
|
||||
import { getFileIcon, safeCreateIcons } from './utils.js';
|
||||
import { TabManager } from './ui.js';
|
||||
import { TabManager, ContextMenuManager } from './ui.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from './navfacets.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
@@ -27,25 +30,35 @@ import { t, getLocale } from './i18n.js';
|
||||
const state = {
|
||||
active: false,
|
||||
currentVault: null,
|
||||
currentDir: '', // empty = vault root
|
||||
currentDir: '', // empty = vault root
|
||||
sort: 'modified', // 'modified' (Date) | 'relevance' (Pertinence)
|
||||
filterTag: '',
|
||||
filterExt: '',
|
||||
files: [], // last listing, unfiltered
|
||||
dirs: [], // subdirectories of the current directory
|
||||
};
|
||||
|
||||
// Handles of the mounted view (only one navigation view exists at a time).
|
||||
let _view = null;
|
||||
let _renderSeq = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if vault home is currently displayed. */
|
||||
/** Check if the navigation view is currently displayed. */
|
||||
export function isVaultHomeActive() {
|
||||
return state.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the vault home page for a vault directory.
|
||||
* Display the navigation view for a vault directory.
|
||||
* Called by TabManager when the navigation tab is activated.
|
||||
* @param {string} vaultName - Vault name
|
||||
* @param {string} dirPath - Directory path (empty for root)
|
||||
*/
|
||||
export async function showVaultHome(vaultName, dirPath = '') {
|
||||
if (vaultName === 'all' || !vaultName) {
|
||||
if (!vaultName || vaultName === 'all') {
|
||||
state.active = false;
|
||||
return;
|
||||
}
|
||||
@@ -53,6 +66,9 @@ export async function showVaultHome(vaultName, dirPath = '') {
|
||||
state.active = true;
|
||||
state.currentVault = vaultName;
|
||||
state.currentDir = dirPath || '';
|
||||
state.filterTag = '';
|
||||
state.filterExt = '';
|
||||
state.sort = 'modified';
|
||||
|
||||
const area = document.getElementById('content-area');
|
||||
if (!area) return;
|
||||
@@ -61,7 +77,7 @@ export async function showVaultHome(vaultName, dirPath = '') {
|
||||
const home = document.getElementById('dashboard-home');
|
||||
if (home) home.remove();
|
||||
|
||||
// Build vault home container
|
||||
const seq = ++_renderSeq;
|
||||
area.innerHTML = '';
|
||||
|
||||
const container = el('div', { class: 'vault-home', id: 'vault-home' });
|
||||
@@ -70,117 +86,412 @@ export async function showVaultHome(vaultName, dirPath = '') {
|
||||
// Breadcrumb
|
||||
renderBreadcrumb(container, vaultName, state.currentDir);
|
||||
|
||||
// Sort + save toolbar (same classes as the search results header)
|
||||
container.appendChild(buildToolbar());
|
||||
|
||||
// Facets host (filled once the listing is loaded)
|
||||
const facetsHost = el('div', { id: 'vault-home-facets' });
|
||||
container.appendChild(facetsHost);
|
||||
|
||||
// Results container
|
||||
const resultsArea = el('div', { class: 'vault-home-results', id: 'vault-home-results' });
|
||||
container.appendChild(resultsArea);
|
||||
|
||||
// Show loading
|
||||
resultsArea.innerHTML = `
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
<p data-i18n="vaulthome.loading" style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">${t('vaulthome.loading')}</p>
|
||||
</div>`;
|
||||
safeCreateIcons();
|
||||
|
||||
await loadAndRender(vaultName, state.currentDir, resultsArea);
|
||||
_view = { container, facetsHost, resultsArea, vault: vaultName, dirs: [] };
|
||||
|
||||
// Also load recent files for this vault (only at root level)
|
||||
if (!state.currentDir) {
|
||||
loadRecentSection(vaultName, container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the vault home directory without rebuilding the whole page.
|
||||
* Called when navigating into a subdirectory.
|
||||
* @param {string} vaultName
|
||||
* @param {string} dirPath
|
||||
*/
|
||||
export async function updateVaultHomeDir(vaultName, dirPath) {
|
||||
if (!state.active || state.currentVault !== vaultName) return;
|
||||
|
||||
state.currentDir = dirPath || '';
|
||||
|
||||
const container = document.getElementById('vault-home');
|
||||
if (!container) return;
|
||||
|
||||
// Update breadcrumb
|
||||
const bc = container.querySelector('.vault-home-breadcrumb');
|
||||
if (bc) {
|
||||
bc.innerHTML = '';
|
||||
buildBreadcrumbHTML(bc, vaultName, state.currentDir);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// Remove recent section when navigating into subdirectory
|
||||
const recentSection = container.querySelector('.vault-home-recent');
|
||||
if (recentSection) recentSection.remove();
|
||||
|
||||
// Reload results
|
||||
const resultsArea = document.getElementById('vault-home-results');
|
||||
if (resultsArea) {
|
||||
// Listing + subdirectories
|
||||
let files;
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(state.currentDir)}&limit=200&recursive=false`,
|
||||
);
|
||||
files = data.files || [];
|
||||
} catch (err) {
|
||||
if (seq !== _renderSeq) return;
|
||||
const errMsg = escapeHtml(
|
||||
t('vaulthome.error_loading') + ' ' + (err?.detail || err?.message || t('common.unknown')),
|
||||
);
|
||||
resultsArea.innerHTML = `
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:20px;height:20px;border-width:2px;margin:0 auto"></div>
|
||||
<div class="vault-home-error">
|
||||
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
|
||||
</div>`;
|
||||
await loadAndRender(vaultName, state.currentDir, resultsArea);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree click delegation — update vault home on directory clicks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _treeDelegationInstalled = false;
|
||||
|
||||
function _initTreeDelegation() {
|
||||
if (_treeDelegationInstalled) return;
|
||||
|
||||
const tree = document.getElementById('vault-tree');
|
||||
if (!tree) {
|
||||
// Retry once when DOM might not be ready — use MutationObserver as fallback
|
||||
const observer = new MutationObserver(() => {
|
||||
const t = document.getElementById('vault-tree');
|
||||
if (t) {
|
||||
observer.disconnect();
|
||||
_attachTreeListener(t);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return;
|
||||
}
|
||||
|
||||
_attachTreeListener(tree);
|
||||
try {
|
||||
const browse = await api(
|
||||
`/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(state.currentDir)}`,
|
||||
);
|
||||
state.dirs = (browse.items || []).filter((i) => i.type === 'directory');
|
||||
} catch {
|
||||
state.dirs = []; // no subdirectory list — the file listing still renders
|
||||
}
|
||||
if (seq !== _renderSeq) return;
|
||||
|
||||
state.files = files;
|
||||
_view.dirs = state.dirs;
|
||||
const facets = buildFacets();
|
||||
if (facets) facetsHost.appendChild(facets);
|
||||
renderResults();
|
||||
|
||||
// Recent files for this vault (root level only)
|
||||
if (!state.currentDir) {
|
||||
loadRecentSection(vaultName, container, resultsArea);
|
||||
}
|
||||
}
|
||||
|
||||
function _attachTreeListener(tree) {
|
||||
if (_treeDelegationInstalled) return;
|
||||
_treeDelegationInstalled = true;
|
||||
|
||||
tree.addEventListener('click', (e) => {
|
||||
if (!state.active) return;
|
||||
|
||||
// Find the closest tree-item that is a directory
|
||||
const item = e.target.closest('.tree-item');
|
||||
if (!item || !item.dataset.vault || item.dataset.path === undefined) return;
|
||||
|
||||
const vault = item.dataset.vault;
|
||||
const path = item.dataset.path;
|
||||
|
||||
// Only react to directory clicks (items that have a child container)
|
||||
const hasChildContainer = item.nextElementSibling
|
||||
&& item.nextElementSibling.classList.contains('tree-children');
|
||||
if (!hasChildContainer) return;
|
||||
|
||||
// Update vault home dir
|
||||
updateVaultHomeDir(vault, path);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize delegation when module loads
|
||||
_initTreeDelegation();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// Sort + save toolbar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildToolbar() {
|
||||
const header = el('div', { class: 'search-results-header' });
|
||||
|
||||
const sortDiv = el('div', { class: 'search-sort' });
|
||||
const btnRelevance = el('button', {
|
||||
class: 'search-sort__btn' + (state.sort === 'relevance' ? ' active' : ''),
|
||||
type: 'button',
|
||||
});
|
||||
btnRelevance.textContent = 'Pertinence';
|
||||
btnRelevance.addEventListener('click', () => {
|
||||
state.sort = 'relevance';
|
||||
btnRelevance.classList.add('active');
|
||||
btnDate.classList.remove('active');
|
||||
renderResults();
|
||||
});
|
||||
const btnDate = el('button', {
|
||||
class: 'search-sort__btn' + (state.sort === 'modified' ? ' active' : ''),
|
||||
type: 'button',
|
||||
});
|
||||
btnDate.textContent = 'Date';
|
||||
btnDate.addEventListener('click', () => {
|
||||
state.sort = 'modified';
|
||||
btnDate.classList.add('active');
|
||||
btnRelevance.classList.remove('active');
|
||||
renderResults();
|
||||
});
|
||||
sortDiv.appendChild(btnRelevance);
|
||||
sortDiv.appendChild(btnDate);
|
||||
header.appendChild(sortDiv);
|
||||
|
||||
// Save the current directory as a saved search — same payload as the search
|
||||
// page's "save directory" action (query vide + include_paths).
|
||||
const saveBtn = el('button', { class: 'search-save-btn', type: 'button', title: t('search.save_search') });
|
||||
saveBtn.innerHTML = '<i data-lucide="bookmark-plus" style="width:14px;height:14px"></i> Sauver';
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/saved-searches', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: '',
|
||||
vault: state.currentVault,
|
||||
case_sensitive: false,
|
||||
whole_word: false,
|
||||
regex: false,
|
||||
include_paths: state.currentDir ? state.currentDir + '/**' : '**',
|
||||
exclude_paths: '',
|
||||
}),
|
||||
});
|
||||
flashSaveBtn(saveBtn, false);
|
||||
import('./viewer.js').then((m) => m.loadSavedSearches());
|
||||
} catch (err) {
|
||||
flashSaveBtn(saveBtn, true, err);
|
||||
}
|
||||
});
|
||||
header.appendChild(saveBtn);
|
||||
safeCreateIcons();
|
||||
return header;
|
||||
}
|
||||
|
||||
/** Tiny inline feedback on the save button (no toast module dependency). */
|
||||
function flashSaveBtn(btn, isError, err) {
|
||||
const original = btn.innerHTML;
|
||||
if (isError) {
|
||||
btn.title = String(err?.detail || err?.message || err);
|
||||
}
|
||||
btn.textContent = isError ? '✕' : '✓';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = original;
|
||||
safeCreateIcons();
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Facets panel — same mechanism and visuals as the search page (#157/#158)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildFacets() {
|
||||
const facets = buildNavFacets(state.files);
|
||||
// Parité avec la page de recherche : pas de panneau vide (listing sans fichier).
|
||||
if (!Object.keys(facets.vaults).length && !Object.keys(facets.tags).length
|
||||
&& !Object.keys(facets.extensions).length) {
|
||||
return null;
|
||||
}
|
||||
const collapsed = localStorage.getItem('obsigate_facets_collapsed') === '1';
|
||||
const facetsDiv = el('div', { class: 'search-facets', 'data-collapsed': collapsed ? 'true' : 'false' });
|
||||
|
||||
const toggleBtn = el('button', {
|
||||
class: 'search-facets__toggle',
|
||||
type: 'button',
|
||||
'aria-expanded': String(!collapsed),
|
||||
title: collapsed ? t('search.facets_expand') : t('search.facets_collapse'),
|
||||
});
|
||||
toggleBtn.innerHTML = '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const nowCollapsed = facetsDiv.getAttribute('data-collapsed') !== 'true';
|
||||
facetsDiv.setAttribute('data-collapsed', nowCollapsed ? 'true' : 'false');
|
||||
toggleBtn.setAttribute('aria-expanded', String(!nowCollapsed));
|
||||
toggleBtn.title = nowCollapsed ? t('search.facets_expand') : t('search.facets_collapse');
|
||||
localStorage.setItem('obsigate_facets_collapsed', nowCollapsed ? '1' : '0');
|
||||
});
|
||||
facetsDiv.appendChild(toggleBtn);
|
||||
|
||||
const body = el('div', { class: 'search-facets__body' });
|
||||
|
||||
// Vaults — the view is scoped to one vault; clicking the chip jumps to the
|
||||
// vault root (never a dead control).
|
||||
if (Object.keys(facets.vaults).length > 0) {
|
||||
const group = el('div', { class: 'search-facets__group' });
|
||||
group.appendChild(facetLabel('Vaults'));
|
||||
for (const [vaultName, count] of Object.entries(facets.vaults)) {
|
||||
const item = facetItem(vaultName, count, false);
|
||||
item.addEventListener('click', () => TabManager.openNav(vaultName, ''));
|
||||
group.appendChild(item);
|
||||
}
|
||||
body.appendChild(group);
|
||||
}
|
||||
|
||||
// Tags — filter the listing client-side (toggle).
|
||||
if (Object.keys(facets.tags).length > 0) {
|
||||
const group = el('div', { class: 'search-facets__group' });
|
||||
group.appendChild(facetLabel('Tags'));
|
||||
for (const [tagName, count] of Object.entries(facets.tags).slice(0, 12)) {
|
||||
const item = facetItem('#' + tagName, count, state.filterTag === tagName);
|
||||
item.addEventListener('click', () => {
|
||||
state.filterTag = state.filterTag === tagName ? '' : tagName;
|
||||
refreshView();
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
body.appendChild(group);
|
||||
}
|
||||
|
||||
// Extensions — filter the listing client-side (toggle).
|
||||
if (Object.keys(facets.extensions).length > 0) {
|
||||
const group = el('div', { class: 'search-facets__group', 'data-facet': 'extensions' });
|
||||
group.appendChild(facetLabel('Extensions'));
|
||||
for (const [extName, count] of Object.entries(facets.extensions)) {
|
||||
const item = facetItem('.' + extName, count, state.filterExt === extName);
|
||||
item.addEventListener('click', () => {
|
||||
state.filterExt = state.filterExt === extName ? '' : extName;
|
||||
refreshView();
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
body.appendChild(group);
|
||||
}
|
||||
|
||||
facetsDiv.appendChild(body);
|
||||
return facetsDiv;
|
||||
}
|
||||
|
||||
function facetLabel(text) {
|
||||
const label = el('span', { class: 'search-facets__label' });
|
||||
label.textContent = text;
|
||||
return label;
|
||||
}
|
||||
|
||||
function facetItem(label, count, active) {
|
||||
// textContent: facet labels come from user data (names, tags, extensions),
|
||||
// never innerHTML.
|
||||
const item = el('span', { class: 'search-facets__item' + (active ? ' active' : '') });
|
||||
item.appendChild(document.createTextNode(label + ' '));
|
||||
const countEl = el('span', { class: 'facet-count' });
|
||||
countEl.textContent = String(count);
|
||||
item.appendChild(countEl);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Re-render facets + listing after a filter change (no refetch). */
|
||||
function refreshView() {
|
||||
if (!_view) return;
|
||||
_view.facetsHost.replaceChildren();
|
||||
const facets = buildFacets();
|
||||
if (facets) _view.facetsHost.appendChild(facets);
|
||||
renderResults();
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Listing: subdirectories + files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderResults() {
|
||||
if (!_view) return;
|
||||
const container = _view.resultsArea;
|
||||
container.innerHTML = '';
|
||||
|
||||
const files = filterNavFiles(state.files, { tag: state.filterTag, ext: state.filterExt });
|
||||
const isEmptyListing = state.files.length === 0;
|
||||
|
||||
if (isEmptyListing && state.dirs.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-empty">
|
||||
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
|
||||
<p data-i18n="vaulthome.empty">${t('vaulthome.empty')}</p>
|
||||
</div>`;
|
||||
safeCreateIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Header: count + directory label
|
||||
const header = el('div', { class: 'vault-home-header' });
|
||||
const shown = isEmptyListing ? state.dirs.length : files.length;
|
||||
const countKey = shown > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count';
|
||||
header.appendChild(el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [
|
||||
document.createTextNode(t(countKey, { count: shown })),
|
||||
]));
|
||||
header.appendChild(el('span', { class: 'vault-home-dir-info' }, [
|
||||
document.createTextNode(state.currentDir ? `/${state.currentDir}` : ` ${t('common.root')}`),
|
||||
]));
|
||||
container.appendChild(header);
|
||||
|
||||
// Active filter chips (same badges as the search page)
|
||||
if (state.filterTag || state.filterExt) {
|
||||
const activeFilters = el('div', { class: 'search-results-active-tags' });
|
||||
const addChip = (label, onRemove) => {
|
||||
const removeBtn = el('button', { class: 'search-results-active-tag-remove', title: label }, [
|
||||
document.createTextNode('×'),
|
||||
]);
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
});
|
||||
activeFilters.appendChild(el('span', { class: 'search-results-active-tag' }, [
|
||||
document.createTextNode(label), removeBtn,
|
||||
]));
|
||||
};
|
||||
if (state.filterTag) addChip('#' + state.filterTag, () => { state.filterTag = ''; refreshView(); });
|
||||
if (state.filterExt) addChip('.' + state.filterExt, () => { state.filterExt = ''; refreshView(); });
|
||||
container.appendChild(activeFilters);
|
||||
}
|
||||
|
||||
// Subdirectories — clickable, navigate deeper
|
||||
if (state.dirs.length > 0) {
|
||||
const section = el('div', { class: 'vault-home-recent vault-home-dirs' });
|
||||
const secHeader = el('div', { class: 'vault-home-recent-header' });
|
||||
secHeader.innerHTML = `
|
||||
<i data-lucide="folders" style="width:14px;height:14px"></i>
|
||||
<span data-i18n="vaulthome.directories">${t('vaulthome.directories')}</span>`;
|
||||
section.appendChild(secHeader);
|
||||
const list = el('div', { class: 'vault-home-recent-list' });
|
||||
state.dirs.forEach((d) => {
|
||||
const item = el('div', { class: 'vault-home-recent-item', 'data-path': d.path });
|
||||
// Même couleur d'icône que l'arbre de la sidebar (.tree-item .icon)
|
||||
item.innerHTML = `
|
||||
<i data-lucide="folder" class="icon" style="width:16px;height:16px;flex-shrink:0"></i>
|
||||
<span class="vault-home-recent-title">${escapeHtml(d.name)}</span>
|
||||
<span class="vault-home-recent-time">(${d.children_count})</span>`;
|
||||
item.addEventListener('click', () => TabManager.openNav(state.currentVault, d.path));
|
||||
// Menu contextuel identique à celui de la sidebar (#158)
|
||||
item.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // sinon le handler global de ContextMenuManager referme le menu
|
||||
ContextMenuManager.show(e.clientX, e.clientY, state.currentVault, d.path, 'directory', false);
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// File list — search-result-item style
|
||||
if (files.length === 0) {
|
||||
if (!isEmptyListing) {
|
||||
container.appendChild(el('p', { style: 'color:var(--text-muted);margin-top:20px' }, [
|
||||
document.createTextNode(t('search.no_results')),
|
||||
]));
|
||||
}
|
||||
safeCreateIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('div', { class: 'search-results' });
|
||||
const locale = getLocale();
|
||||
sortNavFiles(files, state.sort).forEach((f) => {
|
||||
list.appendChild(buildFileItem(f, locale));
|
||||
});
|
||||
container.appendChild(list);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
function buildFileItem(f, locale) {
|
||||
const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file';
|
||||
const ext = f.extension || '';
|
||||
|
||||
const titleDiv = el('div', { class: 'search-result-title' }, [
|
||||
document.createTextNode(navDisplayName(f)),
|
||||
]);
|
||||
|
||||
const modDate = new Date(f.modified * 1000);
|
||||
const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
const metaDiv = el('div', { class: 'search-result-vault' }, [
|
||||
document.createTextNode([dateStr, formatSize(f.size)].join(' · ')),
|
||||
]);
|
||||
|
||||
const item = el('div', {
|
||||
class: 'search-result-item vault-home-item',
|
||||
'data-vault': f.vault,
|
||||
'data-path': f.path,
|
||||
}, [
|
||||
el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]),
|
||||
el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }),
|
||||
titleDiv,
|
||||
metaDiv,
|
||||
]);
|
||||
|
||||
// Tags — clicking one applies the tag filter (like the search page)
|
||||
if (f.tags && f.tags.length > 0) {
|
||||
const tagsDiv = el('div', { class: 'search-result-tags' });
|
||||
f.tags.forEach((tag) => {
|
||||
const tagEl = el('span', { class: 'file-tag' }, [document.createTextNode('#' + tag)]);
|
||||
tagEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
state.filterTag = tag;
|
||||
refreshView();
|
||||
});
|
||||
tagsDiv.appendChild(tagEl);
|
||||
});
|
||||
item.appendChild(tagsDiv);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
|
||||
item.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
TabManager.openPersistent(f.vault, f.path);
|
||||
});
|
||||
// Menu contextuel identique à celui de la sidebar (#158)
|
||||
item.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ContextMenuManager.show(e.clientX, e.clientY, f.vault, f.path, 'file', false);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breadcrumb
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderBreadcrumb(container, vaultName, dirPath) {
|
||||
@@ -190,12 +501,13 @@ function renderBreadcrumb(container, vaultName, dirPath) {
|
||||
}
|
||||
|
||||
function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
|
||||
// Vault icon + name (root)
|
||||
// Vault icon + name (root) — routed through the navigation tab so the tab
|
||||
// cache and the view never drift apart.
|
||||
const vaultLink = el('span', { class: 'vault-home-breadcrumb-item vault-home-breadcrumb-root' }, [
|
||||
el('i', { 'data-lucide': 'database', style: 'width:14px;height:14px' }),
|
||||
el('span', {}, [document.createTextNode(vaultName)]),
|
||||
]);
|
||||
vaultLink.addEventListener('click', () => updateVaultHomeDir(vaultName, ''));
|
||||
vaultLink.addEventListener('click', () => TabManager.openNav(vaultName, ''));
|
||||
vaultLink.style.cursor = 'pointer';
|
||||
parentEl.appendChild(vaultLink);
|
||||
|
||||
@@ -205,126 +517,31 @@ function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
|
||||
let cumulative = '';
|
||||
for (const seg of segments) {
|
||||
cumulative = cumulative ? cumulative + '/' + seg : seg;
|
||||
const sep = el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]);
|
||||
parentEl.appendChild(sep);
|
||||
parentEl.appendChild(el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]));
|
||||
const target = cumulative;
|
||||
const segLink = el('span', { class: 'vault-home-breadcrumb-item' }, [
|
||||
el('i', { 'data-lucide': 'folder', style: 'width:14px;height:14px' }),
|
||||
el('span', {}, [document.createTextNode(seg)]),
|
||||
]);
|
||||
segLink.addEventListener('click', () => updateVaultHomeDir(vaultName, cumulative));
|
||||
segLink.addEventListener('click', () => TabManager.openNav(vaultName, target));
|
||||
segLink.style.cursor = 'pointer';
|
||||
parentEl.appendChild(segLink);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRender(vaultName, dirPath, container) {
|
||||
try {
|
||||
const url = `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(dirPath)}&limit=200&recursive=false`;
|
||||
const data = await api(url);
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-empty">
|
||||
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
|
||||
<p data-i18n="vaulthome.empty">${t('vaulthome.empty')}</p>
|
||||
</div>`;
|
||||
safeCreateIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Header
|
||||
const header = el('div', { class: 'vault-home-header' });
|
||||
const countKey = data.files.length > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count';
|
||||
const countStr = t(countKey, { count: data.files.length });
|
||||
const count = el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [
|
||||
document.createTextNode(countStr),
|
||||
]);
|
||||
header.appendChild(count);
|
||||
|
||||
// Directory label
|
||||
const dirLabel = dirPath ? `/${dirPath}` : ` ${t('common.root')}`;
|
||||
const dirInfo = el('span', { class: 'vault-home-dir-info' }, [
|
||||
document.createTextNode(dirLabel),
|
||||
]);
|
||||
header.appendChild(dirInfo);
|
||||
|
||||
container.innerHTML = '';
|
||||
container.appendChild(header);
|
||||
|
||||
// File list — using search-result-item style
|
||||
const list = el('div', { class: 'search-results' });
|
||||
const locale = getLocale();
|
||||
|
||||
data.files.forEach((f) => {
|
||||
const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file';
|
||||
const displayName = f.name.endsWith('.md') ? f.name.slice(0, -3) : f.name;
|
||||
const ext = f.extension || '';
|
||||
|
||||
const titleDiv = el('div', { class: 'search-result-title' }, [
|
||||
document.createTextNode(displayName),
|
||||
]);
|
||||
|
||||
// Format date using current locale
|
||||
const modDate = new Date(f.modified * 1000);
|
||||
const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
// Build metadata line: date + size
|
||||
const metaParts = [];
|
||||
metaParts.push(dateStr);
|
||||
metaParts.push(formatSize(f.size));
|
||||
|
||||
const metaDiv = el('div', { class: 'search-result-vault' }, [
|
||||
document.createTextNode(metaParts.join(' · ')),
|
||||
]);
|
||||
|
||||
const item = el('div', {
|
||||
class: 'search-result-item vault-home-item',
|
||||
'data-vault': f.vault,
|
||||
'data-path': f.path,
|
||||
}, [
|
||||
el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]),
|
||||
el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }),
|
||||
titleDiv,
|
||||
metaDiv,
|
||||
]);
|
||||
|
||||
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
|
||||
item.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
TabManager.openPersistent(f.vault, f.path);
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
container.appendChild(list);
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
const errMsg = t('vaulthome.error_loading') + ' ' + escapeHtml(err?.detail || err?.message || t('common.unknown'));
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-error">
|
||||
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent files section (loaded at vault root only)
|
||||
// Recent files section (root only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadRecentSection(vaultName, container) {
|
||||
async function loadRecentSection(vaultName, container, resultsArea) {
|
||||
try {
|
||||
const data = await api(`/api/recent?mode=opened&vault=${encodeURIComponent(vaultName)}&limit=5`);
|
||||
const files = data.files || [];
|
||||
if (files.length === 0) return;
|
||||
// The view may have been replaced while fetching
|
||||
if (!container.isConnected) return;
|
||||
|
||||
// Insert recent section BEFORE the results area
|
||||
const resultsArea = document.getElementById('vault-home-results');
|
||||
const section = el('div', { class: 'vault-home-recent' });
|
||||
|
||||
const header = el('div', { class: 'vault-home-recent-header' });
|
||||
header.innerHTML = `
|
||||
<i data-lucide="clock" style="width:14px;height:14px"></i>
|
||||
@@ -351,16 +568,14 @@ async function loadRecentSection(vaultName, container) {
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
section.appendChild(list);
|
||||
|
||||
// Insert before the results area
|
||||
if (resultsArea) {
|
||||
if (resultsArea && resultsArea.parentNode === container) {
|
||||
container.insertBefore(section, resultsArea);
|
||||
} else {
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
} catch {
|
||||
// Silently skip if recent files can't be loaded
|
||||
|
||||
+1504
-120
File diff suppressed because it is too large
Load Diff
@@ -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,7 +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-structure-btn" title="${escapeHtml(t("xlsx.structure_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"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
@@ -68,7 +71,17 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||||
<span class="export-dropdown xlsx-export-dropdown">
|
||||
<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" 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><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>');
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/* ObsiGate — generic themed context menu for the XLSX grid (#155, #179).
|
||||
*
|
||||
* Builds a small menu anchored at a screen position. Labels, icons and
|
||||
* callbacks come from the caller, so no i18n lives here.
|
||||
*
|
||||
* #179 — every menu entry carries a representative Lucide icon, the menu
|
||||
* closes as soon as the focus leaves it (outside click, focusout, Escape,
|
||||
* window blur, scroll, resize) and arrow keys walk through the entries.
|
||||
* trackDismissable() exposes the same dismissal contract to the other
|
||||
* spreadsheet menus (Structure, Mise en forme, Export), which keep their
|
||||
* own markup.
|
||||
*/
|
||||
import { safeCreateIcons } from '../utils.js';
|
||||
|
||||
let _menu = null;
|
||||
let _openedAt = 0;
|
||||
let _dismissBound = false;
|
||||
|
||||
export function closeContextMenu() {
|
||||
if (_menu) {
|
||||
_menu.remove();
|
||||
_menu = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isContextMenuOpen() {
|
||||
return _menu !== null;
|
||||
}
|
||||
|
||||
function bindDismiss() {
|
||||
if (_dismissBound) return;
|
||||
_dismissBound = true;
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!_menu) return;
|
||||
// Ignore the very gesture that opened the menu (touch long-press can emit
|
||||
// a click right after).
|
||||
if (Date.now() - _openedAt < 250) return;
|
||||
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
|
||||
closeContextMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") 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, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||
* @returns {HTMLElement} the menu element
|
||||
*/
|
||||
export function openContextMenu(x, y, items) {
|
||||
bindDismiss();
|
||||
closeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "xlsx-context-menu";
|
||||
menu.setAttribute("role", "menu");
|
||||
for (const item of items) {
|
||||
if (item.separator) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "xlsx-context-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
menu.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
|
||||
btn.setAttribute("role", "menuitem");
|
||||
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();
|
||||
if (!item.disabled && item.onClick) item.onClick();
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
enableArrowNav(menu, ".xlsx-context-item");
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
|
||||
// which simply leaves the requested position).
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const vw = (typeof window !== "undefined" && window.innerWidth) || 1024;
|
||||
const vh = (typeof window !== "undefined" && window.innerHeight) || 768;
|
||||
const left = Math.max(4, Math.min(x, vw - rect.width - 4));
|
||||
const top = Math.max(4, Math.min(y, vh - rect.height - 4));
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
|
||||
_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;
|
||||
}
|
||||
@@ -90,8 +90,6 @@
|
||||
"ai.confirm_actions": "{count} actions to approve",
|
||||
"ai.stop": "Stop the assistant",
|
||||
"ai.stopped": "Run stopped.",
|
||||
"ai.agent_mode_off": "Agent mode off (read/search + actions)",
|
||||
"ai.agent_mode_on": "Agent mode on (read/search tools + actions)",
|
||||
"ai.casual": "Casual tone",
|
||||
"ai.close": "Close assistant",
|
||||
"ai.completion_added": "AI: completion added",
|
||||
@@ -344,6 +342,7 @@
|
||||
"common.previous": "Previous",
|
||||
"common.refresh": "Refresh",
|
||||
"common.rename": "Rename",
|
||||
"common.home": "Home",
|
||||
"common.root": "(root)",
|
||||
"common.save": "Save",
|
||||
"common.search": "Search",
|
||||
@@ -511,6 +510,13 @@
|
||||
"config.section_format-du-payload": "Format du payload",
|
||||
"config.section_gestion-des-onglets": "📑 Gestion des onglets",
|
||||
"config.section_hidden": "🗂️ Hidden files",
|
||||
"config.section_desktop": "🖥️ Vaults & folders (Desktop)",
|
||||
"config.desktop_roots_desc": "Add or remove the vaults and root folders loaded by the desktop application (local config removal only — no file is deleted).",
|
||||
"desktop.remove_root": "Remove from app",
|
||||
"desktop.remove_root_confirm": "Remove “{name}” from ObsiGate Desktop? No files will be deleted.",
|
||||
"desktop.add_vault": "📁 Add a vault",
|
||||
"desktop.add_dir": "📂 Add a folder",
|
||||
"desktop.roots_empty": "No vault or folder configured.",
|
||||
"config.section_historique-recent-redemarrage-non-requis": "📋 Recent History\n No restart required",
|
||||
"config.section_indicateurs-visuels": "Indicateurs visuels",
|
||||
"config.section_intelligence-artificielle-dans-l-editeur": "🤖 AI in the Editor",
|
||||
@@ -1101,6 +1107,7 @@
|
||||
"help.desc_f952db17": "Refresh tokens : Cookies HttpOnly avec rotation automatique",
|
||||
"help.desc_fa126609": "Converts the document structure into an Obsidian Canvas (visual cards).",
|
||||
"help.desc_facet_vaults": ": Counters by vault",
|
||||
"help.desc_facet_extensions": ": Counters by file extension in the results",
|
||||
"help.desc_fc9391f3": "ObsiGate sends an HTTP POST to your webhook URL with event details.",
|
||||
"help.desc_fdeaf803": ": Each file type has its icon (markdown, code, etc.)",
|
||||
"help.desc_file_suggest": ": File title suggestions",
|
||||
@@ -1356,7 +1363,7 @@
|
||||
"help.assistant_insert": "The \"Add\" button (revealed on hover of an answer) inserts the answer into the document open in the editor (Editer or Forge); each code block offers \"Add section\" to insert just that block.",
|
||||
"help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.",
|
||||
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
|
||||
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
|
||||
"help.assistant_agent": "The assistant always uses its tools (read, list, search, modify); modifying actions require confirmation with a change preview.",
|
||||
"help.assistant_agent_run": "Actions appear in the thread: the assistant groups modifications into a single approval (\"Approve all\") and refreshes the file tree and the open document as soon as they are applied. The send button becomes \"Stop\" to interrupt the run at any time.",
|
||||
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
|
||||
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
|
||||
@@ -1608,6 +1615,8 @@
|
||||
"search.exclude_placeholder": "Exclude: vendor/*, *.lock",
|
||||
"search.facet_tags": "Tags",
|
||||
"search.facet_vaults": "Vaults",
|
||||
"search.facets_collapse": "Collapse facets",
|
||||
"search.facets_expand": "Expand facets",
|
||||
"search.files_filtered": "{count} file(s) with filters",
|
||||
"search.filter_path_title": "Path filters (Alt-F)",
|
||||
"search.filters": "Filters",
|
||||
@@ -1803,6 +1812,7 @@
|
||||
"vault.add_dynamic": "Add vault",
|
||||
"vault.reindex": "Reindex vault",
|
||||
"vault.remove_dynamic": "Remove vault",
|
||||
"vaulthome.directories": "Folders",
|
||||
"vaulthome.empty": "No files in this directory",
|
||||
"vaulthome.error_loading": "Loading error:",
|
||||
"vaulthome.file_count": "{count} file",
|
||||
@@ -1848,13 +1858,59 @@
|
||||
"xlsx.load_error": "Could not load the remaining rows",
|
||||
"xlsx.formula_bar_placeholder": "Active cell content",
|
||||
"xlsx.active_cell": "Cell",
|
||||
"xlsx.find_placeholder": "Search in the sheet…",
|
||||
"xlsx.name_box_label": "Name Box — jump to a cell or a range",
|
||||
"xlsx.name_box_hint": "Type a reference (B12, A1:B3) then Enter to jump there",
|
||||
"xlsx.name_box_invalid": "Unknown reference: {ref}",
|
||||
"xlsx.copy": "Copy",
|
||||
"xlsx.cut": "Cut",
|
||||
"xlsx.paste": "Paste",
|
||||
"xlsx.paste_empty": "Clipboard empty or unreadable",
|
||||
"xlsx.paste_out_of_grid": "Part of the pasted block is outside the displayed grid and was skipped",
|
||||
"xlsx.fn_suggestions": "SUM;AVERAGE;COUNT;COUNTA;MIN;MAX;IF;VLOOKUP;XLOOKUP;SUMIF;SUMIFS;COUNTIF;CONCAT;LEFT;RIGHT;MID;TODAY;NOW;ROUND;ABS;SQRT",
|
||||
"xlsx.formula_stored_as_text": "{n} value(s) starting with “=” or “@” saved as text — turn on the f(x) button to write real formulas",
|
||||
"xlsx.find_placeholder": "Search in the workbook…",
|
||||
"xlsx.find_prev": "Previous",
|
||||
"xlsx.find_next": "Next",
|
||||
"xlsx.find_case": "Match case",
|
||||
"xlsx.find_no_match": "No match",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Export the sheet as CSV",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
|
||||
"xlsx.csv_export": "Export the sheet or the selection as CSV",
|
||||
"xlsx.export_btn": "Export",
|
||||
"xlsx.export_title": "Export the selection or the sheet",
|
||||
"xlsx.export_md": "Export as Markdown",
|
||||
"xlsx.export_html": "Export as HTML",
|
||||
"xlsx.print": "Print",
|
||||
"xlsx.export_scope_sheet": "the sheet",
|
||||
"xlsx.export_scope_selection": "the selection",
|
||||
"xlsx.export_done": "Exported {scope}",
|
||||
"xlsx.format_btn": "Formatting",
|
||||
"xlsx.format_bold": "Bold",
|
||||
"xlsx.format_italic": "Italic",
|
||||
"xlsx.format_underline": "Underline",
|
||||
"xlsx.format_clear": "Clear formatting",
|
||||
"xlsx.format_align_left": "Align left",
|
||||
"xlsx.format_align_center": "Center",
|
||||
"xlsx.format_align_right": "Align right",
|
||||
"xlsx.format_font_color": "Text colour",
|
||||
"xlsx.format_fill_color": "Fill colour",
|
||||
"xlsx.format_num_general": "General format",
|
||||
"xlsx.format_num_number": "Number (2 decimals)",
|
||||
"xlsx.format_num_percent": "Percentage",
|
||||
"xlsx.format_num_currency": "Currency (€)",
|
||||
"xlsx.format_num_date": "Date (DD/MM/YYYY)",
|
||||
"xlsx.format_num_text": "Text",
|
||||
"xlsx.format_merge": "Merge cells",
|
||||
"xlsx.format_unmerge": "Unmerge cells",
|
||||
"xlsx.format_merge_needs_range": "Select at least two cells to merge",
|
||||
"xlsx.format_freeze": "Freeze panes here",
|
||||
"xlsx.format_unfreeze": "Unfreeze panes",
|
||||
"xlsx.format_col_width": "Column width…",
|
||||
"xlsx.format_col_prompt": "Width of column {col} (0 to 255)",
|
||||
"xlsx.format_row_height": "Row height…",
|
||||
"xlsx.format_row_prompt": "Height of row {row} (0 to 409)",
|
||||
"xlsx.format_applied": "Formatting saved",
|
||||
"xlsx.format_error": "Formatting failed",
|
||||
"xlsx.sort_asc": "Sort column A→Z",
|
||||
"xlsx.sort_desc": "Sort column Z→A",
|
||||
"xlsx.sort_applied": "Sort applied on {col} — display only, the workbook is unchanged",
|
||||
@@ -1875,6 +1931,17 @@
|
||||
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
|
||||
"xlsx.select_column": "Select the column",
|
||||
"xlsx.select_row": "Select the row",
|
||||
"xlsx.insert_row_above": "Insert a row above",
|
||||
"xlsx.insert_row_below": "Insert a row below",
|
||||
"xlsx.insert_col_left": "Insert a column to the left",
|
||||
"xlsx.insert_col_right": "Insert a column to the right",
|
||||
"xlsx.delete_rows": "Delete {n} row(s)",
|
||||
"xlsx.delete_cols": "Delete {n} column(s)",
|
||||
"xlsx.clear_contents": "Clear contents",
|
||||
"xlsx.confirm_delete_rows": "Delete {n} row(s)? This changes the file (a backup is created).",
|
||||
"xlsx.confirm_delete_cols": "Delete {n} column(s)? This changes the file (a backup is created).",
|
||||
"xlsx.structure_saved": "Structure updated",
|
||||
"xlsx.structure_error": "Could not change the structure",
|
||||
"xlsx.last_sheet": "The last sheet cannot be deleted",
|
||||
@@ -1894,6 +1961,7 @@
|
||||
"xlsx.dashboard_nr_ref": "Reference",
|
||||
"xlsx.dashboard_hint": "Select a range or open the AI assistant to analyse this data.",
|
||||
"xlsx.feature_cached_values": "cached values",
|
||||
"xlsx.feature_cached_values_unverified": "cached values could not be verified (workbook too large)",
|
||||
"xlsx.feature_slicers": "slicers and timelines",
|
||||
"xlsx.feature_form_controls": "form controls",
|
||||
"xlsx.feature_connections": "connections and queries",
|
||||
@@ -2069,9 +2137,19 @@
|
||||
"ai.step.xlsx_read": "Workbook read: {value}",
|
||||
"ai.step.xlsx_update": "Cells edited: {value}",
|
||||
"ai.step.xlsx_append": "Rows appended: {value}",
|
||||
"ai.step.xlsx_search": "Workbook searched: {value}",
|
||||
"ai.step.xlsx_analyze": "Range analysed: {value}",
|
||||
"ai.step.xlsx_structure": "Structure changed: {value}",
|
||||
"ai.step.docx_create": "Word document proposed: {value}",
|
||||
"ai.step.csv_create": "CSV file proposed: {value}",
|
||||
"ai.step.pdf_create": "PDF document proposed: {value}",
|
||||
"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",
|
||||
|
||||
@@ -90,8 +90,6 @@
|
||||
"ai.confirm_actions": "{count} actions à approuver",
|
||||
"ai.stop": "Arrêter l'assistant",
|
||||
"ai.stopped": "Exécution arrêtée.",
|
||||
"ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)",
|
||||
"ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)",
|
||||
"ai.casual": "Ton décontracté",
|
||||
"ai.close": "Fermer l'assistant",
|
||||
"ai.completion_added": "AI: complétion ajoutée",
|
||||
@@ -344,6 +342,7 @@
|
||||
"common.previous": "Précédent",
|
||||
"common.refresh": "Rafraîchir",
|
||||
"common.rename": "Renommer",
|
||||
"common.home": "Accueil",
|
||||
"common.root": "(racine)",
|
||||
"common.save": "Enregistrer",
|
||||
"common.search": "Rechercher",
|
||||
@@ -511,6 +510,13 @@
|
||||
"config.section_format-du-payload": "Format du payload",
|
||||
"config.section_gestion-des-onglets": "📑 Gestion des onglets",
|
||||
"config.section_hidden": "🗂️ Fichiers cachés",
|
||||
"config.section_desktop": "🖥️ Vaults & dossiers (Desktop)",
|
||||
"config.desktop_roots_desc": "Ajoutez ou retirez les vaults et dossiers racine chargés par l'application desktop (retrait local de la configuration, aucun fichier supprimé).",
|
||||
"desktop.remove_root": "Retirer de l'application",
|
||||
"desktop.remove_root_confirm": "Retirer « {name} » de l'application ObsiGate Desktop ? Les fichiers ne seront pas supprimés.",
|
||||
"desktop.add_vault": "📁 Ajouter un vault",
|
||||
"desktop.add_dir": "📂 Ajouter un dossier",
|
||||
"desktop.roots_empty": "Aucun vault ou dossier configuré.",
|
||||
"config.section_historique-recent-redemarrage-non-requis": "📋 Historique récent\n Redémarrage non requis",
|
||||
"config.section_indicateurs-visuels": "Indicateurs visuels",
|
||||
"config.section_intelligence-artificielle-dans-l-editeur": "🤖 Intelligence Artificielle dans l'Éditeur",
|
||||
@@ -1101,6 +1107,7 @@
|
||||
"help.desc_f952db17": "Refresh tokens : Cookies\n HttpOnly avec rotation automatique",
|
||||
"help.desc_fa126609": "🔷\n Convert to canvas :\n Convertit en diagramme Mermaid.js",
|
||||
"help.desc_facet_vaults": ": Compteurs par vault",
|
||||
"help.desc_facet_extensions": ": Compteurs par extension de fichier dans les résultats",
|
||||
"help.desc_fc9391f3": "# Exemple avec curl + jq dans un script bash\nWEBHOOK_URL=\"https://discord.com/api/webhooks/...\"\nOBSIGATE_SECRET=\"mon-secret\"\n\n# Lire le payload depuis stdin\nread -r payload\nevent=$(echo \"$payload\" | jq -r '.event')\npath=$(echo \"$payload\" | jq -r '.data.path')\n\ncurl -H \"Content-Type: application/json\" -d \"{\n \\\"content\\\": \\\"📝 **$event** — $path\\\"\n}\" \"$WEBHOOK_URL\"\n\n Tester avec curl\n # Simuler ce qu'ObsiGate envoie (pour tester votre endpoint)\ncurl -X POST https://votre-serveur.com/webhook \\\n -H \"Content-Type: application/json\" \\\n -H \"X-ObsiGate-Event: file_created\" \\\n -H \"X-ObsiGate-Signature: sha256=$(echo -n '{\"event\":\"file_created\",...}' | openssl dgst -sha256 -hmac 'mon-secret' | cut -d' ' -f2)\" \\\n -d '{\"event\":\"file_created\",\"timestamp\":\"2026-06-03T14:30:00Z\",\"data\":{\"vault\":\"IT\",\"path\":\"notes/test.md\"}}'\n\n Limitations actuelles\n \n \n Timeout de 5 secondes par\n requête — les webhooks sont \"fire and\n forget\" (pas de retry)\n \n \n Les modifications détectées par le watcher\n filesystem (watchdog)\n ne déclenchent pas de\n webhooks — seules les opérations via l'API\n REST sont prises en compte\n \n \n file_modified n'est pas encore\n dispatché (réservé pour version future)\n \n Pas de retry automatique en cas d'échec\n \n Pas de filtrage par vault — un webhook\n écoute toutes les vaults\n \n \n \n \n 💾 Sauvegardes et Audits\n\n Backup automatique\n \n ObsiGate crée automatiquement des sauvegardes\n avant chaque modification destructive.",
|
||||
"help.desc_fdeaf803": ": Chaque type de\n fichier a son icône (markdown, code, etc.)",
|
||||
"help.desc_file_suggest": ": Suggestions de titres de fichiers",
|
||||
@@ -1356,7 +1363,7 @@
|
||||
"help.assistant_insert": "Le bouton « Ajouter » (au survol d'une réponse) insère la réponse dans le document ouvert dans l'éditeur (Editer ou Forge) ; chaque bloc de code propose « Ajouter la section » pour n'insérer que ce bloc.",
|
||||
"help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.",
|
||||
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
|
||||
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
|
||||
"help.assistant_agent": "L'assistant utilise toujours ses outils (lire, lister, chercher, modifier) ; les actions de modification demandent une confirmation avec aperçu des changements.",
|
||||
"help.assistant_agent_run": "Les actions s'affichent dans le fil : l'assistant regroupe les modifications en une seule approbation (« Tout approuver ») et met à jour l'arborescence et le document ouvert dès qu'elles sont appliquées. Le bouton d'envoi devient « Stop » pour interrompre l'exécution à tout moment.",
|
||||
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
|
||||
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
|
||||
@@ -1608,6 +1615,8 @@
|
||||
"search.exclude_placeholder": "Exclure: vendor/*, *.lock",
|
||||
"search.facet_tags": "Tags",
|
||||
"search.facet_vaults": "Vaults",
|
||||
"search.facets_collapse": "Réduire les facettes",
|
||||
"search.facets_expand": "Afficher les facettes",
|
||||
"search.files_filtered": "{count} fichier(s) avec filtres",
|
||||
"search.filter_path_title": "Filtres de chemin (Alt-F)",
|
||||
"search.filters": "Filtres",
|
||||
@@ -1803,6 +1812,7 @@
|
||||
"vault.add_dynamic": "Ajouter une vault",
|
||||
"vault.reindex": "Réindexer la vault",
|
||||
"vault.remove_dynamic": "Retirer la vault",
|
||||
"vaulthome.directories": "Répertoires",
|
||||
"vaulthome.empty": "Aucun fichier dans ce repertoire",
|
||||
"vaulthome.error_loading": "Erreur de chargement:",
|
||||
"vaulthome.file_count": "{count} fichier",
|
||||
@@ -1848,13 +1858,59 @@
|
||||
"xlsx.load_error": "Chargement de la suite impossible",
|
||||
"xlsx.formula_bar_placeholder": "Contenu de la cellule active",
|
||||
"xlsx.active_cell": "Cellule",
|
||||
"xlsx.find_placeholder": "Rechercher dans la feuille…",
|
||||
"xlsx.name_box_label": "Zone Nom — atteindre une cellule ou une plage",
|
||||
"xlsx.name_box_hint": "Saisissez une référence (B12, A1:B3) puis Entrée pour y aller",
|
||||
"xlsx.name_box_invalid": "Référence inconnue : {ref}",
|
||||
"xlsx.copy": "Copier",
|
||||
"xlsx.cut": "Couper",
|
||||
"xlsx.paste": "Coller",
|
||||
"xlsx.paste_empty": "Presse-papiers vide ou illisible",
|
||||
"xlsx.paste_out_of_grid": "Une partie du collage dépasse la grille affichée et a été ignorée",
|
||||
"xlsx.fn_suggestions": "SOMME;MOYENNE;NB;NBVAL;MIN;MAX;SI;RECHERCHEV;RECHERCHEX;SOMME.SI;SOMME.SI.ENS;NB.SI;CONCAT;GAUCHE;DROITE;STXT;AUJOURDHUI;MAINTENANT;ARRONDI;ABS;RACINE",
|
||||
"xlsx.formula_stored_as_text": "{n} valeur(s) commençant par « = » ou « @ » enregistrée(s) comme texte — activez le bouton f(x) pour écrire de vraies formules",
|
||||
"xlsx.find_placeholder": "Rechercher dans le classeur…",
|
||||
"xlsx.find_prev": "Précédent",
|
||||
"xlsx.find_next": "Suivant",
|
||||
"xlsx.find_case": "Respecter la casse",
|
||||
"xlsx.find_no_match": "Aucune correspondance",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Exporter la feuille en CSV",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
|
||||
"xlsx.csv_export": "Exporter la feuille ou la sélection en CSV",
|
||||
"xlsx.export_btn": "Exporter",
|
||||
"xlsx.export_title": "Exporter la sélection ou la feuille",
|
||||
"xlsx.export_md": "Exporter en Markdown",
|
||||
"xlsx.export_html": "Exporter en HTML",
|
||||
"xlsx.print": "Imprimer",
|
||||
"xlsx.export_scope_sheet": "la feuille",
|
||||
"xlsx.export_scope_selection": "la sélection",
|
||||
"xlsx.export_done": "Export de {scope} terminé",
|
||||
"xlsx.format_btn": "Mise en forme",
|
||||
"xlsx.format_bold": "Gras",
|
||||
"xlsx.format_italic": "Italique",
|
||||
"xlsx.format_underline": "Souligné",
|
||||
"xlsx.format_clear": "Effacer la mise en forme",
|
||||
"xlsx.format_align_left": "Aligner à gauche",
|
||||
"xlsx.format_align_center": "Centrer",
|
||||
"xlsx.format_align_right": "Aligner à droite",
|
||||
"xlsx.format_font_color": "Couleur du texte",
|
||||
"xlsx.format_fill_color": "Couleur de fond",
|
||||
"xlsx.format_num_general": "Format général",
|
||||
"xlsx.format_num_number": "Nombre (2 décimales)",
|
||||
"xlsx.format_num_percent": "Pourcentage",
|
||||
"xlsx.format_num_currency": "Monétaire (€)",
|
||||
"xlsx.format_num_date": "Date (JJ/MM/AAAA)",
|
||||
"xlsx.format_num_text": "Texte",
|
||||
"xlsx.format_merge": "Fusionner les cellules",
|
||||
"xlsx.format_unmerge": "Annuler la fusion",
|
||||
"xlsx.format_merge_needs_range": "Sélectionnez au moins deux cellules pour fusionner",
|
||||
"xlsx.format_freeze": "Figer les volets ici",
|
||||
"xlsx.format_unfreeze": "Libérer les volets",
|
||||
"xlsx.format_col_width": "Largeur de colonne…",
|
||||
"xlsx.format_col_prompt": "Largeur de la colonne {col} (0 à 255)",
|
||||
"xlsx.format_row_height": "Hauteur de ligne…",
|
||||
"xlsx.format_row_prompt": "Hauteur de la ligne {row} (0 à 409)",
|
||||
"xlsx.format_applied": "Mise en forme enregistrée",
|
||||
"xlsx.format_error": "Échec de la mise en forme",
|
||||
"xlsx.sort_asc": "Trier la colonne A→Z",
|
||||
"xlsx.sort_desc": "Trier la colonne Z→A",
|
||||
"xlsx.sort_applied": "Tri appliqué sur {col} — l'affichage seul, le classeur n'est pas modifié",
|
||||
@@ -1875,6 +1931,17 @@
|
||||
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.select_column": "Sélectionner la colonne",
|
||||
"xlsx.select_row": "Sélectionner la ligne",
|
||||
"xlsx.insert_row_above": "Insérer une ligne au-dessus",
|
||||
"xlsx.insert_row_below": "Insérer une ligne en dessous",
|
||||
"xlsx.insert_col_left": "Insérer une colonne à gauche",
|
||||
"xlsx.insert_col_right": "Insérer une colonne à droite",
|
||||
"xlsx.delete_rows": "Supprimer {n} ligne(s)",
|
||||
"xlsx.delete_cols": "Supprimer {n} colonne(s)",
|
||||
"xlsx.clear_contents": "Effacer le contenu",
|
||||
"xlsx.confirm_delete_rows": "Supprimer {n} ligne(s) ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.confirm_delete_cols": "Supprimer {n} colonne(s) ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_saved": "Structure mise à jour",
|
||||
"xlsx.structure_error": "Modification de la structure impossible",
|
||||
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
|
||||
@@ -1894,6 +1961,7 @@
|
||||
"xlsx.dashboard_nr_ref": "Référence",
|
||||
"xlsx.dashboard_hint": "Sélectionnez une plage ou ouvrez l'assistant IA pour analyser ces données.",
|
||||
"xlsx.feature_cached_values": "valeurs calculées",
|
||||
"xlsx.feature_cached_values_unverified": "valeurs calculées non vérifiables (classeur trop volumineux)",
|
||||
"xlsx.feature_slicers": "segments et chronologies",
|
||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||
"xlsx.feature_connections": "connexions et requêtes",
|
||||
@@ -2069,9 +2137,19 @@
|
||||
"ai.step.xlsx_read": "Classeur lu : {value}",
|
||||
"ai.step.xlsx_update": "Cellules modifiées : {value}",
|
||||
"ai.step.xlsx_append": "Lignes ajoutées : {value}",
|
||||
"ai.step.xlsx_search": "Recherche dans le classeur : {value}",
|
||||
"ai.step.xlsx_analyze": "Plage analysée : {value}",
|
||||
"ai.step.xlsx_structure": "Structure modifiée : {value}",
|
||||
"ai.step.docx_create": "Document Word proposé : {value}",
|
||||
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
||||
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
||||
"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",
|
||||
|
||||
+306
-5
@@ -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;
|
||||
}
|
||||
@@ -5472,10 +5483,55 @@ body.resizing-v {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
/* Facette actuellement appliquée (filtre local — recherche et navigation) */
|
||||
.search-facets__item.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.search-facets__item .facet-count {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-facets__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-start; /* mobile (colonne) : ne jamais s'étirer en pleine largeur */
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 120ms,
|
||||
color 120ms,
|
||||
background 120ms;
|
||||
}
|
||||
.search-facets__toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Seule l'icône pivote — la boîte du bouton reste fixe */
|
||||
.search-facets__toggle i,
|
||||
.search-facets__toggle svg {
|
||||
transition: transform 120ms;
|
||||
}
|
||||
.search-facets[data-collapsed="true"] .search-facets__toggle i,
|
||||
.search-facets[data-collapsed="true"] .search-facets__toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.search-facets__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-facets[data-collapsed="true"] .search-facets__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Pagination
|
||||
@@ -5860,6 +5916,11 @@ body.resizing-v {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Icône des répertoires : même couleur que l'arbre de la sidebar (.tree-item .icon) */
|
||||
.vault-home-dirs .icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.vault-home-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
@@ -11148,7 +11209,8 @@ body.desktop-mode .editor-container {
|
||||
border-bottom: 2px solid var(--grid-border);
|
||||
}
|
||||
.xlsx-table td[contenteditable] {
|
||||
cursor: text;
|
||||
/* #155 — Excel-style cross cursor over the cells. */
|
||||
cursor: cell;
|
||||
min-width: 40px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -11171,8 +11233,11 @@ body.desktop-mode .editor-container {
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
/* #156-A7 — the Name Box is editable (“Atteindre”): a plain input styled like
|
||||
the read-only badge it replaced. */
|
||||
.xlsx-active-cell {
|
||||
min-width: 52px;
|
||||
width: 84px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
@@ -11183,6 +11248,10 @@ body.desktop-mode .editor-container {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.xlsx-active-cell:focus {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-formula-sep {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -11252,6 +11321,73 @@ body.desktop-mode .editor-container {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* #156-A11 — export menu (CSV stays a button; MD/HTML/Print live here). */
|
||||
.xlsx-export-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.xlsx-export-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
max-width: 86vw;
|
||||
padding: 4px;
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.xlsx-export-menu .export-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
.xlsx-export-menu .export-menu-item:hover {
|
||||
background: var(--surface-hover, rgba(127, 127, 127, 0.12));
|
||||
}
|
||||
.xlsx-print-frame {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* #156-A8 — formatting menu: the colour rows pair a label with the native
|
||||
picker (data-driven colours, no hardcoded swatch). */
|
||||
.xlsx-format-menu {
|
||||
min-width: 230px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.xlsx-format-colour {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-format-colour input[type="color"] {
|
||||
width: 34px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* #153 A15 — freeze panes: frozen rows sit UNDER the sticky thead, frozen
|
||||
columns stay left. z-index mirrors thead (3) without covering it. */
|
||||
.xlsx-table tr.xlsx-frozen-row td,
|
||||
@@ -11275,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);
|
||||
@@ -11473,6 +11610,99 @@ 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);
|
||||
}
|
||||
.xlsx-viewer.xlsx-selecting,
|
||||
.xlsx-viewer.xlsx-selecting td {
|
||||
user-select: none;
|
||||
}
|
||||
.xlsx-viewer.xlsx-selecting td[contenteditable] {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.xlsx-context-menu {
|
||||
position: fixed;
|
||||
z-index: 10050;
|
||||
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;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-context-item:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.xlsx-context-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.xlsx-context-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
.xlsx-context-sep {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* JSDOM shims for the tests that click anchors */
|
||||
mark {
|
||||
font: inherit;
|
||||
@@ -11788,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;
|
||||
@@ -11941,8 +12244,6 @@ mark {
|
||||
background: var(--accent); color: #fff; cursor: pointer; font-size: 12px; flex-shrink: 0; }
|
||||
.bookslm-action-apply:disabled { opacity: 0.6; cursor: default; }
|
||||
.bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
|
||||
/* Agent-mode toggle in the header. */
|
||||
.bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); }
|
||||
/* Tool-call trace shown above an assistant answer (agent mode).
|
||||
The header is the live indicator (« ●●● 3 étapes ▶ »); expanding it reveals
|
||||
the chronological steps, thinking notes as « Réflexion » sub-sections and
|
||||
|
||||
+2
-1
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v28';
|
||||
const SW_VERSION = 'v29';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
@@ -33,6 +33,7 @@ const PRECACHE_URLS = [
|
||||
'/static/js/xlsx/refs.js',
|
||||
'/static/js/xlsx/command-bar.js',
|
||||
'/static/js/xlsx/dashboard.js',
|
||||
'/static/js/xlsx/context-menu.js',
|
||||
'/static/js/search.js',
|
||||
'/static/js/config.js',
|
||||
'/static/js/utils.js',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.43.1",
|
||||
"version": "2.53.2",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* E2E — Page de navigation & onglet Accueil (#158).
|
||||
*
|
||||
* Couvre :
|
||||
* - clic sur la racine d'un vault -> page de navigation (onglet dédié)
|
||||
* - icône des répertoires à la couleur de l'arbre de la sidebar
|
||||
* - menu contextuel des répertoires et des fichiers identique à la sidebar
|
||||
* - clic sur le titre -> onglet Accueil (icône Maison) épinglé en tête de barre
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
async function boot(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
|
||||
}
|
||||
|
||||
const menuItems = (page) =>
|
||||
page.$$eval('#context-menu .context-menu-item', (els) => els.map((e) => e.textContent.trim()))
|
||||
.catch(() => []);
|
||||
|
||||
test.describe('Page de navigation (#158)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await boot(page);
|
||||
});
|
||||
|
||||
test("clic sur la racine d'un vault ouvre la page de navigation", async ({ page }) => {
|
||||
const vault = await page.locator('.vault-item').first().getAttribute('data-vault');
|
||||
await page.locator('.vault-item').first().click();
|
||||
|
||||
await page.waitForSelector('#vault-home .vault-home-breadcrumb', { timeout: 15000 });
|
||||
await expect(page.locator(`#tab-list .tab-item[data-tab-id="nav::${vault}"]`)).toHaveCount(1);
|
||||
// Le fil d'Ariane est à la racine du vault
|
||||
await expect(page.locator('#vault-home .vault-home-breadcrumb-item')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("répertoires de la vue : icône à la couleur de la sidebar + menu contextuel", async ({ page }) => {
|
||||
await page.locator('.vault-item').first().click();
|
||||
await page.waitForSelector('#vault-home .vault-home-dirs .vault-home-recent-item', { timeout: 15000 });
|
||||
|
||||
// Couleur identique à l'icône de l'arbre (.tree-item .icon -> var(--accent))
|
||||
const colors = await page.evaluate(() => {
|
||||
const cs = (el) => (el ? getComputedStyle(el).color : null);
|
||||
return {
|
||||
nav: cs(document.querySelector('#vault-home .vault-home-dirs .vault-home-recent-item svg')),
|
||||
side: cs(document.querySelector('.tree-item[data-type="directory"] svg')),
|
||||
};
|
||||
});
|
||||
expect(colors.nav).toBe(colors.side);
|
||||
expect(colors.nav).toBeTruthy();
|
||||
|
||||
// Menu contextuel : page de navigation vs sidebar
|
||||
await page.locator('#vault-home .vault-home-dirs .vault-home-recent-item').first().click({ button: 'right' });
|
||||
await page.waitForSelector('#context-menu', { timeout: 5000 });
|
||||
const navMenu = await menuItems(page);
|
||||
expect(navMenu.length).toBeGreaterThan(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.locator('.tree-item[data-type="directory"]').first().click({ button: 'right' });
|
||||
await page.waitForSelector('#context-menu', { timeout: 5000 });
|
||||
const sideMenu = await menuItems(page);
|
||||
await page.keyboard.press('Escape');
|
||||
expect(navMenu).toEqual(sideMenu);
|
||||
});
|
||||
|
||||
test('fichiers de la vue : menu contextuel identique à la sidebar', async ({ page }) => {
|
||||
await page.locator('.vault-item').first().click();
|
||||
await page.waitForSelector('#vault-home .search-result-item', { timeout: 15000 });
|
||||
|
||||
await page.locator('#vault-home .search-result-item').first().click({ button: 'right' });
|
||||
await page.waitForSelector('#context-menu', { timeout: 5000 });
|
||||
const navMenu = await menuItems(page);
|
||||
expect(navMenu.length).toBeGreaterThan(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.locator('.tree-item[data-type="file"]').first().click({ button: 'right' });
|
||||
await page.waitForSelector('#context-menu', { timeout: 5000 });
|
||||
const sideMenu = await menuItems(page);
|
||||
await page.keyboard.press('Escape');
|
||||
expect(navMenu).toEqual(sideMenu);
|
||||
});
|
||||
|
||||
test('titre -> onglet Accueil (Maison) épinglé en tête de barre', async ({ page }) => {
|
||||
// Un fichier ouvert pour vérifier que l'onglet Accueil passe devant
|
||||
await page.locator('.vault-item').first().click();
|
||||
await page.waitForSelector('.tree-item[data-type="file"]', { timeout: 15000 });
|
||||
await page.locator('.tree-item[data-type="file"]').first().dblclick();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.locator('#header-logo').click();
|
||||
await page.waitForSelector('#dashboard-home', { timeout: 10000 });
|
||||
|
||||
const ids = await page.$$eval('#tab-list .tab-item', (els) => els.map((e) => e.dataset.tabId));
|
||||
expect(ids[0]).toBe('home');
|
||||
expect(ids.length).toBeGreaterThan(1);
|
||||
await expect(page.locator('#tab-list .tab-item.active')).toHaveAttribute('data-tab-id', 'home');
|
||||
// Icône Maison + libellé traduit
|
||||
const iconClass = await page.locator('#tab-list .tab-item').first().locator('.tab-icon').getAttribute('class');
|
||||
expect(iconClass).toContain('home');
|
||||
const label = (await page.locator('#tab-list .tab-item').first().locator('.tab-name').textContent()) || '';
|
||||
expect(['Accueil', 'Home']).toContain(label.trim());
|
||||
// La page d'accueil est affichée (avec sa section Raccourcis & Astuces)
|
||||
await expect(page.locator('#dashboard-home #quick-help')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,33 @@ test.describe('Full-Text Search', () => {
|
||||
// Not always present — depends on vault content
|
||||
});
|
||||
|
||||
test('should show the extensions facet and collapse the facets panel (#157)', async ({ page }) => {
|
||||
await goHome(page);
|
||||
await searchFor(page, 'test');
|
||||
const extFacet = page.locator('.search-facets__group[data-facet="extensions"]');
|
||||
await expect(extFacet).toBeVisible();
|
||||
await expect(extFacet.locator('.search-facets__item').first()).toBeVisible();
|
||||
// discreet collapse/expand of the whole vaults+tags+extensions section
|
||||
await page.locator('.search-facets__toggle').click();
|
||||
await expect(page.locator('.search-facets__body')).toBeHidden();
|
||||
await page.locator('.search-facets__toggle').click();
|
||||
await expect(page.locator('.search-facets__body')).toBeVisible();
|
||||
|
||||
// mobile: the toggle stays compact (no full-width stretch) and only the
|
||||
// icon rotates — the button box itself never spins
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const toggle = page.locator('.search-facets__toggle');
|
||||
const box = await toggle.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeLessThan(150);
|
||||
await toggle.click();
|
||||
expect(await toggle.evaluate((el) => getComputedStyle(el).transform)).toBe('none');
|
||||
const icon = toggle.locator('svg');
|
||||
expect(await icon.evaluate((el) => getComputedStyle(el).transform)).not.toBe('none');
|
||||
await toggle.click();
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
test('should navigate results with floating bar arrows', async ({ page }) => {
|
||||
await goHome(page);
|
||||
await searchFor(page, 'test');
|
||||
|
||||
@@ -160,8 +160,9 @@ test.describe('Split View Matrix — Boutons & menu contextuel', () => {
|
||||
await flyout.click();
|
||||
await page.waitForTimeout(600);
|
||||
// Pane 0 (single) holds both files now; empty source pane auto-collapsed
|
||||
// (+ onglet navigation ouvert au clic sur la racine du vault — #158)
|
||||
await expect(page.locator('.pane-container')).toHaveCount(0);
|
||||
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
|
||||
await expect(page.locator('#tab-list .tab-item')).toHaveCount(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,10 +206,10 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
test('X button on tab closes it', async ({ page }) => {
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
await openFile(page, 'TestVault', 'note2.md');
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
|
||||
await page.locator('.tab-item').first().locator('.tab-close').click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(1);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('double-click on tab closes it', async ({ page }) => {
|
||||
@@ -216,12 +217,15 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await openFile(page, 'TestVault', 'note2.md');
|
||||
await page.locator('.tab-item').first().dblclick();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(1);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2); // reste : onglet navigation (#158)
|
||||
});
|
||||
|
||||
test('Ctrl+W closes active tab (single pane)', async ({ page }) => {
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
await page.keyboard.press('Control+w');
|
||||
await openFile(page, 'TestVault', 'note1.md'); // ouvre aussi l'onglet navigation (#158)
|
||||
await page.keyboard.press('Control+w'); // ferme l'onglet fichier
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(1);
|
||||
await page.keyboard.press('Control+w'); // ferme l'onglet navigation
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(0);
|
||||
await expect(page.locator('#dashboard-home')).toBeVisible();
|
||||
@@ -233,7 +237,7 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await page.locator('.tab-item').first().click({ button: 'right' });
|
||||
await page.locator('#tab-context-menu [data-action="close"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(1);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2); // reste navigation + fichier (#158)
|
||||
});
|
||||
|
||||
test('context menu Fermer les autres keeps one', async ({ page }) => {
|
||||
@@ -243,7 +247,7 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await openFile(page, 'TestVault', 'Accueil.md');
|
||||
await page.waitForTimeout(300);
|
||||
const tabs = page.locator('.tab-item');
|
||||
await expect(tabs).toHaveCount(3);
|
||||
await expect(tabs).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
|
||||
await tabs.nth(1).click({ button: 'right' });
|
||||
await page.locator('#tab-context-menu [data-action="closeOthers"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
@@ -256,7 +260,7 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await page.locator('#header-logo').click(); await page.waitForTimeout(300);
|
||||
await openFile(page, 'TestVault', 'Accueil.md');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(3);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
|
||||
await page.locator('.tab-item').first().click({ button: 'right' });
|
||||
await page.locator('#tab-context-menu [data-action="closeRight"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
@@ -286,8 +290,8 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await pane1.locator('.tab-item').first().locator('.tab-close').click({ force: true });
|
||||
await page.waitForTimeout(700);
|
||||
await expect(page.locator('.pane-container')).toHaveCount(0);
|
||||
// pane 0 file is still open in single mode
|
||||
await expect(page.locator('#tab-list .tab-item')).toHaveCount(1);
|
||||
// pane 0 file + onglet navigation still open in single mode (#158)
|
||||
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('closing pane 0 of 2 focuses neighbour then collapses correctly', async ({ page }) => {
|
||||
@@ -376,7 +380,7 @@ test.describe('Split View Matrix — Edge cases', () => {
|
||||
// dblclick note1 again
|
||||
await page.locator('.tree-item[data-path="note1.md"]').dblclick();
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
|
||||
// it should be the active one
|
||||
const active = page.locator('.tab-item.active');
|
||||
await expect(active).toHaveCount(1);
|
||||
|
||||
@@ -79,7 +79,7 @@ test.describe('Split View — Basic Operations', () => {
|
||||
const tabBar = page.locator('#tab-bar');
|
||||
await expect(tabBar).toBeVisible();
|
||||
const tabs = page.locator('.tab-item');
|
||||
await expect(tabs).toHaveCount(1);
|
||||
await expect(tabs).toHaveCount(2); // navigation (racine vault) + fichier (#158)
|
||||
// Content area should show the file
|
||||
const content = page.locator('#content-area');
|
||||
await expect(content).not.toBeEmpty();
|
||||
@@ -91,7 +91,7 @@ test.describe('Split View — Basic Operations', () => {
|
||||
await page.waitForTimeout(300);
|
||||
await openFile(page, 'TestVault', 'note2.md');
|
||||
const tabs = page.locator('.tab-item');
|
||||
await expect(tabs).toHaveCount(2);
|
||||
await expect(tabs).toHaveCount(4); // accueil + navigation + 2 fichiers (#158)
|
||||
});
|
||||
|
||||
test('split right via keyboard shortcut', async ({ page }) => {
|
||||
@@ -192,7 +192,7 @@ test.describe('Split View — Tab Drag & Drop', () => {
|
||||
// Source pane should have 1 tab, target should have 1 tab
|
||||
const pane0Tabs = page.locator('.pane-container').nth(0).locator('.tab-item');
|
||||
const pane1Tabs = page.locator('.pane-container').nth(1).locator('.tab-item');
|
||||
await expect(pane0Tabs).toHaveCount(1);
|
||||
await expect(pane0Tabs).toHaveCount(2); // navigation + fichier (#158)
|
||||
await expect(pane1Tabs).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -343,12 +343,13 @@ test.describe('Split View — Regression', () => {
|
||||
});
|
||||
|
||||
test('closing last tab shows dashboard', async ({ page }) => {
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
await openFile(page, 'TestVault', 'note1.md'); // ouvre aussi l'onglet navigation (#158)
|
||||
|
||||
// Close tab via middle-click
|
||||
const tab = page.locator('.tab-item').first();
|
||||
await tab.click({ button: 'middle' });
|
||||
await page.waitForTimeout(500);
|
||||
// Close tabs via middle-click until none remains (dernier onglet -> dashboard)
|
||||
while (await page.locator('.tab-item').count()) {
|
||||
await page.locator('.tab-item').first().click({ button: 'middle' });
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Dashboard should be visible
|
||||
const dashboard = page.locator('#dashboard-home');
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -241,17 +245,18 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
// Un clic sur une cellule active la barre avec son nom et son contenu.
|
||||
// (#156-A7 : la zone Nom est un input, donc on lit sa valeur.)
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await cell.click();
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('B2');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B2');
|
||||
const bar = page.locator('#xlsx-formula-input');
|
||||
await expect(bar).toHaveValue(/Operation 1/);
|
||||
|
||||
// Les flèches déplacent la cellule active.
|
||||
await cell.press('ArrowDown');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('B3');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B3');
|
||||
await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('C3');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveValue('C3');
|
||||
|
||||
// Éditer depuis la barre marque la cellule dirty, Échap annule.
|
||||
await bar.fill('Operation 2 modifiee');
|
||||
@@ -260,3 +265,140 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
||||
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #156 P1 — presse-papiers de plage, clavier complet, zone Nom ────────────
|
||||
|
||||
test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)', () => {
|
||||
test('coller une plage, la navigation clavier et la zone Nom (#156 A5/A6/A7)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const nameBox = page.locator('#xlsx-active-cell');
|
||||
const cellOf = (ref) => page.locator(`#content-area td[data-cell="${ref}"]`);
|
||||
const textOf = async (ref) => (await cellOf(ref).innerText()).trim();
|
||||
|
||||
// — A7 : la zone Nom est éditable et sert à « Atteindre ».
|
||||
await nameBox.fill('B12');
|
||||
await nameBox.press('Enter');
|
||||
await expect(nameBox).toHaveValue('B12');
|
||||
await expect(cellOf('B12')).toHaveClass(/xlsx-active/);
|
||||
|
||||
// — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins.
|
||||
await cellOf('B12').click();
|
||||
await page.keyboard.press('Control+a');
|
||||
await expect(nameBox).toHaveValue('A1:C500');
|
||||
await page.keyboard.press('Control+Home');
|
||||
await expect(nameBox).toHaveValue('A1');
|
||||
await page.keyboard.press('Control+End');
|
||||
await expect(nameBox).toHaveValue('C500');
|
||||
|
||||
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
|
||||
// utilise le presse-papiers interne de la grille : aucune permission
|
||||
// navigateur n'est requise.
|
||||
const src = { a1: await textOf('A1'), b1: await textOf('B1'), b2: await textOf('B2'), c10: await textOf('C10') };
|
||||
await cellOf('A1').click();
|
||||
await cellOf('B2').click({ modifiers: ['Shift'] });
|
||||
await expect(nameBox).toHaveValue('A1:B2');
|
||||
await cellOf('A1').click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'Copier' }).click();
|
||||
await cellOf('A10').click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'Coller' }).click();
|
||||
expect(await textOf('A10')).toBe(src.a1);
|
||||
expect(await textOf('B10')).toBe(src.b1);
|
||||
expect(await textOf('B11')).toBe(src.b2);
|
||||
expect(await textOf('C10')).toBe(src.c10);
|
||||
|
||||
// — A6 : Suppr vide la sélection (et seulement elle).
|
||||
await cellOf('A10').click();
|
||||
await cellOf('B11').click({ modifiers: ['Shift'] });
|
||||
await expect(nameBox).toHaveValue('A10:B11');
|
||||
await page.keyboard.press('Delete');
|
||||
expect(await textOf('A10')).toBe('');
|
||||
expect(await textOf('B11')).toBe('');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+15
-26
@@ -159,7 +159,8 @@ async function main() {
|
||||
read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }),
|
||||
};
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
if (String(url).includes("/api/ai/bookslm/chat")) {
|
||||
// #187: plain-text sends now go to /agent — accept both bookslm endpoints.
|
||||
if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) {
|
||||
capturedBody = JSON.parse(opts.body);
|
||||
return { ok: true, status: 200, body: { getReader: () => reader } };
|
||||
}
|
||||
@@ -1171,38 +1172,28 @@ async function main() {
|
||||
assert.equal(b._messages[0].content, "Ancienne conversation");
|
||||
});
|
||||
|
||||
// ── 14. Agent mode & confirmations (B5) ──
|
||||
await test("agent mode toggle persists and switches the endpoint", async () => {
|
||||
// ── 14. Agent mode & confirmations (B5 / #187) ──
|
||||
await test("#187: no agent toggle — plain requests always target /agent", async () => {
|
||||
localStorage.clear();
|
||||
const b = new BooksLM();
|
||||
assert.equal(b._agentMode, false, "agent mode off by default");
|
||||
b._toggleAgentMode();
|
||||
assert.equal(b._agentMode, true);
|
||||
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true");
|
||||
const b2 = new BooksLM();
|
||||
assert.equal(b2._agentMode, true, "persisted across instances");
|
||||
assert.equal(b._agentMode, undefined, "agent-mode state removed");
|
||||
assert.equal(b._toggleAgentMode, undefined, "toggle method removed");
|
||||
|
||||
let url = null;
|
||||
globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; };
|
||||
b2._abortCtrl = null;
|
||||
await b2._postChat({ message: "x" });
|
||||
assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent");
|
||||
b2._agentMode = false;
|
||||
await b2._postChat({ message: "x" });
|
||||
assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat");
|
||||
b._abortCtrl = null;
|
||||
await b._postChat({ message: "x" });
|
||||
assert.ok(url.includes("/api/ai/bookslm/agent"), "default targets /agent");
|
||||
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), null, "no legacy persistence");
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
await test("header exposes the agent-mode toggle", () => {
|
||||
await test("header no longer exposes the agent-mode toggle", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
const btn = panel.querySelector(".bookslm-btn-agent");
|
||||
assert.ok(btn, "agent toggle button present");
|
||||
assert.equal(btn.getAttribute("aria-pressed"), "false");
|
||||
btn.click();
|
||||
assert.equal(btn.getAttribute("aria-pressed"), "true");
|
||||
assert.equal(panel.querySelector(".bookslm-btn-agent"), null, "toggle button removed");
|
||||
panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -1236,7 +1227,6 @@ async function main() {
|
||||
};
|
||||
|
||||
const b = new BooksLM();
|
||||
b._agentMode = true;
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
const msg = {
|
||||
@@ -1418,7 +1408,7 @@ async function main() {
|
||||
|
||||
let posted = null;
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body);
|
||||
if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) posted = JSON.parse(opts.body);
|
||||
return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
|
||||
};
|
||||
await b._sendMessage();
|
||||
@@ -1432,10 +1422,9 @@ async function main() {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ──
|
||||
await test("images route to /chat even in agent mode", async () => {
|
||||
// ── 32. Images force the plain chat endpoint (#81 / #187: always-on agent) ──
|
||||
await test("images route to /chat, text to /agent", async () => {
|
||||
const b = new BooksLM();
|
||||
b._agentMode = true;
|
||||
let url = "";
|
||||
globalThis.fetch = async (u) => {
|
||||
url = String(u);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — pure tests for the navigation page facets/filters (#158):
|
||||
* `frontend/js/navfacets.js` (no DOM, real module import).
|
||||
*
|
||||
* Usage: node tests/frontend/navfacets.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from "../../frontend/js/navfacets.js";
|
||||
|
||||
const files = [
|
||||
{ name: "note2.md", path: "note2.md", vault: "V1", extension: "md", modified: 100, tags: ["docker"] },
|
||||
{ name: "note1.md", path: "note1.md", vault: "V1", extension: "md", modified: 300, tags: ["python", "docker"] },
|
||||
{ name: "photo.png", path: "sub/photo.png", vault: "V1", extension: ".PNG", modified: 200, tags: [] },
|
||||
{ name: "sans-titre.md", path: "sans-titre.md", vault: "V1", extension: "md", modified: 400 },
|
||||
];
|
||||
|
||||
let pass = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
pass++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
test("buildNavFacets counts vaults/tags/extensions, sorted desc", () => {
|
||||
const f = buildNavFacets(files);
|
||||
assert.deepStrictEqual(f.vaults, { V1: 4 });
|
||||
assert.deepStrictEqual(f.tags, { docker: 2, python: 1 });
|
||||
// leading dot stripped + lowercased, untagged file contributes nothing
|
||||
assert.deepStrictEqual(f.extensions, { md: 3, png: 1 });
|
||||
});
|
||||
|
||||
test("buildNavFacets tolerates empty/missing input", () => {
|
||||
assert.deepStrictEqual(buildNavFacets([]), { vaults: {}, tags: {}, extensions: {} });
|
||||
assert.deepStrictEqual(buildNavFacets(undefined), { vaults: {}, tags: {}, extensions: {} });
|
||||
});
|
||||
|
||||
test("filterNavFiles: no filter returns the input untouched", () => {
|
||||
assert.strictEqual(filterNavFiles(files, {}), files);
|
||||
assert.strictEqual(filterNavFiles(files, { tag: "", ext: "" }), files);
|
||||
});
|
||||
|
||||
test("filterNavFiles: tag filter keeps files carrying the tag", () => {
|
||||
const out = filterNavFiles(files, { tag: "docker" });
|
||||
assert.deepStrictEqual(out.map((f) => f.name).sort(), ["note1.md", "note2.md"]);
|
||||
});
|
||||
|
||||
test("filterNavFiles: extension filter is dot-insensitive", () => {
|
||||
assert.deepStrictEqual(filterNavFiles(files, { ext: "png" }).map((f) => f.name), ["photo.png"]);
|
||||
assert.deepStrictEqual(filterNavFiles(files, { ext: ".png" }).map((f) => f.name), ["photo.png"]);
|
||||
});
|
||||
|
||||
test("filterNavFiles: tag + ext combine (AND) and can empty the list", () => {
|
||||
assert.deepStrictEqual(filterNavFiles(files, { tag: "docker", ext: "md" }).map((f) => f.name).sort(), ["note1.md", "note2.md"]);
|
||||
assert.deepStrictEqual(filterNavFiles(files, { tag: "python", ext: "png" }), []);
|
||||
});
|
||||
|
||||
test("sortNavFiles 'modified' = newest first", () => {
|
||||
assert.deepStrictEqual(sortNavFiles(files, "modified").map((f) => f.modified), [400, 300, 200, 100]);
|
||||
});
|
||||
|
||||
test("sortNavFiles 'relevance' = alphabetical, .md stripped, case-insensitive", () => {
|
||||
const names = sortNavFiles(files, "relevance").map(navDisplayName);
|
||||
assert.deepStrictEqual(names, ["note1", "note2", "photo.png", "sans-titre"]);
|
||||
});
|
||||
|
||||
test("sortNavFiles does not mutate its input", () => {
|
||||
const before = files.map((f) => f.name);
|
||||
sortNavFiles(files, "relevance");
|
||||
sortNavFiles(files, "modified");
|
||||
assert.deepStrictEqual(files.map((f) => f.name), before);
|
||||
});
|
||||
|
||||
test("navDisplayName strips only a trailing .md", () => {
|
||||
assert.strictEqual(navDisplayName({ name: "a.md" }), "a");
|
||||
assert.strictEqual(navDisplayName({ name: "a.markdown" }), "a.markdown");
|
||||
assert.strictEqual(navDisplayName({ path: "dir/b.md" }), "b");
|
||||
});
|
||||
|
||||
console.log(`\n${pass} passed`);
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate - JSDOM tests for the search results facets panel (#157):
|
||||
* vault, tag and file-extension facets rendered by
|
||||
* `renderAdvancedSearchResults()` (frontend/js/search.js).
|
||||
*
|
||||
* Covers:
|
||||
* - the three facet groups are rendered (Vaults / Tags / Extensions)
|
||||
* - the extensions facet appears even when tags and vaults are empty
|
||||
* - clicking an extension replaces any `ext:` operator in the search
|
||||
* input and triggers a new search
|
||||
*
|
||||
* Usage: node tests/frontend/search-facets.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 JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html><body>
|
||||
<input id="search-input" value="" />
|
||||
<select id="vault-filter"><option value="all">all</option></select>
|
||||
<div id="results-bar" style="display:none"></div>
|
||||
<span id="rb-count"></span>
|
||||
<div id="content-area"></div>
|
||||
</body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.KeyboardEvent = w.KeyboardEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
globalThis.MutationObserver = w.MutationObserver;
|
||||
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Backend payload shape for /api/search/advanced (facets only matter here).
|
||||
const PAYLOAD = {
|
||||
results: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
query_time_ms: 1,
|
||||
facets: {
|
||||
vaults: { IT: 4, Recettes: 2 },
|
||||
tags: { linux: 3 },
|
||||
extensions: { md: 5, pdf: 2, xlsx: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => PAYLOAD,
|
||||
});
|
||||
|
||||
const { renderAdvancedSearchResults } = await import(
|
||||
pathToFileURL(path.join(JS_DIR, "search.js")).href
|
||||
);
|
||||
|
||||
const facetGroups = () =>
|
||||
Array.from(document.querySelectorAll(".search-facets__group"));
|
||||
const extGroup = () =>
|
||||
document.querySelector('.search-facets__group[data-facet="extensions"]');
|
||||
const extItems = () =>
|
||||
Array.from(extGroup()?.querySelectorAll(".search-facets__item") || []);
|
||||
|
||||
console.log("Search facets (#157) — Vaults / Tags / Extensions");
|
||||
|
||||
await test("renders the three facet groups", async () => {
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
assert.equal(facetGroups().length, 3, "vault, tag and extension groups");
|
||||
const labels = facetGroups().map((g) =>
|
||||
g.querySelector(".search-facets__label").textContent,
|
||||
);
|
||||
assert.deepEqual(labels, ["Vaults", "Tags", "Extensions"]);
|
||||
});
|
||||
|
||||
await test("lists extensions with their counts", async () => {
|
||||
const texts = extItems().map((i) => i.textContent.trim());
|
||||
assert.deepEqual(texts, [".md 5", ".pdf 2", ".xlsx 1"]);
|
||||
assert.equal(
|
||||
extItems()[0].querySelector(".facet-count").textContent,
|
||||
"5",
|
||||
"count lives in .facet-count",
|
||||
);
|
||||
});
|
||||
|
||||
await test("extension facet shows even with empty tags and vaults", async () => {
|
||||
renderAdvancedSearchResults(
|
||||
{ ...PAYLOAD, facets: { vaults: {}, tags: {}, extensions: { md: 1 } } },
|
||||
"",
|
||||
null,
|
||||
);
|
||||
assert.ok(extGroup(), "extensions group rendered alone");
|
||||
assert.equal(extItems().length, 1);
|
||||
});
|
||||
|
||||
await test("clicking an extension adds the ext: operator", async () => {
|
||||
document.getElementById("search-input").value = "python ext:md";
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
extItems()[1].click(); // .pdf
|
||||
assert.equal(
|
||||
document.getElementById("search-input").value,
|
||||
"python ext:pdf",
|
||||
"existing ext: operator replaced, not duplicated",
|
||||
);
|
||||
});
|
||||
|
||||
await test("facets panel collapses and restores via the toggle", async () => {
|
||||
localStorage.removeItem("obsigate_facets_collapsed");
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
const panel = document.querySelector(".search-facets");
|
||||
const toggle = panel.querySelector(".search-facets__toggle");
|
||||
assert.ok(toggle, "collapse toggle rendered");
|
||||
assert.equal(panel.getAttribute("data-collapsed"), "false", "expanded by default");
|
||||
assert.ok(panel.querySelector(".search-facets__body"), "groups live in the body");
|
||||
|
||||
toggle.click();
|
||||
assert.equal(panel.getAttribute("data-collapsed"), "true", "click collapses");
|
||||
assert.equal(
|
||||
localStorage.getItem("obsigate_facets_collapsed"),
|
||||
"1",
|
||||
"collapsed state persisted",
|
||||
);
|
||||
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
const panel2 = document.querySelector(".search-facets");
|
||||
assert.equal(
|
||||
panel2.getAttribute("data-collapsed"),
|
||||
"true",
|
||||
"collapsed state survives a re-render",
|
||||
);
|
||||
panel2.querySelector(".search-facets__toggle").click();
|
||||
assert.equal(panel2.getAttribute("data-collapsed"), "false", "toggle restores");
|
||||
localStorage.removeItem("obsigate_facets_collapsed");
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -109,6 +109,7 @@ function testModulesHaveImports() {
|
||||
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
|
||||
{ file: 'editor-inline.js', reason: 'standalone — DOM-only helpers for the inline editor host (#93)' },
|
||||
{ file: 'themes.js', reason: 'standalone — no deps' },
|
||||
{ file: 'navfacets.js', reason: 'pure helpers — no deps, imported by vaulthome.js' },
|
||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
||||
];
|
||||
@@ -306,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;
|
||||
@@ -323,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);
|
||||
+1019
-16
File diff suppressed because it is too large
Load Diff
@@ -940,6 +940,31 @@ class TestBooksLMAgentEndpoint:
|
||||
assert "create_file" in captured["system"]
|
||||
assert '"action": "create_file"' not in captured["system"]
|
||||
|
||||
def test_agent_llm_uses_resolved_provider(self, bookslm_client, monkeypatch):
|
||||
"""#187: the agent loop must call the provider resolved by
|
||||
_resolve_provider_name (not the raw request value), so the provider
|
||||
tag reported by SSE matches the engine that actually answered."""
|
||||
import backend.bookslm_routes as routes
|
||||
from backend.ai_chat import LLMResponse
|
||||
|
||||
seen = {}
|
||||
|
||||
async def fake_chat_completion(messages, **kwargs):
|
||||
seen["provider"] = kwargs.get("provider")
|
||||
return LLMResponse(content="ok")
|
||||
|
||||
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
|
||||
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "openrouter")
|
||||
|
||||
token, _ = _login_bookslm(bookslm_client)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/agent",
|
||||
json={"directory": "", "message": "salut", "mode": "general", "provider": "nope"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert seen["provider"] == "openrouter"
|
||||
|
||||
def test_agent_tool_call_flow(self, bookslm_client, monkeypatch):
|
||||
import backend.bookslm_routes as routes
|
||||
from backend.ai_chat import LLMResponse, ToolCall
|
||||
|
||||
@@ -252,7 +252,9 @@ class TestDependencySecurityFloors:
|
||||
#: (paquet, plancher minimal, advisories corrigées au-dessus)
|
||||
FLOORS = {
|
||||
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
|
||||
"pyjwt": (2, 13, 0), # PYSEC-2026-178 (fix 2.13.0)
|
||||
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
|
||||
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
|
||||
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
|
||||
}
|
||||
|
||||
def test_security_floors_are_declared(self):
|
||||
|
||||
@@ -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,49 @@
|
||||
# tests/test_nav_files.py — Navigation page listing (#158)
|
||||
#
|
||||
# The vault home / navigation page lists a directory with its indexed tags so
|
||||
# the Vaults · Tags · Extensions facets can filter the listing client-side.
|
||||
# The tags must survive the endpoint's response_model.
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_vault_files_expose_indexed_tags(client):
|
||||
"""Every entry carries a `tags` list; markdown tags come from the index."""
|
||||
resp = client.get("/api/vault/TestVault/files", params={"recursive": False})
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
files = data["files"]
|
||||
assert files, "expected a non-empty listing"
|
||||
|
||||
# The field is always present (response_model must not strip it)
|
||||
assert all("tags" in f for f in files)
|
||||
|
||||
by_name = {f["name"]: f for f in files}
|
||||
assert "note1.md" in by_name
|
||||
assert sorted(by_name["note1.md"]["tags"]) == ["python", "tutorial"]
|
||||
|
||||
# Unindexed / tagless files keep an empty list, never null
|
||||
assert sorted(by_name["note2.md"]["tags"]) == ["devops", "docker"]
|
||||
for f in files:
|
||||
assert isinstance(f["tags"], list)
|
||||
|
||||
|
||||
def test_vault_files_tags_in_subdirectory(client):
|
||||
"""Tags are resolved per directory listing, not only at the vault root."""
|
||||
resp = client.get(
|
||||
"/api/vault/TestVault/files",
|
||||
params={"dir": "Projets", "recursive": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
files = resp.json()["files"]
|
||||
by_name = {f["name"]: f for f in files}
|
||||
assert "projet.md" in by_name
|
||||
assert sorted(by_name["projet.md"]["tags"]) == ["projet", "python"]
|
||||
|
||||
|
||||
def test_indexed_tags_unknown_vault_returns_empty():
|
||||
"""Index miss (unknown vault/file) degrades to an empty tag list."""
|
||||
from backend.services.vaults import _indexed_tags
|
||||
|
||||
assert _indexed_tags("NoSuchVault", "ghost.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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user