Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e01e837a2a | ||
|
|
634ba8a272 | ||
|
|
ce1944d437 | ||
|
|
39bad990c0 | ||
|
|
431d6e9338 | ||
|
|
d453969708 | ||
|
|
778fa65b4c | ||
|
|
69c3817579 | ||
|
|
b89af917b1 | ||
|
|
d79202e698 | ||
|
|
4ce677902a | ||
|
|
fe9f7b49f7 | ||
|
|
649f965c52 | ||
|
|
d6fa8c7bb1 | ||
|
|
d2734dc8ce | ||
|
|
bab272bb5c | ||
|
|
34428ea7b3 | ||
|
|
83e6a951dd | ||
|
|
3d618eb660 |
+11
-1
@@ -39,6 +39,7 @@ jobs:
|
||||
run: |
|
||||
node tests/frontend/unit.test.mjs
|
||||
node tests/frontend/navfacets.test.mjs
|
||||
node tests/frontend/desktop-roots.test.mjs
|
||||
node tests/frontend/image-viewer.test.mjs
|
||||
node tests/frontend/pdf-viewer.test.mjs
|
||||
node tests/frontend/forge-completion.test.mjs
|
||||
@@ -180,13 +181,22 @@ jobs:
|
||||
# pypdf>=6.16.1 (BUG-093).
|
||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||
# corrigés par le plancher urllib3>=2.8.0.
|
||||
# CVE-2026-104874 (multidict 6.7.x) corrigé par le plancher
|
||||
# multidict>=6.9.1 (transitive aiohttp/yarl ; 6.7.x est dans la
|
||||
# toolcache de l'image du runner — même piège « already satisfied »).
|
||||
# CVE-2026-85394 (python-jose ≤3.5.0, forgery HS256 par clé publique
|
||||
# DER passée comme secret HMAC) : AUCUN correctif upstream (projet
|
||||
# sans release depuis 2025). Non atteignable dans ObsiGate :
|
||||
# jwt.decode passe toujours algorithms=["HS256"] et un secret
|
||||
# symétrique serveur (backend/auth/jwt_handler.py,
|
||||
# backend/mcp/confirmations.py) — jamais une clé publique comme clé.
|
||||
# Ces planchers doivent rester *au-dessus* des versions préinstallées
|
||||
# dans la toolcache de l'image du runner : en dessous, pip répond
|
||||
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
|
||||
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
|
||||
# TestDependencySecurityFloors verrouille ces planchers.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325 --ignore-vuln CVE-2026-85394
|
||||
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
|
||||
+505
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.49.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.55.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,510 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.55.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#188 - Secrets masqués : couverture universelle + clic pour copier**
|
||||
- **Détection élargie** (`backend/secret_redactor.py`) : les formats de
|
||||
clés les plus répandus sont masqués d'office — OpenAI/Anthropic/
|
||||
OpenRouter (`sk-`), Stripe (`sk_live_`, `whsec_`), GitLab (`glpat-`),
|
||||
GitHub (`ghp_`, `github_pat_`), Google (`AIza…`, `ya29.`), AWS
|
||||
(`AKIA`/`ASIA`), Slack (`xoxb-`), SendGrid, Hugging Face, npm, Docker,
|
||||
Resend, Square, Atlassian, Discord, Telegram, jetons `Bearer …` — en
|
||||
plus des JWT, clés privées et connection strings déjà couverts ; le
|
||||
plancher des affectations `api_key=`/`token=`/`secret=` passe de 20 à
|
||||
8 caractères.
|
||||
- **Mots de passe masqués à leur tour** : `password=`, `"passwd": "…"`,
|
||||
`db_password=`, `passphrase=`, `mot de passe=`… toute longueur et
|
||||
toute quote, avec le libellé `[MOT DE PASSE MASQUÉ]` ; `PWD=` (dossier
|
||||
de travail shell) reste épargné.
|
||||
- **Clic sur un masque = copie de la valeur** : l'aperçu authentifié
|
||||
sert les masques en `<span class="secret-mask" data-secret="…">` — la
|
||||
valeur n'apparaît jamais en clair et un clic la copie dans le
|
||||
presse-papiers (toast + infobulle FR/EN). Les masques passent par des
|
||||
placeholders qui survivent à la conversion markdown (blocs de code
|
||||
compris), où un `<span>` serait affiché en toutes lettres ; les blocs
|
||||
de code masqués ne sont plus re-colorés (highlight.js perdrait le
|
||||
badge).
|
||||
- **Aucune fuite à l'extérieur** : partages publics, exports PDF et
|
||||
contexte IA / MCP ne reçoivent que le libellé, jamais la valeur.
|
||||
- Guide in-app FR/EN + `docs/GUIDES/AUTHENTIFICATION_SECURITE.md`
|
||||
(nouvelle section « Secrets masqués dans les aperçus »), README FR/EN,
|
||||
tests `TestSecretRedactor` (+33).
|
||||
|
||||
---
|
||||
|
||||
## [2.54.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#186 - Création d'un fichier Excel depuis « Créer un fichier »**
|
||||
- **Excel (`.xlsx`) fait partie des types proposés** dans la modale « Créer un
|
||||
fichier » (menu contextuel d'un vault ou d'un dossier, palette de commandes) :
|
||||
le classeur s'ouvre directement dans la visionneuse, prêt à être saisi. Les 10
|
||||
libellés du sélecteur passent en i18n FR/EN (`modal.ext_*`) — ils étaient
|
||||
codés en dur en français.
|
||||
- **Un vrai classeur, pas un fichier vide.** Un `.xlsx` est une archive OPC, pas
|
||||
du texte : `create_file()` construit désormais l'archive avec openpyxl (une
|
||||
feuille `Feuille1`) au lieu d'écrire la charge utile vide de la modale, qui
|
||||
aurait produit un fichier que ni la visionneuse ni Excel n'ouvrent. Le champ
|
||||
`content` est ignoré pour cette extension.
|
||||
|
||||
- **#183 — Panneau de sélection de couleurs façon Google Sheets (texte et fond)**
|
||||
- Le ruban et le menu **Mise en forme** n'utilisent plus le sélecteur natif :
|
||||
ils ouvrent un **panneau** conforme à la description fournie —
|
||||
*Réinitialiser* en en-tête, palette **8 × 10** (ligne 1 = 8 nuances de gris
|
||||
du noir au blanc, lignes 2 à 10 = 8 teintes × 9 nuances du clair au foncé),
|
||||
section **STANDARD** (8 couleurs prédéfinies) avec crayon, section
|
||||
**PERSONNALISÉ** (pastille + bouton « + » + crayon), pied de page
|
||||
**Mise en forme conditionnelle** / **Couleurs en alternance**.
|
||||
- Un **clic sur une pastille referme le panneau** et applique la couleur à la
|
||||
cellule ou à la plage sélectionnée ; l'indicateur de sélection est une
|
||||
coche **blanche ou noire selon la luminance** de la pastille.
|
||||
- **Correction d'un piège d'encodage** : la palette est calculée en HSL pour
|
||||
la lisibilité mais sort en **`#rrggbb`**. Une valeur `hsl(...)` était
|
||||
silencieusement rejetée par `normHex()` — la couleur n'était donc jamais
|
||||
écrite sur la cellule.
|
||||
- Le chrome du panneau suit le **thème actif** (variables CSS uniquement) ;
|
||||
« + » et les crayons ouvrent le sélecteur natif pour une couleur
|
||||
arbitraire.
|
||||
- Les deux entrées de pied de page sont **annoncées mais pas encore
|
||||
disponibles** : elles affichent un message explicite plutôt qu'un bouton
|
||||
inerte.
|
||||
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#185 - Tableur : barre de menus Google Sheets, boutons façon Sheets, grille unie**
|
||||
- **Les dix menus de Google Sheets sont là** : Fichier, Modifier, Affichage,
|
||||
Insertion, Format, Données, Outils, Gemini, Extensions, Aide — dans l'ordre
|
||||
et avec les libellés exacts de Sheets (161 entrées). Le nouveau module
|
||||
`frontend/js/xlsx/menus.js` décrit l'arborescence, `viewer.js` ne fait que
|
||||
brancher chaque entrée sur un flux existant. Les sections imbriquées
|
||||
(Nombre, Thème, Alignement, Zoom, Nettoyage…) sont des **accordéons**, une
|
||||
seule ouverte à la fois, comme dans Sheets.
|
||||
- **La majorité des fonctions sont opérationnelles** : Partager, Télécharger
|
||||
(original / CSV / Markdown / HTML), Imprimer, **Renommer**, **Déplacer vers
|
||||
la corbeille**, **Historique des versions**, Détails ; Annuler, Rétablir,
|
||||
Couper, Copier, Coller, **Collage spécial (valeurs uniquement)**,
|
||||
**Supprimer (lignes / colonnes / contenu)**, Tout sélectionner,
|
||||
**Rechercher et remplacer** ; **Figer** (ici / ligne / colonne), **Afficher
|
||||
→ barre de formule · grille · en-têtes · tableau de bord**, **Zoom**,
|
||||
**Plein écran** ; Lignes / Colonnes / Cellules, Lien, Commentaire,
|
||||
**Fonction** (curseur placé entre les parenthèses) ; Nombre, **Thème**,
|
||||
Gras / Italique / Souligné / Barré, Taille de police, Alignement, Retour à la
|
||||
ligne, Rotation, **Couleurs alternées**, Effacer la mise en forme ; Trier,
|
||||
**Créer un filtre**, **Plages nommées**, **Nettoyage des données
|
||||
(doublons / espaces superflus)**, **Diviser le texte en colonnes** ;
|
||||
**Saisie semi-automatique**, **Accessibilité** ; Gemini (6 entrées vers
|
||||
l'assistant IA) ; Aide, **Mises à jour**, **Raccourcis clavier**,
|
||||
**Signaler un problème** (diagnostic copié dans le presse-papiers).
|
||||
- **Les options qu'ObsiGate n'a pas le disent** : les 36 entrées absentes
|
||||
(macros, Apps Script, tableaux croisés, graphiques, formulaires, modules,
|
||||
AppSheet, Looker Studio, formation…) restent **listées** pour tenir la
|
||||
parité demandée, mais un clic y répond « *« X » n'est pas disponible dans
|
||||
ObsiGate* » au lieu de ne rien faire.
|
||||
- **Le menu Fichier a quitté la barre d'onglets** pour la barre de menus, en
|
||||
**conservant ses identifiants** (`#xlsx-file-btn`, `#xlsx-file-menu`,
|
||||
`#xlsx-download-btn`, `#xlsx-csv-btn`) : seuls les raccourcis internes et les
|
||||
tests bougent. Seul **Enregistrer** reste épinglé à droite des onglets.
|
||||
- **Ruban refait façon Google Sheets** : boutons plats de 30 × 30, sans bordure
|
||||
ni fond au repos, survol discret, état actif teinté, groupes séparés par des
|
||||
filets verticaux. **Enregistrer** reste le seul pilier plein. Nouveau bouton
|
||||
**grille**, synchronisé avec Affichage ▸ Afficher ▸ Grille.
|
||||
- **Grille unie et visible** : chaque cellule est encadrée sur **les quatre
|
||||
côtés** par **la même** ligne de 1 px, en-têtes compris (ils se distinguent
|
||||
par leur fond, plus par une bordure plus lourde). `--grid-border` n'est plus
|
||||
`--border`, quasi identique au fond des cellules sur la plupart des thèmes :
|
||||
c'est désormais un `color-mix` du texte vers la surface, calculé par
|
||||
`themes.js`, donc une seule valeur pour toute la grille et lisible sur les 15
|
||||
thèmes comme sur les modes contraste élevé et sépia.
|
||||
|
||||
- **#184 - Tableur : peinture de format complète, sélection de lignes/colonnes
|
||||
multiples, grille A → Z × 1000 lignes**
|
||||
- **Peindre le format ne ratait plus aucun cas.** L'outil copiait un
|
||||
sous-ensemble de propriétés et **refusait** une cellule source sans mise en
|
||||
forme explicite — donc une cellule simple vers une cellule colorée et en
|
||||
grande taille ne produisait rien. La copie porte maintenant sur **toute** la
|
||||
mise en forme (gras, italique, souligné, barré, couleur du texte, couleur de
|
||||
fond, taille, alignement, valign, retour ligne, rotation, format de nombre,
|
||||
bordures) ; une source **sans** format **réinitialise** la cible au lieu de
|
||||
ne rien faire.
|
||||
- **Fuite corrigée** : annuler la peinture (`Échap`, second clic) laissait le
|
||||
gestionnaire « appliquer au prochain clic » en place — le clic suivant dans la
|
||||
grille repeignait une vieille mise en forme. Il est maintenant désinstallé.
|
||||
- **Sélection de plusieurs lignes / colonnes depuis les marges**, comme dans
|
||||
Sheets et Excel : glisser sur les numéros de ligne (ou `Maj`+clic) sélectionne
|
||||
une plage de lignes entières ; idem sur les lettres de colonne. Le
|
||||
redimensionnement par les bords d'en-tête reste prioritaire sur ce geste.
|
||||
- **Grille étendue à A → Z, et jusqu'à 1000 lignes**, les cellules vides
|
||||
étant de **vraies cellules éditables**. Les colonnes sont posées d'emblée ;
|
||||
les lignes s'ajoutent **par blocs de 100 au défilement**, et non d'un seul
|
||||
coup : matérialiser 1000 lignes revient à ~26 000 cellules câblées par
|
||||
feuille, ce qui épuisait le tas de la suite JSDOM. Une feuille tronquée
|
||||
garde son chargement serveur « Charger la suite » (les lignes manquantes
|
||||
sont des données, pas du remplissage).
|
||||
- **Trois goulots d'étranglement levés**, sinon la grille étendue rendait
|
||||
l'éditeur inutilisable : `findTd` faisait un `querySelector` par appel
|
||||
(sélectionner une colonne = 26 000 requêtes) → **index de cellules** avec
|
||||
auto-réparation ; le rafraîchissement des formules clonait 26 000 nœuds à
|
||||
chaque frappe → **court-circuit** sur les cellules vides ; l'application des
|
||||
styles touchait 26 000 cellules → **court-circuit** sur celles que le
|
||||
classeur ne style pas.
|
||||
- Marqueur `data-wired` explicite : `data-orig` valait `""` pour une cellule
|
||||
vide, ce qui recâblait les cellules déjà câblées (double écouteur).
|
||||
|
||||
- **#181 — Éditeur tableur : modèle de saisie Google Sheets (sélection ≠ édition)**
|
||||
- **Deux états distincts.** Une cellule est *sélectionnée* (clic, flèches) ou
|
||||
*en cours d'édition*, et plus les deux en même temps. Le curseur de texte
|
||||
n'apparaît plus en sélection (`caret-color: transparent`, sans quitter
|
||||
`contenteditable`) : on ne voit plus un curseur clignotant au clic ou aux
|
||||
flèches.
|
||||
- **Entrées en édition**, comme Google Sheets / Excel : **double-clic** (le
|
||||
curseur est placé *sous le pointeur*, l'édition se fait en place), **première
|
||||
frappe** — qui **remplace** le contenu au lieu de s'y ajouter —, ou `F2`.
|
||||
`Alt`+flèche insère un saut de ligne, `Échap` quitte l'édition et restaure
|
||||
la valeur.
|
||||
- **`Entrée` contextuelle** : en édition, elle valide et déplace le focus sur
|
||||
**la cellule du dessous** (au lieu de perdre le focus) ; en sélection, elle
|
||||
bascule la cellule courante en mode édition. `Tab` suit la même logique.
|
||||
- **Indicateur de sélection renforcé** : la plage est **contourée** comme dans
|
||||
Excel / Sheets (quatre classes de bord sur les côtés réels du bloc, pas un
|
||||
simple lavis), et les en-têtes couverts — lettres `A`, `B`… et numéros
|
||||
`1`, `2`… — sont reflétés.
|
||||
- Un clic simple après une édition abandonne l'état d'édition : plus de
|
||||
curseur résiduel.
|
||||
- **Inventaire des écarts** vs Google Sheets / Excel dressé et priorisé
|
||||
(poignée de recopie, *remplacer*, validation de données, filtre
|
||||
automatique, tri multi-critères, mise en forme conditionnelle, création de
|
||||
graphiques, protection…) : [fiche #181](docs/features/xlsx-sheet-input-181.md).
|
||||
- Tests JSDOM : `xlsx-viewer` passe de 136 à **146/146** (10 cas #181).
|
||||
|
||||
- **#180 — Éditeur tableur : redimensionnement au curseur, grille thémée, menu
|
||||
Fichier & barre épinglée**
|
||||
- **Curseur de redimensionnement.** Survoler le bord d'un en-tête de colonne
|
||||
(`A`, `B`, `C`…) ou de ligne (`1`, `2`, `3`…) affiche désormais l'icône
|
||||
`col-resize` / `row-resize`. Le curseur par défaut des cellules
|
||||
(`cell`) et des en-têtes (`pointer`) masquait auparavant l'indicateur, y
|
||||
compris pendant le glissement : les en-têtes concerned sont désormais
|
||||
miroirés en ligne et la table porte une classe de repaint
|
||||
(`.xlsx-resize-col` / `.xlsx-resize-row`).
|
||||
- **Grille compatible tous les thèmes.** Les jetons de grille
|
||||
(`--grid-*`) sont dérivés du thème actif (`--surface`, `--bg-hover`,
|
||||
`--text-primary`, `--border`, bandeau zébré en `color-mix`) : les
|
||||
15 thèmes × 4 modes de l'application, y compris les modes générés
|
||||
contrasté élevé / sépia et les thèmes importés, sont désormais
|
||||
respectés, plus les couleurs figées du thème par défaut.
|
||||
- **Menu Fichier.** Télécharger, CSV, Markdown, HTML et Imprimer sont
|
||||
réunis dans un seul menu **Fichier**, placé à droite de la barre des
|
||||
onglets de feuilles, avec le bouton **Enregistrer** juste à côté. Le
|
||||
ruban conserve les commandes d'édition. Les identifiants des contrôles
|
||||
(`#xlsx-download-btn`, `#xlsx-csv-btn`, `#xlsx-save-btn`) sont
|
||||
inchangés.
|
||||
- **Barre épinglée pleine largeur.** La barre de boutons est épinglée en
|
||||
haut, au bord de la marge de la page, et l'éditeur occupe toute la
|
||||
largeur du contenu (comme les visionneuses PDF / images / Excalidraw).
|
||||
- **Indicateur retiré.** La pastille « Formules calculées localement »
|
||||
disparaît : le calcul local est le comportement normal, pas une limite
|
||||
worthy of a badge.
|
||||
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#182 — Éditeur tableur : les encadrés de la sélection s'empilaient et ne
|
||||
suivaient plus le focus**
|
||||
- **Cause racine.** Le sélecteur CSS de nettoyage des contours de plage était
|
||||
écrit **sans le point** (`querySelectorAll("xlsx-sel-top,…")`). En CSS, un
|
||||
nom nu désigne une **balise** (`<xlsx-sel-top>`), pas une classe : la requête
|
||||
ne renvoyait **rien** et aucun contour n'était jamais effacé. Les encadrés
|
||||
restaient donc en place et s'accumulaient à chaque changement de cellule.
|
||||
Même famille de faute sur les en-têtes reflétés (`classList.remove` appelé
|
||||
avec un point).
|
||||
- **Comportement corrigé** : le contour suit désormais exactement la sélection —
|
||||
une cellule seule est encadrée, une plage est encadrée sur ses quatre bords
|
||||
réels, et **rien ne reste** sur les cellules quittées (navigation clavier,
|
||||
clic, changement de cellule, changement de feuille).
|
||||
- **Écart Sheets corrigé au passage** : pendant la saisie, les flèches
|
||||
**valident et déplacent** la sélection (auparavant elles déplaçaient le
|
||||
curseur dans le texte et la cellule semblait figée). `Alt`+flèche conserve
|
||||
l'insertion d'un saut de ligne.
|
||||
- Non-régression : `xlsx-viewer` passe de 149 à **152/152** (3 cas #182).
|
||||
|
||||
- **#182 — Éditeur tableur : la sélection multiple ne se désélectionnait plus, et
|
||||
les couleurs paraissaient inertes sur une plage**
|
||||
- **Cause racine de la sélection « collée ».** La désélection reposait sur
|
||||
l'événement `focus` de la cellule. Or `Maj`+clic appelle `preventDefault()`,
|
||||
ce qui **empêche le navigateur de déplacer le focus** : `activeTd` et le
|
||||
focus réel divergeaient. Un clic suivant sur la cellule déjà focalisée ne
|
||||
déclenchait donc **aucun** `focus` et la plage restait affichée. Le clic
|
||||
simple efface désormais la plage explicitement dans `mousedown`, sans
|
||||
dépendre du focus ; après un `Maj`+clic le focus suit enfin la cellule
|
||||
cliquée (sous `suppressSelectionReset`, pour ne pas écraser la plage).
|
||||
- **Zone Nom** : après un glissement, elle affiche de nouveau la **plage**
|
||||
(`A1:B2`) au lieu de la seule cellule d'origine (`A1`).
|
||||
- **Couleurs sur une plage** : vérifié sur les trois chemins (menu Mise en
|
||||
forme, nuanciers du ruban, sélection par `Maj`+flèches ou par glissement) —
|
||||
la charge utile porte bien la plage (`A1:B2`) et les 4 cellules reçoivent
|
||||
`color` **et** `background`.
|
||||
- **Retour visuel manquant** : les deux nuanciers du ruban affichent désormais
|
||||
une **pastille** qui prend la couleur choisie, ce qui donnait l'impression
|
||||
que la couleur n'était pas appliquée.
|
||||
- Non-régression : `xlsx-viewer` passe de 146 à **149/149** (3 cas #182).
|
||||
|
||||
- **Visionneuse tableur / CSV : plus de ligne teintée au survol, la teinte de
|
||||
fond est réservée aux sélections de 2 cellules et plus (Google Sheets)**
|
||||
- Le survol ne colore **plus rien** : les règles `tr:hover` de la grille
|
||||
XLSX (ligne entière en `--bg-hover`, y compris sur une cellule déjà
|
||||
sélectionnée) et du tableau CSV sont retirées.
|
||||
- Un clic simple ne change **plus le fond de la cellule** : elle ne garde que
|
||||
son cadre accent (les quatre bords `xlsx-sel-*` + le contour
|
||||
`xlsx-active`), comme dans Google Sheets.
|
||||
- La teinte `.xlsx-selected` est désormais conditionnée par la classe
|
||||
`.xlsx-range-multi`, posée/retirée sur `.xlsx-viewer` par
|
||||
`applySelection()` selon l'étendue de la sélection — elle n'apparaît donc
|
||||
qu'à partir de deux cellules. Les en-têtes de ligne / de colonne couverts
|
||||
restent reflétés, comme dans Sheets.
|
||||
|
||||
- **Visionneuse tableur : le clic droit écrasait la sélection multiple avant
|
||||
l'ouverture du menu contextuel**
|
||||
- **Cause.** Le `mousedown` du bouton droit laissait le navigateur déplacer
|
||||
le focus sur la cellule ; le handler `focus` appelait `setActiveCell()` et
|
||||
réduisait la plage à cette cellule **avant** que le menu ne s'ouvre. Les
|
||||
entrées Copier / Coller / Effacer / Insérer / Supprimer ne s'appliquaient
|
||||
donc plus qu'à une cellule (`Delete 1 row(s)` au lieu de
|
||||
`Delete 2 row(s)`), et la plage disparaissait à l'écran.
|
||||
- **Correctif.** `preventDefault()` sur le `mousedown` de bouton droit dans
|
||||
la grille : le focus ne bouge plus, la sélection multiple survit jusqu'au
|
||||
choix d'une option. Un clic droit **hors** plage déplace toujours la
|
||||
sélection sur la cellule visée (comportement Sheets), via
|
||||
`selectForTarget()` ; le clic gauche, lui, écrase toujours la plage (#182).
|
||||
- Vérifié en conditions réelles sur l'instance Docker : la plage `A2:C3` est
|
||||
conservée à l'ouverture du menu, le menu annonce 2 lignes / 3 colonnes, et
|
||||
Copier place bien les 2 lignes × 3 colonnes dans le presse-papiers.
|
||||
|
||||
- **#180 — 8 tests JSDOM réalignés** sur la sémantique « écriture verrouillée
|
||||
puis application locale » des flux structure / mise en forme (le viewer
|
||||
n'est plus re-lu après chaque écriture) : le second appel PUT est absent,
|
||||
les assertions portent sur la grille peinte en place.
|
||||
|
||||
- **Tableur — les sorties du menu Fichier étaient inertes.** Les entrées
|
||||
Markdown, HTML et Imprimer portaient `data-xlsx-export` mais aucun
|
||||
`data-xlsx-action`, et le répartiteur générique ne lit que ce dernier : le
|
||||
clic ne produisait rien (les identifiants `#xlsx-download-btn` et
|
||||
`#xlsx-csv-btn`, eux, fonctionnaient). Elles sont câblées sur
|
||||
`export-md` / `export-html` / `print`, donc sur les fonctions déjà en place.
|
||||
|
||||
- **Tableur — collision de classe sur `.xlsx-structure-menu`.** Les menus
|
||||
déroulants de la nouvelle barre de menus portaient la même classe que le
|
||||
menu Structure de la barre d'outils : toute requête `.xlsx-structure-menu`
|
||||
tombait sur le premier nœud rencontré, donc sur un élément masqué. Les menus
|
||||
de la barre prennent `.xlsx-menudrop` ; l'aspect reste partagé par le CSS.
|
||||
|
||||
- **Tableur — curseur de redimensionnement absent sur la table.** La sonde
|
||||
géométrique était conditionnée à la présence d'un en-tête sous le pointeur ;
|
||||
sur la table elle-même aucun en-tête n'est nommé et le curseur ne
|
||||
s'affichait pas. La table retombe désormais sur la sonde géométrique.
|
||||
|
||||
- **Tableur — l'export emportait le quadrillage vide.** `sheetMatrix()`
|
||||
renvoyait la grille telle que mise en page : avec les colonnes étendues à
|
||||
A → Z, un classeur de deux colonnes exportait 26 colonnes de vide (CSV,
|
||||
Markdown, HTML, impression). Les lignes et colonnes vides de queue sont
|
||||
écartées — l'export porte le bloc utilisé, comme l'export d'un tableur.
|
||||
|
||||
- **Tableur — fuite mémoire à chaque rendu.** `renderXlsxViewer()` ajoutait un
|
||||
écouteur `click` **anonyme** sur `#content-area` : l'élément survit au
|
||||
re-rendu et la fermeture de l'écouteur retenait la grille précédente en
|
||||
entier. Ouvrir ou enregistrer un classeur plusieurs fois faisait croître le
|
||||
tas sans borne. Gestionnaire nommé, démonté en tête de rendu.
|
||||
|
||||
- **Tableur — le clic extérieur laissait le menu contextuel ouvert.** La fenêtre
|
||||
de grâce de 250 ms, destinée au `click` résiduel d'un appui long tactile,
|
||||
s'appliquait aussi au clic droit souris — qui n'en émet aucun : un clic
|
||||
délibéré fait juste après l'ouverture était avalé par la grâce. Elle ne
|
||||
couvre plus que l'ouverture tactile.
|
||||
|
||||
- **Tableur — une entrée indisponible ne refermait pas la barre de menus.** Le
|
||||
répartiteur annonçait « *X* n'est pas disponible dans ObsiGate » puis sortait
|
||||
avant la fermeture : le menu restait ouvert. Tout choix referme désormais le
|
||||
menu, y compris une entrée qui ne fait que l'annoncer.
|
||||
|
||||
---
|
||||
|
||||
## [2.53.3] — 2026-10-07
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-108 - Tableur : « Coller » du menu contextuel muet**
|
||||
- `closeContextMenu()` retirait le nœud du menu **avant** de déférencer
|
||||
`_menu` : retirer le menu portant le focus émet un `focusout` synchrone
|
||||
qui rappelle la fonction, et le second `remove()` sur un nœud déjà
|
||||
démonté lève `NotFoundError` — le handler de l'action « Coller » du
|
||||
viewer est avorté avant son exécution (régression de la fermeture au
|
||||
focus #179 ; reproduite par l'E2E « coller une plage », CI #850-#854).
|
||||
Déférencement avant retrait ; l'E2E passe après correctif.
|
||||
|
||||
---
|
||||
|
||||
## [2.53.2] — 2026-10-07
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#187 - Assistant IA : mode agent toujours actif**
|
||||
- Le bouton « mode agent » du panneau a été **supprimé** : l'assistant utilise
|
||||
en permanence ses outils (lire, lister, chercher, modifier) ; la sécurité
|
||||
est inchangée — chaque modification demande une confirmation avec aperçu.
|
||||
Les images jointes passent toujours par l'endpoint multimodal classique.
|
||||
- Le provider réel de l'agent est désormais résolu comme pour le chat simple
|
||||
(l'étiquette « fournisseur · modèle » affichée est exacte), et les schémas
|
||||
d'outils sont mis en cache (≈ 27 ms économisées par requête).
|
||||
|
||||
---
|
||||
|
||||
## [2.53.1] — 2026-10-04
|
||||
|
||||
---
|
||||
|
||||
## [2.53.0] — 2026-10-04
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile**
|
||||
- Une icône Lucide par entrée (grille, Structure, Mise en forme, Export) et
|
||||
sur les boutons Enregistrer / « + » ; suppressions marquées danger,
|
||||
`aria-haspopup`/`aria-expanded` sur les boutons à menu.
|
||||
- Tout menu se ferme à la perte de focus (clic extérieur, `focusout`,
|
||||
`Échap`, défilement, redimensionnement) via `trackDismissable()` ;
|
||||
navigation clavier (`↑`/`↓`/`Home`/`End`) dans le menu grille.
|
||||
- Mobile : entrées ≥ 44 px (`pointer: coarse`), menus bornés au viewport,
|
||||
ruban sans débordement horizontal.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Build Docker** : `build.ps1` plaçait `--no-cache` avant `build`
|
||||
(`unknown flag`) — flag déplacé après `docker compose -f … build`, la
|
||||
procédure `.\build.ps1 -Test` refonctionne.
|
||||
|
||||
---
|
||||
|
||||
## [2.52.0] — 2026-10-04
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#166 — Assistant IA : détection & fusion de doublons**
|
||||
- Score déterministe (Jaccard 70 % + titre 30 %, frontmatter exclu),
|
||||
scan borné (500 fichiers, `truncated` exposé).
|
||||
- Outils `find_duplicates` (READ) / `merge_duplicate_notes` (DANGEROUS,
|
||||
backup préalable) ; API `GET /api/duplicates` et
|
||||
`POST /api/duplicates/merge` (`confirm: true` obligatoire, stratégies
|
||||
`append` / `prefer_target` / `prefer_source`).
|
||||
- **#168 — Assistant IA : notifications externes Discord / Telegram / SMTP / webhook**
|
||||
- Canaux avec déclencheurs (`manual`, `schedule_failure`,
|
||||
`schedule_success`, `duplicate_found`), secrets hors config
|
||||
(`notify_secrets.json` 0600 ou `OBSIGATE_NOTIFY_SECRET_<ID>`), SSRF-safe.
|
||||
- CRUD admin `/api/notify/channels`, test `/api/notify/test`,
|
||||
outil `notify_external` (WRITE).
|
||||
- **#170 — Assistant IA : tâches planifiées type cron**
|
||||
- Actions `create_file` / `append_to_file` / `notify` (services existants),
|
||||
planifications `interval_hours` / `daily_time` / `once_at`, tick 60 s
|
||||
(`OBSIGATE_SCHEDULER=0` pour désactiver), échec enregistré + notifié.
|
||||
- API `/api/scheduler/tasks` (CRUD + `POST …/run`), outils
|
||||
`create/list/delete/run_scheduled_task*`.
|
||||
|
||||
---
|
||||
|
||||
## [2.51.0] — 2026-10-03
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#160 — Premier lancement professionnel + section Configuration harmonisée**
|
||||
- **Premier lancement** : `%USERPROFILE%\ObsiGate` est créé et monté comme
|
||||
vault de démarrage (nom « ObsiGate », remplace `voute_obsidian`), le
|
||||
contexte initial s'ouvre dessus, le dossier home reste une racine nommée
|
||||
d'après son chemin (fin du « bruno » codé en dur), et le document
|
||||
**`Prise en main.md`** (contenu embarqué) y est écrit une seule fois —
|
||||
jamais écrasé.
|
||||
- **Section « 🖥️ Vaults & dossiers (Desktop) »** refondue : lignes au motif
|
||||
des cartes de la configuration (icône, nom, chemin tronqué), boutons
|
||||
Retirer/Ajouter en `.config-btn-sm` secondaire/ primaire, zéro style
|
||||
inline, variables CSS uniquement, cibles tactiles 44px en mobile.
|
||||
|
||||
---
|
||||
|
||||
## [2.50.2] — 2026-10-02
|
||||
|
||||
---
|
||||
|
||||
## [2.50.1] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-106 — page blanche sur `/docs`** : la CSP `script-src` sans
|
||||
`unsafe-inline` (#87 T5c) refusait le script inline d'init de Swagger UI,
|
||||
généré par FastAPI et jamais noncé — `SecurityHeadersMiddleware` injecte
|
||||
désormais le nonce dans le HTML de `/docs` et `/redoc` (helper existant
|
||||
`inject_csp_nonce`, corps ré-encodé gzip), avec 3 tests de non-régression.
|
||||
- **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry
|
||||
remplaçait le gestionnaire de drag & drop du WebView2 par le sien (aucun
|
||||
événement natif écouté) — les dépôts depuis l'Explorateur n'atteignaient
|
||||
jamais la page. La fenêtre est désormais créée en code (`create: false`) avec
|
||||
`disable_drag_drop_handler()` : le HTML5 drag & drop (#89) fonctionne comme
|
||||
sur le web, avec un garde-fou de test.
|
||||
|
||||
---
|
||||
|
||||
## [2.50.0] — 2026-10-02
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#159 — Gestion desktop des vaults & dossiers** : retrait d'un vault ou dossier
|
||||
racine par menu contextuel (« Retirer de l'application », desktop uniquement,
|
||||
confirmation — déregistration locale, aucun fichier supprimé) et nouvelle section
|
||||
Configuration « 🖥️ Vaults & dossiers (Desktop) » avec listes, retrait par ligne et
|
||||
ajout de vault / dossier racine (nouvelle commande `pick_folder`, sélecteur natif
|
||||
sans effet de bord). La jump list des raccourcis suit les ajouts et retraits de
|
||||
vaults.
|
||||
|
||||
---
|
||||
|
||||
## [2.49.4] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-104 — commandes desktop bloquées par l'ACL Tauri** : la fenêtre desktop sert
|
||||
sa page depuis `http://127.0.0.1` (origine *remote*) et aucune commande applicative
|
||||
n'était déclarée — toutes étaient rejetées « not allowed by ACL », erreurs avalées
|
||||
par `desktop.js:invoke()`. Nouveau manifeste `desktop/permissions/commands.toml`
|
||||
(`allow-app-commands`, 19 commandes) référencé par la capability, avec un test
|
||||
garde-fou (`test_frontend_invokes_are_acl_allowed`). Le bouton « Choisir mon
|
||||
dossier » du wizard et toute la gestion vaults/dossiers desktop fonctionnent.
|
||||
- **BUG-105 — crash du desktop à l'ouverture (heap corruption `c0000374`)** : le
|
||||
setter de titre du menu contextuel des raccourcis (#77) confiait un `PROPVARIANT
|
||||
VT_LPWSTR` sur un buffer heap Rust au property store du shell, qui le libérait avec
|
||||
l'allocateur Windows — l'application se fermait 2 à 15 s après le lancement. Fonction
|
||||
supprimée (libellé assuré par `SetDescription`).
|
||||
|
||||
---
|
||||
|
||||
## [2.49.2] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- 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é
|
||||
|
||||
@@ -41,6 +41,7 @@ COPY VERSION ./VERSION
|
||||
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
|
||||
RUN groupadd -g 1000 obsigate && useradd -u 1000 -g obsigate -d /app -s /sbin/nologin obsigate \
|
||||
&& mkdir -p /app/data \
|
||||
&& chmod 777 /app/data \
|
||||
&& chown -R obsigate:obsigate /app
|
||||
USER obsigate
|
||||
|
||||
|
||||
+4
-4
@@ -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/)
|
||||
@@ -802,7 +802,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
|
||||
- **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives)
|
||||
- **Audit log** : écritures/suppressions/config journalisées dans `data/audit.log` (JSON lines, rotation 10 MB)
|
||||
- **Backup automatique** : chaque modification/suppression sauvegardée dans `.obsigate-backup/` avec timestamp
|
||||
- **Secret redaction** : masquage automatique des JWT, clés API, tokens dans les aperçus
|
||||
- **Secret redaction** : masquage automatique des JWT, mots de passe, clés API (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens dans les aperçus — cliquez sur un masque pour copier la valeur
|
||||
- **Utilisateur non-root** : conteneur Docker sous `obsigate` (UID 1000)
|
||||
- **Volumes read-only** : vaults montées en `:ro` par défaut
|
||||
- **Secrets dans `.env`** : jamais dans `docker-compose.yml`
|
||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.49.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.55.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.49.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.55.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -964,7 +964,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
|
||||
- **Rate limiting** : 10 login attempts max per IP over 15 minutes + per-account lockout (5 attempts)
|
||||
- **Audit log** : All writes, deletions, and config changes are logged in `data/audit.log` (JSON lines, 10 MB rotation)
|
||||
- **Automatic backup** : Every file modification or deletion is saved in `.obsigate-backup/` with timestamp
|
||||
- **Secret redaction** : Automatic masking of JWTs, API keys, tokens, and connection strings in previews
|
||||
- **Secret redaction** : Automatic masking of JWTs, passwords, API keys (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens and connection strings in previews — click a mask to copy the value
|
||||
- **Non-root user** : The Docker container runs under user `obsigate` (UID 1000)
|
||||
- **Read-only volumes** : Vaults are mounted as `:ro` by default in docker-compose
|
||||
- **Secrets in `.env`** : Passwords and tokens are never in `docker-compose.yml`
|
||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.49.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.55.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.49.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.55.0 | 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",
|
||||
|
||||
+23
-4
@@ -21,7 +21,7 @@ import mistune
|
||||
|
||||
from backend.image_processor import preprocess_images
|
||||
from backend.indexer import find_file_in_index, get_vault_data
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.secret_redactor import redact_with_placeholders, restore_masks
|
||||
from backend.services.sanitizer import sanitize_html
|
||||
|
||||
|
||||
@@ -166,7 +166,13 @@ def _normalize_line_breaks(text: str) -> str:
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
|
||||
def _render_markdown(
|
||||
raw_md: str,
|
||||
vault_name: str,
|
||||
current_file_path: Path | None = None,
|
||||
*,
|
||||
click_to_copy: bool = False,
|
||||
) -> str:
|
||||
"""Render a markdown string to HTML with wikilink and image support.
|
||||
|
||||
Uses the cached singleton mistune renderer for performance.
|
||||
@@ -175,6 +181,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
||||
raw_md: Raw markdown text (frontmatter already stripped).
|
||||
vault_name: Current vault for wikilink resolution context.
|
||||
current_file_path: Absolute path to the current markdown file.
|
||||
click_to_copy: Restore masked secrets as clickable badges carrying
|
||||
the real value (authenticated app preview, feature #188).
|
||||
Public shares and PDF exports keep plain labels: the secret
|
||||
never reaches their HTML.
|
||||
|
||||
Returns:
|
||||
HTML string.
|
||||
@@ -184,8 +194,13 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
||||
vault_root = Path(vault_data["path"]) if vault_data else None
|
||||
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
||||
|
||||
# Redact secrets before rendering (P0 security)
|
||||
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
|
||||
# Redact secrets before rendering (P0 security). Placeholders survive
|
||||
# the markdown conversion (fenced code blocks included) and are turned
|
||||
# back into visible masks — clickable badges when click_to_copy — right
|
||||
# after the HTML is produced (feature #188).
|
||||
raw_md, secret_entries = redact_with_placeholders(
|
||||
raw_md, str(current_file_path) if current_file_path else ""
|
||||
)
|
||||
|
||||
# Preprocess images first
|
||||
if vault_root:
|
||||
@@ -201,6 +216,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
||||
# renderers HTML renvoient toujours `str` à l'exécution).
|
||||
rendered = cast(str, _markdown_renderer(converted))
|
||||
|
||||
# Restore secret masks (plain labels, or clickable badges carrying the
|
||||
# real value on the authenticated app preview — feature #188).
|
||||
rendered = restore_masks(rendered, secret_entries, click_to_copy=click_to_copy)
|
||||
|
||||
# Add heading IDs for TOC navigation
|
||||
rendered = _add_heading_ids(rendered)
|
||||
|
||||
|
||||
@@ -39,3 +39,8 @@ reportlab>=4.0
|
||||
pillow>=10.0
|
||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||
urllib3>=2.8.0
|
||||
# Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive
|
||||
# (aiohttp/yarl). 6.7.x est la version pré-installée dans la toolcache de
|
||||
# l'image du runner — sans plancher, pip répond « already satisfied » et
|
||||
# n'aligne jamais (même piège que pypdf, BUG-093).
|
||||
multidict>=6.9.1
|
||||
|
||||
@@ -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
|
||||
@@ -648,7 +648,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
tags = _extract_tags(post)
|
||||
|
||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
||||
html_content = _render_markdown(post.content, vault_name, file_path)
|
||||
html_content = _render_markdown(post.content, vault_name, file_path, click_to_copy=True)
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
|
||||
@@ -323,9 +323,13 @@ def api_file_xlsx_style(
|
||||
|
||||
``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``)
|
||||
``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
|
||||
``font_color``/``fill_color`` as ``#rrggbb``, ``align``
|
||||
(left/center/right/justify), ``valign`` (top/middle/bottom), ``wrap``,
|
||||
``rotation`` (-90..90), ``border`` (all/outer/none) + ``border_style`` /
|
||||
``border_color`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``),
|
||||
``comment_set`` (``ref``, ``text``) / ``comment_clear`` (``ref``)
|
||||
and ``freeze`` (``cell``, empty to release).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
@@ -548,6 +552,10 @@ async def api_file_create(
|
||||
|
||||
Returns:
|
||||
FileCreateResponse confirming creation.
|
||||
|
||||
Note:
|
||||
A ``.xlsx`` path creates an empty workbook (one ``Feuille1`` sheet) built
|
||||
with openpyxl: the payload is binary, so ``content`` is ignored (#186).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
@@ -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
|
||||
+191
-38
@@ -1,48 +1,104 @@
|
||||
"""
|
||||
Secret redactor: masks sensitive patterns in rendered text.
|
||||
|
||||
Scans for common secret patterns and replaces them with [MASQUÉ]
|
||||
before content is served to the frontend. Prevents accidental
|
||||
exposure of API keys, tokens, and passwords in previews.
|
||||
Scans for common secret patterns and replaces them with a French mask
|
||||
label (``[CLÉ API MASQUÉE]``, ``[MOT DE PASSE MASQUÉ]``, …) before content
|
||||
is served to the frontend. Prevents accidental exposure of API keys,
|
||||
tokens, and passwords in previews.
|
||||
|
||||
Patterns detected:
|
||||
- Generic API keys (long alphanumeric strings with key/secret/token prefix)
|
||||
- Generic API keys (``api_key=…``, ``token: …`` — values of 8+ chars)
|
||||
- Passwords (``password=…``, ``"passwd": "…"`` — any length)
|
||||
- JWT tokens (eyJ... base64url)
|
||||
- AWS-style keys (AKIA..., sk-..., etc.)
|
||||
- Provider key formats: OpenAI/Anthropic/OpenRouter (``sk-``), Stripe,
|
||||
GitLab, Google (``AIza…`` / ``ya29.``), AWS, GitHub, Slack, SendGrid,
|
||||
Hugging Face, npm, Docker, Resend, Square, Atlassian, Discord,
|
||||
Telegram, ``Bearer …`` tokens
|
||||
- Private key blocks (-----BEGIN ... PRIVATE KEY-----)
|
||||
- Connection strings with passwords
|
||||
- Bare hex secrets next to a secret keyword (BUG-035)
|
||||
|
||||
Interactive masking (feature #188): :func:`redact_with_placeholders`
|
||||
returns the text with every mask replaced by an opaque placeholder plus
|
||||
the list of ``(label, secret)`` entries; :func:`restore_masks` turns the
|
||||
placeholders back into plain labels (public shares, PDF exports, AI
|
||||
context) or into clickable ``<span class="secret-mask" data-secret="…">``
|
||||
badges (authenticated app preview) so a click copies the real value to
|
||||
the clipboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger("obsigate.redactor")
|
||||
|
||||
# --- Patterns ---
|
||||
# Order matters: more specific patterns first
|
||||
_PATTERNS = [
|
||||
# Each entry is ``(pattern, replacement, secret_group)``:
|
||||
# * ``replacement``: a literal label, a ``\\1``-style template, or a
|
||||
# callable receiving the match and returning the visible label;
|
||||
# * ``secret_group``: index of the group holding the value that a click
|
||||
# copies to the clipboard (feature #188).
|
||||
_PATTERNS: list[tuple[re.Pattern[str], object, int]] = [
|
||||
# Private key blocks
|
||||
(re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]'),
|
||||
|
||||
(re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]', 0),
|
||||
|
||||
# JWT tokens (base64url encoded, starts with eyJ)
|
||||
(re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]'),
|
||||
|
||||
(re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]', 0),
|
||||
|
||||
# Connection strings with passwords
|
||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]'),
|
||||
|
||||
# Generic API key patterns: key=... or token=... or secret=...
|
||||
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
|
||||
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
|
||||
|
||||
# Prefixed API keys (sk-..., pk-..., rk-...)
|
||||
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
|
||||
|
||||
# AWS access keys
|
||||
(re.compile(r'AKIA[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]'),
|
||||
|
||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]', 0),
|
||||
|
||||
# Passwords — any length, bare or quoted (``password=…``,
|
||||
# ``"passwd": "…"``). The left side may carry a qualifier
|
||||
# (``db_password``, ``DATABASE.PASSWORD``, ``user_pwd``); a *bare*
|
||||
# ``PWD=`` (shell working directory) must NOT match, hence ``pwd``
|
||||
# only in its qualified branch.
|
||||
(re.compile(
|
||||
r'(?i)((?:[A-Za-z0-9_.-]*(?:password|passwd|passphrase|mot\s+de\s+passe)'
|
||||
r'|[A-Za-z0-9_.-]+pwd)["\']?\s*[:=]\s*["\']?)([^\s"\',;]{4,})'),
|
||||
r'\1[MOT DE PASSE MASQUÉ]', 2),
|
||||
|
||||
# Generic API key assignments: api_key=…, token=…, secret=… — values
|
||||
# of 8+ characters (short enough to catch real keys, long enough to
|
||||
# skip plain words).
|
||||
(re.compile(r'(?i)([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|auth[_-]?token)["\']?\s*[:=]\s*["\']?)([^\s\'"]{8,})'),
|
||||
lambda m: f'{m.group(1)}[MASQUÉ]' if ("=" in m.group(0) or ":" in m.group(0)) else '[MASQUÉ]', 2),
|
||||
|
||||
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
|
||||
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'),
|
||||
|
||||
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]', 0),
|
||||
|
||||
# AWS access keys
|
||||
(re.compile(r'(?:AKIA|ASIA)[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]', 0),
|
||||
|
||||
# Provider key formats (feature #188) — one alternation covering the
|
||||
# large majority of token shapes in the wild.
|
||||
(re.compile(
|
||||
r'(?<![A-Za-z0-9])(?:'
|
||||
r'sk-[A-Za-z0-9_\-]{16,}' # OpenAI / Anthropic / OpenRouter
|
||||
r'|sk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe secret key
|
||||
r'|pk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe publishable key
|
||||
r'|whsec_[A-Za-z0-9]{16,}' # Stripe / Svix webhook secret
|
||||
r'|glpat-[A-Za-z0-9_\-]{20,}' # GitLab personal access token
|
||||
r'|github_pat_[A-Za-z0-9_]{22,}' # GitHub fine-grained PAT
|
||||
r'|npm_[A-Za-z0-9]{36}' # npm automation token
|
||||
r'|dckr_pat_[A-Za-z0-9_\-]{20,}' # Docker Hub token
|
||||
r'|hf_[A-Za-z0-9]{30,}' # Hugging Face token
|
||||
r'|AIza[0-9A-Za-z_\-]{35}' # Google API key
|
||||
r'|ya29\.[0-9A-Za-z_\-]{20,}' # Google OAuth access token
|
||||
r'|xox[baprs]-[0-9A-Za-z\-]{10,}' # Slack token
|
||||
r'|SG\.[A-Za-z0-9_\-]{16,}' # SendGrid API key
|
||||
r'|re_[A-Za-z0-9]{40}' # Resend API key
|
||||
r'|sq0[a-z]{3}-[A-Za-z0-9_\-]{16,}' # Square access token
|
||||
r'|ATATT[A-Za-z0-9_\-]{20,}' # Atlassian access token
|
||||
r'|[NOP][A-Za-z0-9_\-]{23,28}\.[A-Za-z0-9_\-]{6}\.[A-Za-z0-9_\-]{27,}' # Discord bot token
|
||||
r'|\d{8,10}:[A-Za-z0-9_\-]{35}' # Telegram bot token
|
||||
r'|Bearer\s+[A-Za-z0-9._~+/=\-]{20,}' # Authorization: Bearer …
|
||||
r')'),
|
||||
'[CLÉ API MASQUÉE]', 0),
|
||||
|
||||
]
|
||||
|
||||
# BUG-035: bare 40–64 char hex strings used to be redacted unconditionally,
|
||||
@@ -61,14 +117,23 @@ _HASH_CONTEXT_RE = re.compile(
|
||||
#: How far before the hex string a keyword may appear to count as context.
|
||||
_HEX_CONTEXT_WINDOW = 60
|
||||
|
||||
# --- Interactive masking (feature #188) ---
|
||||
# Private-use-area sentinels: they survive markdown rendering (mistune
|
||||
# treats them as plain text, fenced code blocks included) and are
|
||||
# stripped by ``backend.render._heading_slugify``.
|
||||
_PLACEHOLDER_OPEN = "\uE000"
|
||||
_PLACEHOLDER_CLOSE = "\uE001"
|
||||
_PLACEHOLDER_RE = re.compile("\uE000(\\d+)\uE001")
|
||||
|
||||
def _redact_bare_hex_secrets(text: str) -> tuple:
|
||||
|
||||
def _redact_bare_hex_secrets(text: str, mask) -> tuple[str, int]:
|
||||
"""Redact 40–64 char hex strings only when a secret keyword is nearby.
|
||||
|
||||
Git/SHA/checksum contexts are left untouched (BUG-035).
|
||||
|
||||
Args:
|
||||
text: Text to scan.
|
||||
mask: ``mask(original, label) -> str`` replacement builder.
|
||||
|
||||
Returns:
|
||||
(redacted_text, redaction_count) tuple.
|
||||
@@ -82,12 +147,38 @@ def _redact_bare_hex_secrets(text: str) -> tuple:
|
||||
return match.group(0)
|
||||
if _SECRET_CONTEXT_RE.search(window):
|
||||
count += 1
|
||||
return '[HEX_KEY MASQUÉ]'
|
||||
return mask(match.group(0), '[HEX_KEY MASQUÉ]')
|
||||
return match.group(0)
|
||||
|
||||
return _HEX_RE.sub(_replace, text), count
|
||||
|
||||
|
||||
def _redact(text: str, mask) -> tuple[str, int]:
|
||||
"""Apply every pattern; ``mask(original, label) -> str`` builds the
|
||||
replacement (plain label, or placeholder for the interactive mode)."""
|
||||
count = 0
|
||||
result = text
|
||||
for pattern, replacement, secret_group in _PATTERNS:
|
||||
|
||||
def _sub(match: re.Match, replacement=replacement, secret_group=secret_group) -> str:
|
||||
if callable(replacement):
|
||||
label = replacement(match)
|
||||
else:
|
||||
label = match.expand(str(replacement))
|
||||
return mask(match.group(secret_group), label)
|
||||
|
||||
new_result, n = pattern.subn(_sub, result)
|
||||
count += n
|
||||
result = new_result
|
||||
result, hex_count = _redact_bare_hex_secrets(result, mask)
|
||||
return result, count + hex_count
|
||||
|
||||
|
||||
def _plain_mask(original: str, label: str) -> str:
|
||||
"""Plain masking: only the visible label survives."""
|
||||
return label
|
||||
|
||||
|
||||
def redact(text: str) -> tuple:
|
||||
"""Redact sensitive patterns from text.
|
||||
|
||||
@@ -97,22 +188,84 @@ def redact(text: str) -> tuple:
|
||||
Returns:
|
||||
(redacted_text, redaction_count) tuple.
|
||||
"""
|
||||
count = 0
|
||||
result = text
|
||||
for pattern, replacement in _PATTERNS:
|
||||
if callable(replacement):
|
||||
new_result, n = pattern.subn(replacement, result)
|
||||
else:
|
||||
new_result, n = pattern.subn(str(replacement), result)
|
||||
count += n
|
||||
result = new_result
|
||||
result, hex_count = _redact_bare_hex_secrets(result)
|
||||
count += hex_count
|
||||
result, count = _redact(text, _plain_mask)
|
||||
if count > 0:
|
||||
logger.info(f"Redacted {count} secret(s) from content")
|
||||
return result, count
|
||||
|
||||
|
||||
def redact_with_placeholders(text: str, file_path: str = "") -> tuple[str, list[tuple[str, str]]]:
|
||||
"""Redact *text*, replacing every mask with an opaque placeholder.
|
||||
|
||||
Used by the markdown rendering pipeline: placeholders survive the
|
||||
markdown → HTML conversion (fenced code blocks included, where a
|
||||
literal ``<span>`` would be shown as text), then
|
||||
:func:`restore_masks` turns them back into labels or clickable badges.
|
||||
|
||||
Args:
|
||||
text: The raw text content to scan.
|
||||
file_path: Optional file path for logging context.
|
||||
|
||||
Returns:
|
||||
(text_with_placeholders, entries) where *entries* is the list of
|
||||
``(label, secret)`` tuples referenced by the placeholders, in
|
||||
order of appearance.
|
||||
"""
|
||||
entries: list[tuple[str, str]] = []
|
||||
|
||||
def mask(original: str, label: str) -> str:
|
||||
entries.append((label, original))
|
||||
return f"{_PLACEHOLDER_OPEN}{len(entries) - 1}{_PLACEHOLDER_CLOSE}"
|
||||
|
||||
result, count = _redact(text, mask)
|
||||
if count > 0:
|
||||
logger.warning(f"Redacted {count} potential secret(s) from {file_path or '<unknown>'}")
|
||||
return result, entries
|
||||
|
||||
|
||||
def restore_masks(
|
||||
text: str,
|
||||
entries: list[tuple[str, str]],
|
||||
*,
|
||||
click_to_copy: bool = False,
|
||||
) -> str:
|
||||
"""Turn placeholders produced by :func:`redact_with_placeholders` back
|
||||
into visible masks.
|
||||
|
||||
Args:
|
||||
text: Rendered HTML still containing placeholders.
|
||||
entries: The ``(label, secret)`` list returned alongside.
|
||||
click_to_copy: When True (authenticated app preview), each mask
|
||||
becomes ``<span class="secret-mask" data-secret="…">label</span>``
|
||||
so a click copies the real value. When False (public shares,
|
||||
PDF exports), only the plain label is restored — the secret
|
||||
never reaches the page.
|
||||
|
||||
Returns:
|
||||
The text with every placeholder replaced.
|
||||
"""
|
||||
if not entries:
|
||||
return text
|
||||
|
||||
def _sub(match: re.Match) -> str:
|
||||
idx = int(match.group(1))
|
||||
if idx >= len(entries):
|
||||
return ""
|
||||
label, original = entries[idx]
|
||||
label_esc = _html.escape(str(label), quote=False)
|
||||
if not click_to_copy:
|
||||
return label_esc
|
||||
return (
|
||||
'<span class="secret-mask" data-secret="'
|
||||
+ _html.escape(str(original), quote=True)
|
||||
+ '">'
|
||||
+ label_esc
|
||||
+ "</span>"
|
||||
)
|
||||
|
||||
return _PLACEHOLDER_RE.sub(_sub, text)
|
||||
|
||||
|
||||
def redact_file_content(content: str, file_path: str = "") -> str:
|
||||
"""Redact a file's content for preview rendering.
|
||||
|
||||
|
||||
@@ -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}
|
||||
+220
-15
@@ -41,6 +41,28 @@ _EXCALIDRAW_SKELETON = (
|
||||
'"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
|
||||
)
|
||||
|
||||
# Title of the single sheet of a workbook created from scratch (#186).
|
||||
_XLSX_DEFAULT_SHEET = "Feuille1"
|
||||
|
||||
|
||||
def _empty_xlsx_bytes() -> bytes:
|
||||
"""Return the bytes of a valid, empty single-sheet workbook.
|
||||
|
||||
An ``.xlsx`` is a ZIP archive (OPC), not a text file: writing ``""`` to it
|
||||
produces a file no reader (and no spreadsheet application) can open. The
|
||||
"Create a file" modal posts an empty text payload, so the archive itself is
|
||||
built here with openpyxl.
|
||||
"""
|
||||
import io
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
wb.active.title = _XLSX_DEFAULT_SHEET
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _rel(root: Path, path: Path) -> str:
|
||||
"""Return *path* relative to *root* as a POSIX-style string."""
|
||||
@@ -98,13 +120,14 @@ def create_file(
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a text file in a vault.
|
||||
"""Create a file in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Vault-relative path of the new file.
|
||||
content: Initial content (an Excalidraw skeleton is injected for empty
|
||||
``.excalidraw`` files).
|
||||
``.excalidraw`` files). ``.xlsx`` is binary: the workbook is always
|
||||
built from scratch and *content* is ignored for that extension.
|
||||
overwrite: When True, replace an existing file (with a backup) instead
|
||||
of raising ``already_exists``.
|
||||
|
||||
@@ -130,17 +153,25 @@ def create_file(
|
||||
)
|
||||
create_backup(file_path, vault_name, _rel(root, file_path))
|
||||
|
||||
suffix = file_path.suffix.lower()
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if file_path.suffix.lower() == ".excalidraw" and not content.strip():
|
||||
content = _EXCALIDRAW_SKELETON
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
if suffix == ".xlsx":
|
||||
# Binary OPC archive: a text write would leave an unopenable file
|
||||
# (#186). Written as bytes, so the size below is the byte count.
|
||||
payload = _empty_xlsx_bytes()
|
||||
file_path.write_bytes(payload)
|
||||
else:
|
||||
if suffix == ".excalidraw" and not content.strip():
|
||||
content = _EXCALIDRAW_SKELETON
|
||||
payload = content.encode("utf-8")
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
except PermissionError as e:
|
||||
raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
logger.info(f"File created: {vault_name}/{rel_path}")
|
||||
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
|
||||
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(payload)}
|
||||
|
||||
|
||||
def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]:
|
||||
@@ -673,11 +704,42 @@ def mutate_xlsx_structure(
|
||||
"Position 'at' / 'count' invalides", code="invalid", status=400
|
||||
)
|
||||
if op == "row_insert":
|
||||
ws.insert_rows(at, count)
|
||||
# Le modèle sparse d'openpyxl ignore les inserts
|
||||
# au-delà de la dernière ligne utilisée (rien à
|
||||
# décaler), et l'écrivain abandonne les cellules
|
||||
# vides (None) au cycle suivant : on matérialise
|
||||
# avec " " (un blanc qui persiste, relu tel quel,
|
||||
# affiché vide, ignoré par la recherche) pour
|
||||
# qu'une insertion « sous la dernière ligne »
|
||||
# crée bien des lignes blanches, comme Excel.
|
||||
if at > ws.max_row:
|
||||
if at - ws.max_row > _INSERT_GAP_LIMIT:
|
||||
raise ServiceError(
|
||||
"Position trop éloignée de la fin de la feuille",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
for r in range(at, at + count):
|
||||
cell = ws.cell(row=r, column=1)
|
||||
if cell.value is None:
|
||||
cell.value = " "
|
||||
else:
|
||||
ws.insert_rows(at, count)
|
||||
elif op == "row_delete":
|
||||
ws.delete_rows(at, count)
|
||||
elif op == "col_insert":
|
||||
ws.insert_cols(at, count)
|
||||
# Idem côté colonnes : « à droite de la dernière ».
|
||||
if at > ws.max_column:
|
||||
if at - ws.max_column > _INSERT_GAP_LIMIT:
|
||||
raise ServiceError(
|
||||
"Position trop éloignée de la fin de la feuille",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
for c in range(at, at + count):
|
||||
cell = ws.cell(row=1, column=c)
|
||||
if cell.value is None:
|
||||
cell.value = " "
|
||||
else:
|
||||
ws.insert_cols(at, count)
|
||||
else:
|
||||
ws.delete_cols(at, count)
|
||||
applied.append(f"{op}:{sheet}@{at}x{count}")
|
||||
@@ -725,16 +787,34 @@ def mutate_xlsx_structure(
|
||||
# #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.
|
||||
# Cap anti-DoS : une insertion au-delà de la fin matérialise des cellules,
|
||||
# jamais un gouffre de lignes/colonnes.
|
||||
_INSERT_GAP_LIMIT = 1000
|
||||
_STYLE_KEYS = {
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strike",
|
||||
"font_size",
|
||||
"font_color",
|
||||
"fill_color",
|
||||
"align",
|
||||
"valign",
|
||||
"wrap",
|
||||
"rotation",
|
||||
"border",
|
||||
"border_style",
|
||||
"border_color",
|
||||
"number_format",
|
||||
}
|
||||
_STYLE_ALIGNS = {"left", "center", "right"}
|
||||
_STYLE_ALIGNS = {"left", "center", "right", "justify"}
|
||||
_STYLE_VALIGNS = {"top": "top", "middle": "center", "bottom": "bottom"}
|
||||
_STYLE_BORDERS = {"all", "outer", "none"}
|
||||
_STYLE_BORDER_STYLES = {
|
||||
"thin", "medium", "thick", "dashed", "dotted", "double", "hair",
|
||||
"mediumDashed", "dashDot", "mediumDashDot", "dashDotDot",
|
||||
"mediumDashDotDot", "slantDashDot",
|
||||
}
|
||||
MAX_STYLE_CELLS = 10_000
|
||||
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
|
||||
|
||||
@@ -760,14 +840,14 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
"""
|
||||
import copy as copy_mod
|
||||
|
||||
from openpyxl.styles import Alignment, Color, PatternFill
|
||||
from openpyxl.styles import Alignment, Border, Color, PatternFill, Side
|
||||
|
||||
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):
|
||||
if {"bold", "italic", "underline", "strike", "font_size", "font_color"} & set(style):
|
||||
font = copy_mod.copy(cell.font)
|
||||
if "bold" in style:
|
||||
font.bold = bool(style["bold"])
|
||||
@@ -775,6 +855,22 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
font.italic = bool(style["italic"])
|
||||
if "underline" in style:
|
||||
font.underline = "single" if style["underline"] else None
|
||||
if "strike" in style:
|
||||
font.strike = bool(style["strike"])
|
||||
if "font_size" in style:
|
||||
try:
|
||||
size = float(style["font_size"])
|
||||
except (TypeError, ValueError):
|
||||
raise ServiceError(
|
||||
f"Taille de police invalide: {style['font_size']!r}",
|
||||
code="invalid", status=400,
|
||||
) from None
|
||||
if not 6 <= size <= 72:
|
||||
raise ServiceError(
|
||||
f"Taille de police hors bornes (6-72): {style['font_size']!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
font.size = size
|
||||
if "font_color" in style:
|
||||
rgb = _style_color(style["font_color"])
|
||||
font.color = Color(rgb=rgb) if rgb else None
|
||||
@@ -797,7 +893,69 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
horizontal=align,
|
||||
vertical=getattr(current, "vertical", None),
|
||||
wrap_text=getattr(current, "wrap_text", None),
|
||||
text_rotation=getattr(current, "text_rotation", None),
|
||||
)
|
||||
if "valign" in style or "wrap" in style or "rotation" in style:
|
||||
current = cell.alignment
|
||||
vertical = getattr(current, "vertical", None)
|
||||
wrap_text = getattr(current, "wrap_text", None)
|
||||
text_rotation = getattr(current, "text_rotation", None) or 0
|
||||
if "valign" in style:
|
||||
valign = style["valign"]
|
||||
if valign not in _STYLE_VALIGNS:
|
||||
raise ServiceError(
|
||||
f"Alignement vertical invalide: {valign!r}", code="invalid", status=400
|
||||
)
|
||||
vertical = _STYLE_VALIGNS[valign]
|
||||
if "wrap" in style:
|
||||
wrap_text = bool(style["wrap"])
|
||||
if "rotation" in style:
|
||||
try:
|
||||
rotation = int(style["rotation"])
|
||||
except (TypeError, ValueError):
|
||||
raise ServiceError(
|
||||
f"Rotation invalide: {style['rotation']!r}", code="invalid", status=400
|
||||
) from None
|
||||
if not (-90 <= rotation <= 90 or rotation == 180):
|
||||
raise ServiceError(
|
||||
f"Rotation hors bornes (-90..90, 180): {rotation!r}", code="invalid", status=400
|
||||
)
|
||||
# OOXML stocke les angles horaires sur 91..180 (-45° → 135).
|
||||
# 180 = texte vertical retourné (le lecteur le relit tel quel).
|
||||
text_rotation = rotation if rotation >= 0 else 180 + rotation
|
||||
cell.alignment = Alignment(
|
||||
horizontal=getattr(current, "horizontal", None),
|
||||
vertical=vertical,
|
||||
wrap_text=wrap_text,
|
||||
text_rotation=text_rotation,
|
||||
)
|
||||
if {"border", "border_style", "border_color"} & set(style):
|
||||
which = style.get("border", "all")
|
||||
if which not in _STYLE_BORDERS:
|
||||
raise ServiceError(
|
||||
f"Bordure invalide: {which!r}", code="invalid", status=400
|
||||
)
|
||||
bstyle = style.get("border_style", "thin")
|
||||
if bstyle not in _STYLE_BORDER_STYLES:
|
||||
raise ServiceError(
|
||||
f"Style de bordure invalide: {bstyle!r}", code="invalid", status=400
|
||||
)
|
||||
raw_color = style.get("border_color", "#000000")
|
||||
if raw_color in (None, ""):
|
||||
raw_color = "FF000000"
|
||||
rgb = _style_color(raw_color)
|
||||
if rgb is None:
|
||||
raise ServiceError(
|
||||
f"Couleur de bordure invalide: {style.get('border_color')!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
if which == "none":
|
||||
cell.border = Border()
|
||||
else:
|
||||
# `all` et `outer` se rendent pareil cellule par cellule : les
|
||||
# bords partagés coïncident, le contour de la plage reste net.
|
||||
side = Side(style=bstyle, color=rgb)
|
||||
cell.border = Border(left=side, right=side, top=side, bottom=side)
|
||||
if "number_format" in style:
|
||||
fmt = style["number_format"] or "General"
|
||||
if not isinstance(fmt, str) or len(fmt) > 120:
|
||||
@@ -821,12 +979,17 @@ def mutate_xlsx_style(
|
||||
``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
|
||||
``bold``, ``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
|
||||
``font_color`` / ``fill_color`` (``#rrggbb``, ``""`` clears),
|
||||
``align`` (left/center/right/justify), ``valign`` (top/middle/bottom),
|
||||
``wrap`` (bool), ``rotation`` (-90..90), ``border``
|
||||
(all/outer/none) + ``border_style`` / ``border_color`` 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": "comment_set", "sheet": "X", "ref": "B2", "text": "…"}``
|
||||
(``author`` optional) / ``{"op": "comment_clear", ...}`` ;
|
||||
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
|
||||
|
||||
Same guards as the cell edits: per-file lock, ``.bak`` backup,
|
||||
@@ -960,7 +1123,17 @@ def mutate_xlsx_style(
|
||||
raise ServiceError(
|
||||
"Largeur invalide (0 à 255)", code="invalid", status=400
|
||||
)
|
||||
ws.column_dimensions[col.strip().upper()].width = float(width)
|
||||
width = float(width)
|
||||
if width == 0:
|
||||
# 0 = largeur par défaut (équivalent « Default
|
||||
# Width » d'Excel / « Reset » de Sheets) : on
|
||||
# retire la mesure explicite au lieu d'écrire une
|
||||
# largeur de 0, qu'Excel lirait comme une colonne
|
||||
# quasi masquée pendant que l'affichage reste en
|
||||
# largeur automatique.
|
||||
ws.column_dimensions.pop(col.strip().upper(), None)
|
||||
else:
|
||||
ws.column_dimensions[col.strip().upper()].width = width
|
||||
applied.append(f"col_width:{ws.title}!{col}={width}")
|
||||
elif op == "row_height":
|
||||
ws = _sheet(wb, action)
|
||||
@@ -972,7 +1145,14 @@ def mutate_xlsx_style(
|
||||
raise ServiceError(
|
||||
"Hauteur invalide (0 à 409)", code="invalid", status=400
|
||||
)
|
||||
ws.row_dimensions[row].height = float(height)
|
||||
height = float(height)
|
||||
if height == 0:
|
||||
# 0 = hauteur par défaut (même logique que la
|
||||
# largeur ci-dessus) : la mesure explicite est
|
||||
# retirée, la ligne redevient automatique.
|
||||
ws.row_dimensions.pop(row, None)
|
||||
else:
|
||||
ws.row_dimensions[row].height = height
|
||||
applied.append(f"row_height:{ws.title}!{row}={height}")
|
||||
elif op == "freeze":
|
||||
ws = _sheet(wb, action)
|
||||
@@ -981,6 +1161,31 @@ def mutate_xlsx_style(
|
||||
_range(cell_ref)
|
||||
ws.freeze_panes = cell_ref.upper() or None
|
||||
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
|
||||
elif op in ("comment_set", "comment_clear"):
|
||||
from openpyxl.comments import Comment
|
||||
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("ref") or action.get("cell") or "")
|
||||
min_col, min_row, max_col, max_row = _range(ref)
|
||||
if (min_col, min_row) != (max_col, max_row):
|
||||
raise ServiceError(
|
||||
"Un commentaire vise une cellule (A1), pas une plage",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
if op == "comment_clear":
|
||||
ws.cell(row=min_row, column=min_col).comment = None
|
||||
else:
|
||||
text = str(action.get("text") or "")
|
||||
if not text.strip() or len(text) > 2000:
|
||||
raise ServiceError(
|
||||
"Texte de commentaire invalide (1 à 2000 caractères)",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
author = str(action.get("author") or "ObsiGate")[:64]
|
||||
ws.cell(row=min_row, column=min_col).comment = Comment(
|
||||
text, author
|
||||
)
|
||||
applied.append(f"{op}:{ws.title}!{ref}")
|
||||
else:
|
||||
raise ServiceError(
|
||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
||||
|
||||
@@ -12,6 +12,9 @@ which ``.gitignore`` excludes via ``_*.py``), hence this explicit facade.
|
||||
from backend.tools import connected as _connected # noqa: F401 (registers connected-source tools)
|
||||
from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler)
|
||||
from backend.tools import documents as _documents # noqa: F401 (registers document tools)
|
||||
from backend.tools import duplicates as _duplicates # noqa: F401 (registers duplicate tools #166)
|
||||
from backend.tools import notify as _notify_tools # noqa: F401 (registers notify tool #168)
|
||||
from backend.tools import scheduled as _scheduled # noqa: F401 (registers scheduler tools #170)
|
||||
from backend.tools import service as _service # noqa: F401 (registers tools)
|
||||
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
|
||||
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Duplicate detection & merge tools (#166).
|
||||
|
||||
* ``find_duplicates`` — READ, vault-scoped: candidate pairs with scores.
|
||||
* ``merge_duplicate_notes`` — DANGEROUS: confirmed fusion with automatic
|
||||
backup (service layer), never without an explicit approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend.services import duplicates as _duplicates
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import FindDuplicatesInput, MergeDuplicatesInput
|
||||
|
||||
|
||||
@tool(
|
||||
name="find_duplicates",
|
||||
description="Find candidate duplicate markdown notes in a vault (similarity scores).",
|
||||
input_model=FindDuplicatesInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def find_duplicates(ctx: ToolContext, params: FindDuplicatesInput) -> dict[str, Any]:
|
||||
"""List duplicate candidates ordered by descending score."""
|
||||
try:
|
||||
return _duplicates.find_duplicate_pairs(
|
||||
params.vault,
|
||||
threshold=params.threshold,
|
||||
limit=params.limit,
|
||||
subdir=params.subdir,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
|
||||
|
||||
@tool(
|
||||
name="merge_duplicate_notes",
|
||||
description=(
|
||||
"Merge one note into another and delete the source (backup first). "
|
||||
"Destructive: requires confirmation."
|
||||
),
|
||||
input_model=MergeDuplicatesInput,
|
||||
risk=ToolRisk.DANGEROUS,
|
||||
requires_vault=True,
|
||||
)
|
||||
def merge_duplicate_notes(ctx: ToolContext, params: MergeDuplicatesInput) -> dict[str, Any]:
|
||||
"""Fuse *source_path* into *target_path* using the chosen strategy."""
|
||||
try:
|
||||
return _duplicates.merge_duplicates(
|
||||
params.vault,
|
||||
params.source_path,
|
||||
params.target_path,
|
||||
strategy=params.strategy,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
@@ -62,6 +62,13 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
"find_duplicates": ("duplicates", "vault"),
|
||||
"merge_duplicate_notes": ("duplicates_merge", "source_path"),
|
||||
"notify_external": ("notify", "title"),
|
||||
"create_scheduled_task": ("schedule_create", "name"),
|
||||
"list_scheduled_tasks": ("schedule_list", None),
|
||||
"delete_scheduled_task": ("schedule_delete", "task_id"),
|
||||
"run_scheduled_task_now": ("schedule_run", "task_id"),
|
||||
}
|
||||
|
||||
GENERIC_KEY = "generic"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""External notification tool (#168) — Discord, Telegram, SMTP, webhook.
|
||||
|
||||
``notify_external`` is WRITE (external side effect → confirmation card in the
|
||||
UI, propose/apply over MCP). Delivery itself lives in :mod:`backend.notify`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend import notify as _notify
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import NotifyExternalInput
|
||||
|
||||
|
||||
@tool(
|
||||
name="notify_external",
|
||||
description="Send a notification through external channels (Discord, Telegram, SMTP, webhook).",
|
||||
input_model=NotifyExternalInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
def notify_external(ctx: ToolContext, params: NotifyExternalInput) -> dict[str, Any]:
|
||||
"""Broadcast to the trigger scope, or target a single channel id."""
|
||||
try:
|
||||
if params.channel_id:
|
||||
channel = next(
|
||||
(c for c in _notify._read_channels() if c.get("id") == params.channel_id),
|
||||
None,
|
||||
)
|
||||
if channel is None:
|
||||
raise ToolError(f"Unknown channel: {params.channel_id}", code="not_found")
|
||||
if not channel.get("enabled", True):
|
||||
raise ToolError(f"Channel disabled: {params.channel_id}", code="invalid_arguments")
|
||||
_notify.send_via_channel(channel, params.title, params.message, params.trigger)
|
||||
return {"ok": True, "channel_id": params.channel_id}
|
||||
results = _notify.broadcast(params.trigger, params.title, params.message)
|
||||
return {"ok": True, "deliveries": results}
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ToolError(f"Notification failed: {e}", code="notify_failed") from e
|
||||
@@ -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
|
||||
@@ -441,6 +441,53 @@ class PdfInput(BaseModel):
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class FindDuplicatesInput(BaseModel):
|
||||
"""Find candidate duplicate notes in a vault (#166)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
threshold: float = Field(0.75, ge=0.3, le=1.0, description="Minimum similarity score")
|
||||
limit: int = Field(20, ge=1, le=200, description="Maximum number of pairs")
|
||||
subdir: str = Field("", description="Vault-relative directory scope (empty = whole vault)")
|
||||
|
||||
|
||||
class MergeDuplicatesInput(BaseModel):
|
||||
"""Merge one note into another, then delete the source (#166, destructive)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
source_path: str = Field(..., description="Vault-relative path of the note to absorb")
|
||||
target_path: str = Field(..., description="Vault-relative path of the surviving note")
|
||||
strategy: str = Field("append", description="'append', 'prefer_target' or 'prefer_source'")
|
||||
|
||||
|
||||
class NotifyExternalInput(BaseModel):
|
||||
"""Send a notification through external channels (#168)."""
|
||||
|
||||
title: str = Field(..., min_length=1, description="Notification title")
|
||||
message: str = Field(..., min_length=1, description="Notification body")
|
||||
trigger: str = Field("manual", description="Trigger scope: manual, schedule_failure, schedule_success")
|
||||
channel_id: str = Field("", description="Single channel id (empty = broadcast to trigger)")
|
||||
|
||||
|
||||
class CreateScheduledTaskInput(BaseModel):
|
||||
"""Create an automatic task executed by the scheduler (#170)."""
|
||||
|
||||
name: str = Field(..., min_length=1, description="Task display name")
|
||||
action: dict[str, Any] = Field(..., description="{kind, params} (create_file, append_to_file, notify)")
|
||||
schedule: dict[str, Any] = Field(..., description="{kind, ...} (interval_hours, daily_time, once_at)")
|
||||
|
||||
|
||||
class DeleteScheduledTaskInput(BaseModel):
|
||||
"""Delete a scheduled task by id (#170)."""
|
||||
|
||||
task_id: str = Field(..., min_length=1, description="Task id")
|
||||
|
||||
|
||||
class RunScheduledTaskInput(BaseModel):
|
||||
"""Execute a scheduled task immediately (#170)."""
|
||||
|
||||
task_id: str = Field(..., min_length=1, description="Task id")
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
"""Uniform result returned by :func:`backend.tools.registry.call_tool`."""
|
||||
|
||||
|
||||
+139
-10
@@ -129,8 +129,16 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
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).
|
||||
decorations = []
|
||||
if font and font.underline:
|
||||
fragments.append("text-decoration:underline")
|
||||
decorations.append("underline")
|
||||
if font and getattr(font, "strike", False):
|
||||
decorations.append("line-through")
|
||||
if decorations:
|
||||
fragments.append("text-decoration:" + " ".join(decorations))
|
||||
size = getattr(font, "size", None) if font else None
|
||||
if isinstance(size, (int, float)) and size != 11:
|
||||
fragments.append(f"font-size:{size:g}pt")
|
||||
fmt = cell.number_format
|
||||
if fmt and fmt not in ("General", "@"):
|
||||
# A custom number format is signalled typographically (mono font)
|
||||
@@ -140,29 +148,129 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
fragments.append("font-family:'JetBrains Mono',monospace")
|
||||
alignment = cell.alignment
|
||||
align = alignment.horizontal if alignment else None
|
||||
return fragments, (align if align in ("left", "right", "center") else None)
|
||||
if alignment is not None:
|
||||
vertical = getattr(alignment, "vertical", None)
|
||||
if vertical == "center":
|
||||
fragments.append("vertical-align:middle")
|
||||
elif vertical in ("top", "bottom"):
|
||||
fragments.append(f"vertical-align:{vertical}")
|
||||
if getattr(alignment, "wrap_text", None) is True:
|
||||
fragments.append("white-space:normal")
|
||||
elif getattr(alignment, "wrap_text", None) is False:
|
||||
fragments.append("white-space:nowrap")
|
||||
rotation = getattr(alignment, "text_rotation", None) or 0
|
||||
if rotation == 90 or rotation == 255:
|
||||
fragments.append("writing-mode:vertical-rl")
|
||||
elif rotation == 180:
|
||||
fragments.append("writing-mode:vertical-rl;transform:scale(-1)")
|
||||
else:
|
||||
# Angles diagonaux (ex. ±45° du menu Format › Rotation) : OOXML
|
||||
# stocke l'anti-horaire sur 0..90 et l'horaire sur 91..180 (-45°
|
||||
# → 135, cf. mutations). Le CSS tourne en sens horaire : signe
|
||||
# inversé pour un rendu fidèle.
|
||||
excel = rotation if rotation <= 90 else -(180 - rotation)
|
||||
if excel:
|
||||
fragments.append(f"transform:rotate({-excel}deg)")
|
||||
border = getattr(cell, "border", None)
|
||||
if border is not None:
|
||||
sides = []
|
||||
for side_name in ("left", "right", "top", "bottom"):
|
||||
side = getattr(border, side_name, None)
|
||||
style = getattr(side, "style", None)
|
||||
if not style:
|
||||
continue
|
||||
color = getattr(getattr(side, "color", None), "rgb", None)
|
||||
hexpart = ""
|
||||
if isinstance(color, str) and len(color) == 8:
|
||||
hexpart = f" #{color[2:].lower()}"
|
||||
width = {"medium": "2px", "thick": "3px"}.get(style, "1px")
|
||||
css_style = {"dashed": "dashed", "dotted": "dotted", "double": "double"}.get(
|
||||
style, "solid"
|
||||
)
|
||||
sides.append(f"border-{side_name}:{width} {css_style}{hexpart}")
|
||||
fragments.extend(sides)
|
||||
return fragments, (align if align in ("left", "right", "center", "justify") else None)
|
||||
|
||||
|
||||
def _sheet_style_maps(ws: Any) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet.
|
||||
def _sheet_style_maps(
|
||||
ws: Any,
|
||||
) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
|
||||
"""Flat maps of one worksheet: css, align, comment text, number format.
|
||||
|
||||
The flat string is what the API serves and what the viewer applies
|
||||
verbatim to ``td.style``; a plain cell is simply absent from the map.
|
||||
``left`` is the table default and never included. Bounded by
|
||||
``MAX_ROWS x MAX_COLS`` like the render itself.
|
||||
``MAX_ROWS x MAX_COLS`` like the render itself. Comment texts are capped
|
||||
(tooltip use) and formats only list non-General number formats.
|
||||
"""
|
||||
styles: dict[str, str] = {}
|
||||
aligns: dict[str, str] = {}
|
||||
comments: dict[str, str] = {}
|
||||
formats: dict[str, str] = {}
|
||||
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS):
|
||||
for cell in row:
|
||||
if cell.value is None and cell.number_format == "General":
|
||||
comment = getattr(cell, "comment", None)
|
||||
comment_text = getattr(comment, "text", None) if comment is not None else None
|
||||
# Une cellule vide mais stylée (gras, diagonale, commentaire…)
|
||||
# doit survivre à la relecture : `has_style` (et le commentaire)
|
||||
# sont les seuls tests rapides qui la distinguent d'une cellule
|
||||
# vraiment vierge — sinon le style semble « ne pas fonctionner »
|
||||
# dès qu'on recharge le classeur.
|
||||
if cell.value is None and cell.number_format == "General" and not cell.has_style:
|
||||
if comment_text:
|
||||
comments[cell.coordinate] = str(comment_text)[:500]
|
||||
continue
|
||||
fragments, align = _cell_fragments(cell)
|
||||
if fragments:
|
||||
styles[cell.coordinate] = ";".join(fragments)
|
||||
if align and align != "left":
|
||||
aligns[cell.coordinate] = align
|
||||
return styles, aligns
|
||||
if comment_text:
|
||||
comments[cell.coordinate] = str(comment_text)[:500]
|
||||
fmt = getattr(cell, "number_format", None)
|
||||
if fmt and fmt not in ("General", "@", None):
|
||||
formats[cell.coordinate] = str(fmt)
|
||||
return styles, aligns, comments, formats
|
||||
|
||||
|
||||
def _sheet_dim_maps(ws: Any) -> tuple[dict[str, float], dict[str, float]]:
|
||||
"""Explicit column widths (units) and row heights (pt), by letter/number.
|
||||
|
||||
Only dimensions the file sets explicitly are listed (defaults are a
|
||||
rendering concern of the client). Bounded like the render itself.
|
||||
"""
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
colwidths: dict[str, float] = {}
|
||||
try:
|
||||
for key, dim in (getattr(ws, "column_dimensions", {}) or {}).items():
|
||||
width = getattr(dim, "width", None)
|
||||
if not isinstance(width, (int, float)):
|
||||
continue
|
||||
letters = str(key).split(":")
|
||||
try:
|
||||
cols = [column_index_from_string(a.strip()) for a in letters]
|
||||
except ValueError:
|
||||
continue
|
||||
lo, hi = min(cols), max(cols)
|
||||
for c in range(lo, min(hi, MAX_COLS) + 1):
|
||||
if c >= 1:
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
colwidths[get_column_letter(c)] = float(width)
|
||||
except Exception:
|
||||
logger.debug("xlsx column widths unavailable", exc_info=True)
|
||||
rowheights: dict[str, float] = {}
|
||||
try:
|
||||
for r, dim in (getattr(ws, "row_dimensions", {}) or {}).items():
|
||||
if not isinstance(r, int) or not 1 <= r <= MAX_ROWS:
|
||||
continue
|
||||
height = getattr(dim, "height", None)
|
||||
if isinstance(height, (int, float)):
|
||||
rowheights[str(r)] = float(height)
|
||||
except Exception:
|
||||
logger.debug("xlsx row heights unavailable", exc_info=True)
|
||||
return colwidths, rowheights
|
||||
|
||||
|
||||
def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]:
|
||||
@@ -445,7 +553,8 @@ def invalidate_workbook_meta(file_path: Path | str) -> None:
|
||||
|
||||
|
||||
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
|
||||
"""Return ``{sheet: {styles, aligns, comments, formats, colwidths,
|
||||
rowheights, merges, freeze}}``.
|
||||
|
||||
One normal (non-streaming) load serves the three A15 metadata maps: the
|
||||
fragments are the workbook's own values, a failure yields ``{}`` per sheet
|
||||
@@ -483,12 +592,17 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
for ws in wb.worksheets:
|
||||
styles, aligns = _sheet_style_maps(ws)
|
||||
styles, aligns, comments, formats = _sheet_style_maps(ws)
|
||||
colwidths, rowheights = _sheet_dim_maps(ws)
|
||||
merged = getattr(ws, "merged_cells", None)
|
||||
ranges = getattr(merged, "ranges", None) or []
|
||||
out[ws.title] = {
|
||||
"styles": styles,
|
||||
"aligns": aligns,
|
||||
"comments": comments,
|
||||
"formats": formats,
|
||||
"colwidths": colwidths,
|
||||
"rowheights": rowheights,
|
||||
"merges": [str(r) for r in ranges],
|
||||
"freeze": str(getattr(ws, "freeze_panes", None) or ""),
|
||||
}
|
||||
@@ -496,7 +610,14 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
except Exception:
|
||||
logger.debug("xlsx meta unavailable", exc_info=True)
|
||||
for t in wb.sheetnames:
|
||||
out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""})
|
||||
out.setdefault(
|
||||
t,
|
||||
{
|
||||
"styles": {}, "aligns": {}, "comments": {}, "formats": {},
|
||||
"colwidths": {}, "rowheights": {},
|
||||
"merges": [], "freeze": "",
|
||||
},
|
||||
)
|
||||
return out
|
||||
finally:
|
||||
wb.close()
|
||||
@@ -565,6 +686,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
"styles": sheet_meta.get("styles", {}),
|
||||
"aligns": sheet_meta.get("aligns", {}),
|
||||
"comments": sheet_meta.get("comments", {}),
|
||||
"formats": sheet_meta.get("formats", {}),
|
||||
"colwidths": sheet_meta.get("colwidths", {}),
|
||||
"rowheights": sheet_meta.get("rowheights", {}),
|
||||
"merges": sheet_meta.get("merges", []),
|
||||
"freeze": sheet_meta.get("freeze", ""),
|
||||
}
|
||||
@@ -629,6 +754,10 @@ def read_sheet_window(
|
||||
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
|
||||
"styles": meta.get("styles", {}),
|
||||
"aligns": meta.get("aligns", {}),
|
||||
"comments": meta.get("comments", {}),
|
||||
"formats": meta.get("formats", {}),
|
||||
"colwidths": meta.get("colwidths", {}),
|
||||
"rowheights": meta.get("rowheights", {}),
|
||||
"merges": meta.get("merges", []),
|
||||
"freeze": meta.get("freeze", ""),
|
||||
}
|
||||
|
||||
@@ -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.49.0"
|
||||
version = "2.55.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.49.0"
|
||||
version = "2.55.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -38,5 +38,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=.git/refs/heads/main");
|
||||
println!("cargo:rerun-if-changed=.git/refs/tags");
|
||||
|
||||
println!("cargo:rerun-if-changed=permissions");
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"allow-app-commands",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"dialog:default",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","allow-app-commands","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
|
||||
@@ -2210,6 +2210,12 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
|
||||
"type": "string",
|
||||
"const": "allow-app-commands",
|
||||
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2210,6 +2210,12 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
|
||||
"type": "string",
|
||||
"const": "allow-app-commands",
|
||||
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Commandes applicatives ObsiGate — autorisées depuis l'origine distante.
|
||||
#
|
||||
# La fenêtre Tauri redirige vers http://127.0.0.1:<port> (page servie par le
|
||||
# backend Python). Pour Tauri v2 c'est une origine *remote* : sans entrée ACL
|
||||
# explicite, TOUTE commande de l'app (invoke_handler) est rejetée avec
|
||||
# "Command ... not allowed by ACL" — le bouton « Choisir mon dossier » et le
|
||||
# wizard ne faisaient alors rien (erreurs avalées par desktop.js:invoke()).
|
||||
#
|
||||
# Auto-généré en… non : édité à la main. Liste = tauri::generate_handler![…]
|
||||
# dans src/main.rs — ajouter toute nouvelle commande ici.
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-app-commands"
|
||||
description = "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
|
||||
commands.allow = [
|
||||
"get_backend_url",
|
||||
"get_version",
|
||||
"get_config",
|
||||
"save_vault_path",
|
||||
"get_vault_path",
|
||||
"get_wizard_state",
|
||||
"complete_wizard",
|
||||
"pick_vault_folder",
|
||||
"pick_folder",
|
||||
"get_system_theme",
|
||||
"restart_backend",
|
||||
"check_backend_health",
|
||||
"save_window_state",
|
||||
"get_window_state",
|
||||
"add_vault",
|
||||
"remove_vault",
|
||||
"list_vaults",
|
||||
"add_dir",
|
||||
"remove_dir",
|
||||
"list_dirs",
|
||||
]
|
||||
+19
-43
@@ -10,48 +10,23 @@ use log::{info, warn};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::core::{Interface, HSTRING, PCWSTR};
|
||||
use windows::Win32::Foundation::PROPERTYKEY;
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
};
|
||||
use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
|
||||
use windows::Win32::System::Variant::VT_LPWSTR;
|
||||
use windows::Win32::UI::Shell::{
|
||||
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
|
||||
DestinationList, EnumerableObjectCollection, ShellLink,
|
||||
};
|
||||
use windows::Win32::UI::Shell::Common::IObjectCollection;
|
||||
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
|
||||
use super::*;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::core::{Interface, HSTRING, PCWSTR};
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
};
|
||||
use windows::Win32::UI::Shell::{
|
||||
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
|
||||
DestinationList, EnumerableObjectCollection, ShellLink,
|
||||
};
|
||||
use windows::Win32::UI::Shell::Common::IObjectCollection;
|
||||
|
||||
const APP_ID: &str = "com.obsigate.desktop";
|
||||
const APP_ID: &str = "com.obsigate.desktop";
|
||||
|
||||
// PKEY_Title (System.Title): {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, pid 2
|
||||
const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
|
||||
fmtid: windows::core::GUID::from_u128(0xF29F85E0_4FF9_1068_AB91_08002B27B3D9),
|
||||
pid: 2,
|
||||
};
|
||||
|
||||
fn wide_null_terminated(s: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Set the jump-list display title via the shell link's property store.
|
||||
fn set_link_title(link: &IShellLinkW, title: &str) -> windows::core::Result<()> {
|
||||
let store: IPropertyStore = link.cast()?;
|
||||
let mut wide = wide_null_terminated(title);
|
||||
let mut pv: PROPVARIANT = unsafe { std::mem::zeroed() };
|
||||
unsafe {
|
||||
let inner = &mut *pv.Anonymous.Anonymous;
|
||||
inner.vt = VT_LPWSTR;
|
||||
inner.Anonymous.pwszVal = windows::core::PWSTR(wide.as_mut_ptr());
|
||||
store.SetValue(&PKEY_TITLE, &pv)?;
|
||||
store.Commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn wide_null_terminated(s: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Build one shell link for a vault. `arg` is the command-line argument the
|
||||
/// app expects to open that vault (see file associations / single-instance).
|
||||
@@ -70,9 +45,10 @@ mod imp {
|
||||
}
|
||||
link.SetDescription(&HSTRING::from(name))?;
|
||||
}
|
||||
// Title (display name) is best-effort — a missing title only means the
|
||||
// jump-list entry falls back to the executable name.
|
||||
let _ = set_link_title(&link, name);
|
||||
// Title (display name): the raw PROPVARIANT setter that used to live
|
||||
// here corrupted the process heap (STATUS_HEAP_CORRUPTION c0000374 —
|
||||
// the shell property store freed our Rust-allocated VT_LPWSTR buffer).
|
||||
// SetDescription below already gives jump-list entries their label.
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
|
||||
+202
-18
@@ -48,6 +48,7 @@ fn pick_free_port() -> u16 {
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use tauri::Manager;
|
||||
use tauri::webview::WebviewWindowBuilder;
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -160,6 +161,39 @@ fn save_config(config: &AppConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Premier lancement (#160) ───────────────────────────────────────────────
|
||||
|
||||
/// Répertoire de démarrage monté comme vault au premier lancement.
|
||||
const STARTER_VAULT_NAME: &str = "ObsiGate";
|
||||
/// Document d'accueil écrit (si absent) dans le répertoire de démarrage.
|
||||
const WELCOME_DOC_NAME: &str = "Prise en main.md";
|
||||
const WELCOME_DOC: &str = include_str!("prise_en_main.md");
|
||||
|
||||
/// Config par défaut au premier lancement — pure, sans I/O (testable).
|
||||
/// `<home>/ObsiGate` est monté comme vault ET comme contexte initial ; le
|
||||
/// dossier home reste une racine, nommée d'après son dernier segment
|
||||
/// (fin du « bruno » codé en dur, #160).
|
||||
fn default_first_run_config(home: &str) -> AppConfig {
|
||||
let home_path = std::path::Path::new(home);
|
||||
let vault_dir = home_path.join("ObsiGate");
|
||||
let vault_path = vault_dir.to_string_lossy().to_string();
|
||||
AppConfig {
|
||||
vault_path: Some(vault_path.clone()),
|
||||
vaults: vec![VaultConfig {
|
||||
name: STARTER_VAULT_NAME.to_string(),
|
||||
path: vault_path,
|
||||
}],
|
||||
dirs: vec![DirConfig {
|
||||
name: home_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| home.to_string()),
|
||||
path: home.to_string(),
|
||||
}],
|
||||
..AppConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backend lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
|
||||
@@ -320,6 +354,18 @@ async fn pick_vault_folder(app: tauri::AppHandle) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sélecteur de dossier générique, sans effet de bord (contrairement à
|
||||
/// `pick_vault_folder` qui écrit `vault_path` + `wizard_done` pour le wizard).
|
||||
/// Sert à l'ajout d'un dossier racine depuis la section Configuration (#159).
|
||||
#[tauri::command]
|
||||
async fn pick_folder(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
match app.dialog().file().blocking_pick_folder() {
|
||||
Some(p) => Ok(p.to_string()),
|
||||
None => Err("No folder selected".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_wizard_state() -> bool {
|
||||
load_config().wizard_done
|
||||
@@ -343,6 +389,13 @@ fn get_system_theme(app: tauri::AppHandle) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// La jump list des raccourcis suit les vaults : à rafraîchir après tout
|
||||
/// ajout/retrait de vault (#159). Best-effort — `update_jumplist` logge et n'échoue jamais.
|
||||
fn refresh_jumplist() {
|
||||
let vaults = load_config().vaults;
|
||||
jumplist::update_jumplist(&jumplist::build_vault_args(&vaults));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn add_vault(name: String, path: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
@@ -350,6 +403,7 @@ fn add_vault(name: String, path: String) -> Result<(), String> {
|
||||
config.vaults.retain(|v| v.name != name);
|
||||
config.vaults.push(VaultConfig { name: name.clone(), path });
|
||||
save_config(&config);
|
||||
refresh_jumplist();
|
||||
info!("Vault added: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -359,6 +413,7 @@ fn remove_vault(name: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
config.vaults.retain(|v| v.name != name);
|
||||
save_config(&config);
|
||||
refresh_jumplist();
|
||||
info!("Vault removed: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -513,7 +568,7 @@ fn main() {
|
||||
// Load config for window state
|
||||
let mut config = load_config();
|
||||
|
||||
// First run: create sensible defaults per platform
|
||||
// First run: create sensible defaults per platform (#160)
|
||||
if config.vaults.is_empty() {
|
||||
info!("First run — creating default vault configuration");
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -521,25 +576,28 @@ fn main() {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/bruno".to_string());
|
||||
|
||||
let vault_dir = std::path::PathBuf::from(&home).join("voute_obsidian");
|
||||
// Create the vault directory if it doesn't exist
|
||||
if !vault_dir.exists() {
|
||||
if let Err(e) = fs::create_dir_all(&vault_dir) {
|
||||
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
|
||||
} else {
|
||||
info!("Created default vault dir: {}", vault_dir.display());
|
||||
// Montage du répertoire de démarrage (vault + contexte initial) sans
|
||||
// toucher aux autres champs (taille/position de fenêtre) de la config.
|
||||
let defaults = default_first_run_config(&home);
|
||||
config.dirs = defaults.dirs;
|
||||
config.vaults = defaults.vaults;
|
||||
config.vault_path = defaults.vault_path;
|
||||
|
||||
let vault_dir = std::path::PathBuf::from(config.vaults[0].path.clone());
|
||||
if let Err(e) = fs::create_dir_all(&vault_dir) {
|
||||
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
|
||||
} else {
|
||||
info!("Created default vault dir: {}", vault_dir.display());
|
||||
}
|
||||
// Document d'accueil : écrit une seule fois, jamais écrasé (#160).
|
||||
let welcome = vault_dir.join(WELCOME_DOC_NAME);
|
||||
if !welcome.exists() {
|
||||
match fs::write(&welcome, WELCOME_DOC) {
|
||||
Ok(()) => info!("Welcome document written: {}", welcome.display()),
|
||||
Err(e) => error!("Failed to write {}: {}", welcome.display(), e),
|
||||
}
|
||||
}
|
||||
config.dirs = vec![
|
||||
DirConfig { name: "bruno".to_string(), path: home.clone() },
|
||||
];
|
||||
config.vaults = vec![
|
||||
VaultConfig {
|
||||
name: "Obsidian".to_string(),
|
||||
path: vault_dir.to_string_lossy().to_string(),
|
||||
},
|
||||
];
|
||||
config.vault_path = Some(home);
|
||||
|
||||
save_config(&config);
|
||||
info!("Default vault config saved");
|
||||
}
|
||||
@@ -589,6 +647,7 @@ fn main() {
|
||||
get_wizard_state,
|
||||
complete_wizard,
|
||||
pick_vault_folder,
|
||||
pick_folder,
|
||||
get_system_theme,
|
||||
restart_backend,
|
||||
check_backend_health,
|
||||
@@ -602,6 +661,19 @@ fn main() {
|
||||
list_dirs,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// BUG-107 / #89 — HTML5 drag & drop des fichiers : Tauri installe
|
||||
// son propre IDropTarget par-dessus celui du WebView2 (wry :
|
||||
// « find the WebView2 window and override! ») et aucun événement
|
||||
// natif n'est écouté ici → les glisser-déposer depuis
|
||||
// l'Explorateur n'atteignaient jamais la page. La fenêtre est donc
|
||||
// créée manuellement (`create: false` dans tauri.conf.json) avec
|
||||
// le handler désactivé : la page reçoit les drops comme sur le web.
|
||||
let window_config = app.config().app.windows[0].clone();
|
||||
WebviewWindowBuilder::from_config(app, &window_config)
|
||||
.expect("fenêtre principale (tauri.conf.json)")
|
||||
.disable_drag_drop_handler()
|
||||
.build()?;
|
||||
|
||||
// Build native menu bar (File / Edit / Help)
|
||||
let _ = build_native_menu(app);
|
||||
|
||||
@@ -1122,4 +1194,116 @@ mod tests {
|
||||
let dbg = format!("{:?}", d);
|
||||
assert!(dbg.contains("x"));
|
||||
}
|
||||
|
||||
/// Guardrail ACL : toute commande Tauri invoquée par le frontend doit
|
||||
/// figurer dans desktop/permissions/commands.toml. La page servie par le
|
||||
/// backend (http://127.0.0.1) est une origine *remote* pour Tauri v2 :
|
||||
/// sans cette entrée, la commande est rejetée « not allowed by ACL » et
|
||||
/// desktop.js:invoke() avale l'erreur → bouton silencieusement mort
|
||||
/// (bouton « Choisir mon dossier » du wizard, BUG-104).
|
||||
#[test]
|
||||
fn test_frontend_invokes_are_acl_allowed() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let perms = fs::read_to_string(dir.join("permissions/commands.toml"))
|
||||
.expect("desktop/permissions/commands.toml manquant");
|
||||
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
|
||||
|
||||
// Commandes déclarées dans le manifeste de permissions.
|
||||
let allow_list = perms
|
||||
.split("commands.allow")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(']').next())
|
||||
.unwrap_or("");
|
||||
let allowed: Vec<String> = allow_list
|
||||
.split('"')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert!(!allowed.is_empty(), "commands.allow vide dans commands.toml");
|
||||
|
||||
// Chaque permission doit correspondre à une commande réellement
|
||||
// enregistrée (faute de frappe → permission morte).
|
||||
for cmd in &allowed {
|
||||
assert!(
|
||||
main_src.contains(&format!("fn {cmd}(")),
|
||||
"permission allow pour la commande inconnue {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
// Toute commande invoke('…') du frontend doit être autorisée.
|
||||
let js_dir = dir.join("../frontend/js");
|
||||
let mut seen = 0usize;
|
||||
for entry in fs::read_dir(&js_dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("js") { continue; }
|
||||
let src = fs::read_to_string(&path).unwrap();
|
||||
let mut rest = src.as_str();
|
||||
while let Some(pos) = rest.find("invoke('") {
|
||||
rest = &rest[pos + "invoke('".len()..];
|
||||
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if !name.is_empty() {
|
||||
seen += 1;
|
||||
assert!(
|
||||
allowed.contains(&name),
|
||||
"commande {name} invoquée par {} absente de permissions/commands.toml",
|
||||
path.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
}
|
||||
rest = &rest[rest.find('\'').map(|i| i + 1).unwrap_or(rest.len())..];
|
||||
}
|
||||
}
|
||||
assert!(seen > 0, "aucun invoke('…') trouvé dans frontend/js");
|
||||
}
|
||||
|
||||
/// BUG-107 : la fenêtre est créée en code (`create: false`) avec le
|
||||
/// handler de drag & drop de Tauri désactivé. Si l'un des deux manque,
|
||||
/// wry réinstalle son IDropTarget par-dessus celui du WebView2 et les
|
||||
/// glisser-déposer de fichiers depuis l'Explorateur n'atteignent plus la
|
||||
/// page (#89 fonctionnait uniquement sur le web).
|
||||
#[test]
|
||||
fn test_window_created_without_tauri_drag_drop_handler() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let conf = fs::read_to_string(dir.join("tauri.conf.json")).unwrap();
|
||||
assert!(
|
||||
conf.contains("\"create\": false"),
|
||||
"tauri.conf.json : la fenêtre doit être créée en code (create: false)"
|
||||
);
|
||||
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
|
||||
assert!(
|
||||
main_src.contains("disable_drag_drop_handler()"),
|
||||
"main.rs : disable_drag_drop_handler() manquant — drag & drop bloqué"
|
||||
);
|
||||
}
|
||||
|
||||
/// #160 — premier lancement : `<home>/ObsiGate` monté comme vault et
|
||||
/// comme contexte initial, racine home nommée d'après son chemin.
|
||||
#[test]
|
||||
fn test_default_first_run_config() {
|
||||
#[cfg(target_os = "windows")]
|
||||
let home = r"C:\Users\alice";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let home = "/home/alice";
|
||||
|
||||
let c = default_first_run_config(home);
|
||||
assert_eq!(c.vaults.len(), 1);
|
||||
assert_eq!(c.vaults[0].name, STARTER_VAULT_NAME);
|
||||
assert!(c.vaults[0].path.ends_with("ObsiGate"), "{}", c.vaults[0].path);
|
||||
assert_eq!(c.vault_path.as_deref(), Some(c.vaults[0].path.as_str()));
|
||||
assert_eq!(c.dirs.len(), 1);
|
||||
assert_eq!(c.dirs[0].name, "alice", "nom de racine dérivé du chemin");
|
||||
assert_eq!(c.dirs[0].path, home);
|
||||
assert!(!c.wizard_done);
|
||||
}
|
||||
|
||||
/// #160 — le document d'accueil est embarqué dans le binaire : non vide,
|
||||
/// Markdown, et nom de fichier stable (écrit une seule fois sur le disque).
|
||||
#[test]
|
||||
fn test_welcome_doc_embedded() {
|
||||
assert!(WELCOME_DOC.len() > 500, "document d'accueil trop court");
|
||||
assert!(WELCOME_DOC.starts_with('#'), "attendu : titre Markdown");
|
||||
assert!(WELCOME_DOC.contains("ObsiGate"));
|
||||
assert!(WELCOME_DOC.contains("Ctrl+Espace"));
|
||||
assert_eq!(WELCOME_DOC_NAME, "Prise en main.md");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 🟢 Bienvenue sur ObsiGate — Prise en main
|
||||
|
||||
Ce dossier `%USERPROFILE%\ObsiGate` est votre **espace de départ** : il est
|
||||
monté automatiquement comme vault **ObsiGate** au premier lancement. Vous
|
||||
pouvez y écrire librement, puis brancher vos vrais vaults Obsidian quand vous
|
||||
êtes prêt.
|
||||
|
||||
## ObsiGate en 30 secondes
|
||||
|
||||
ObsiGate est une porte d'entrée locale vers vos notes : index en mémoire,
|
||||
recherche instantanée, lecture Markdown avec liens `[[wikilinks]]`, images,
|
||||
PDF, tableurs Excel, Excalidraw, Mermaid et assistant IA — sans base de
|
||||
données, sans modification de vos fichiers, tout reste sur votre machine.
|
||||
|
||||
## Premiers pas (application desktop)
|
||||
|
||||
1. **Ce document** s'affiche dans la visionneuse : c'est votre première note,
|
||||
modifiez-la ou supprimez-la quand vous n'en avez plus besoin.
|
||||
2. **Ajouter vos vaults** : Configuration → « 🖥️ Vaults & dossiers (Desktop) »
|
||||
→ *Ajouter un vault* (sélecteur de dossier natif). Depuis la sidebar,
|
||||
clic droit sur une racine → « Retirer de l'application » la désiste
|
||||
(aucun fichier n'est supprimé).
|
||||
3. **Naviguer** : la sidebar liste vos racines et leur arbre ; l'onglet
|
||||
Navigation parcourt un dossier sous forme de vues répertoire.
|
||||
4. **Rechercher** : `Ctrl+Espace` (palette de fichiers),
|
||||
`Ctrl+Alt+Espace` (palette de commandes), `Ctrl+F` dans un document.
|
||||
5. **Onglets** : `Ctrl+W` ferme l'onglet, `Ctrl+Tab` bascule ; le mode split
|
||||
divise l'écran en panneaux.
|
||||
6. **Guide complet** : bouton d'aide de l'en-tête — sommaire, raccourcis,
|
||||
architecture, et téléchargement du guide en Markdown/PDF.
|
||||
|
||||
## Le saviez-vous ?
|
||||
|
||||
- Le **glisser-déposer** de fichiers depuis l'Explorateur est pris en charge :
|
||||
déposez-les sur la fenêtre pour les importer.
|
||||
- Le **clic droit** est partout : menus contextuels de fichier, dossier et
|
||||
vault (avec renommage, création, suppression, vue graphique).
|
||||
- Le **watcher** surveille vos vaults : une note modifiée dans Obsidian
|
||||
apparaît immédiatement ici.
|
||||
|
||||
— *Ce fichier vous appartient.*
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.49.0",
|
||||
"version": "2.55.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
@@ -13,6 +13,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "ObsiGate",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
|
||||
@@ -13,14 +13,14 @@ services:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate-test
|
||||
user: "1000:1000"
|
||||
user: "197609:197609"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2021:8080"
|
||||
volumes:
|
||||
- /tmp/obsigate-test-vault:/vaults/TestVault
|
||||
- /tmp/obsigate-test-dir:/vaults/TestDir
|
||||
- /tmp/obsigate-test-data:/app/data
|
||||
- ./test_vault:/vaults/TestVault
|
||||
- ./test_dir:/vaults/TestDir
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
@@ -51,3 +51,4 @@ services:
|
||||
|
||||
volumes:
|
||||
ollama_data:
|
||||
obsigate_data:
|
||||
|
||||
@@ -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é
|
||||
|
||||
@@ -174,7 +174,7 @@ et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md).
|
||||
| **Rate limiting MFA** | Appliqué aux endpoints TOTP/WebAuthn/recovery |
|
||||
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
|
||||
| **Backup automatique** | Avant chaque modification/suppression dans `.obsigate-backup/` |
|
||||
| **Redaction** | Masquage des JWT, clés API, tokens dans les aperçus et retours d'outils |
|
||||
| **Redaction** | Masquage des JWT, mots de passe, clés API (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens et connection strings dans les aperçus markdown et les retours d'outils — clic sur un masque = copie de la valeur |
|
||||
| **CSP** | `object-src`, `base-uri`, `form-action`, `frame-ancestors` restreints |
|
||||
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
|
||||
| **Utilisateur non-root** | Conteneur sous `obsigate` (UID 1000) |
|
||||
@@ -187,6 +187,30 @@ et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md).
|
||||
Une politique minimale est validée à la création d'un compte. Choisissez des mots
|
||||
de passe longs et uniques ; activez le MFA pour les comptes admin.
|
||||
|
||||
### Secrets masqués dans les aperçus
|
||||
|
||||
Quand une note contient un secret, l'aperçu markdown le remplace par un masque
|
||||
— `[CLÉ API MASQUÉE]`, `[MOT DE PASSE MASQUÉ]`, `[JWT MASQUÉ]`,
|
||||
`[CONNECTION_STRING MASQUÉE]` — au lieu de la valeur :
|
||||
|
||||
- **Détectés automatiquement** : mots de passe (`password=`, `"passwd": "…"`,
|
||||
`db_password=…`, toute longueur), affectations `api_key=` / `token=` /
|
||||
`secret=`, JWT, clés privées, connection strings, hex en contexte secret,
|
||||
et les formats de clés les plus répandus — OpenAI/Anthropic/OpenRouter
|
||||
(`sk-`), GitHub (`ghp_`, `github_pat_`), Google (`AIza…`, `ya29.`), AWS
|
||||
(`AKIA…`/`ASIA…`), Slack (`xoxb-`), Stripe (`sk_live_`, `whsec_`), GitLab
|
||||
(`glpat-`), Hugging Face (`hf_`), npm, Docker, SendGrid, Resend, Square,
|
||||
Atlassian, Discord, Telegram, jetons `Bearer …`.
|
||||
- **Clic = copie** : dans l'application (aperçu authentifié), cliquer sur un
|
||||
masque copie la valeur réelle dans le presse-papiers (infobulle « Cliquer
|
||||
pour copier la valeur »). La valeur n'apparaît jamais en clair à l'écran.
|
||||
- **Jamais exposé à l'extérieur** : partages publics (`/s/{token}`), exports
|
||||
PDF et contexte envoyé à l'IA / au serveur MCP ne reçoivent que le libellé,
|
||||
jamais la valeur derrière le masque.
|
||||
- **Hors périmètre** : la vue « source » (fichier brut) et les aperçus de
|
||||
fichiers non-markdown ne sont pas masqués — c'est le fichier lui-même qui
|
||||
est affiché.
|
||||
|
||||
---
|
||||
|
||||
## 7. Variables d'environnement
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -134,24 +134,79 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
||||
|
||||
## 6. Tableurs Excel (XLSX)
|
||||
|
||||
### Créer un classeur
|
||||
|
||||
Pour démarrer un tableur, clic droit sur un vault ou un dossier →
|
||||
**Nouveau fichier** (ou **Nouveau fichier ici**), choisissez le type **Excel
|
||||
(.xlsx)** et saisissez un nom : le classeur est créé avec une feuille
|
||||
**Feuille1** et s'ouvre directement dans l'éditeur, prêt à être saisi. La même
|
||||
liste de types est accessible depuis la palette de commandes (`Ctrl+Alt+Space`).
|
||||
|
||||
### Affichage et édition
|
||||
|
||||
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
|
||||
feuille, des onglets pour naviguer entre elles (toujours visibles, même à
|
||||
une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de
|
||||
commandes regroupe les actions en sections (Formules · Insertion · Vue ·
|
||||
Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est
|
||||
modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie.
|
||||
**Enregistrer** envoie les cellules modifiées à
|
||||
commandes reprend l'ordre de Google Sheets (recherche, annuler/rétablir,
|
||||
impression, peinture, zoom, formats de nombre, police, couleurs, bordures,
|
||||
fusion, alignements, retour ligne, rotation, options) et le bouton **⋮**
|
||||
regroupe figeage, tri, filtre, dimensions, lien, commentaire ; les menus
|
||||
**Mise en forme** / **Structure** restent disponibles. Elle est **épinglée en
|
||||
haut** de la page et l'éditeur occupe toute la largeur du contenu, pour que
|
||||
les commandes restent atteignables pendant le défilement d'un grand tableau.
|
||||
|
||||
Les largeurs de colonnes et hauteurs de lignes s'ajustent en **glissant le
|
||||
bord des en-têtes** (souris et tactile) : en plaçant le pointeur sur le bord
|
||||
d'un en-tête de colonne (`A`, `B`, `C`…) ou de ligne (`1`, `2`, `3`…), le
|
||||
curseur change pour indiquer l'ajustement.
|
||||
|
||||
### Sélectionner et saisir
|
||||
|
||||
Comme dans Google Sheets et Excel, une cellule est **sélectionnée** ou **en cours
|
||||
de saisie** — jamais les deux à la fois.
|
||||
|
||||
- **Sélectionner** — un clic ou les flèches : la cellule s'entoure, sans curseur
|
||||
clignotant.
|
||||
- **Saisir** — un **double-clic** (le curseur se place sous le pointeur, la saisie
|
||||
se fait en place), la **première frappe** (qui *remplace* le contenu au lieu
|
||||
de s'y ajouter), ou `F2`.
|
||||
- **Valider et descendre** — `Entrée` pendant la saisie enregistre et place le
|
||||
focus **sur la cellule du dessous**. `Entrée` sur une cellule simplement
|
||||
sélectionnée la bascule en saisie. `Tab` fait de même vers la droite. Les
|
||||
**flèches** valident aussi et déplacent la sélection.
|
||||
- **Quitter la saisie** — `Échap` restaure la valeur d'origine ; un clic ailleurs
|
||||
conserve ce qui a été tapé.
|
||||
- `Alt`+flèche insère un saut de ligne **dans** la cellule.
|
||||
|
||||
**Sélection multiple** — `Maj`+clic ou `Maj`+flèches étendent la plage depuis la
|
||||
cellule d'origine ; glisser à la souris fait de même. Un **clic simple** réduit
|
||||
toujours la sélection à la cellule cliquée. La plage est **contourée** comme dans
|
||||
Excel, et les en-têtes couverts (lettres et numéros de ligne) sont mis en
|
||||
évidence. Depuis les **marges**, un clic sur un numéro de ligne ou une lettre de
|
||||
colonne sélectionne la **ligne ou la colonne entière**, et glisser — ou
|
||||
`Maj`+clic — étend à **plusieurs lignes ou colonnes** (`A1:Z5`). Une sélection
|
||||
de plusieurs cellules affiche une mini-barre (lien, commentaire,
|
||||
réinitialiser le filtre, somme). **Enregistrer**, placé à droite des onglets
|
||||
de feuilles, envoie les cellules modifiées à
|
||||
`PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille, avec
|
||||
**backup automatique** du fichier avant écriture, et une écriture
|
||||
**atomique** (le classeur n'est jamais laissé à moitié écrit).
|
||||
|
||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
|
||||
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
|
||||
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
|
||||
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
|
||||
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
|
||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Une
|
||||
pastille **« Lecture seule »** rappelle la seule limite de la vue : les
|
||||
formats `.xls`/`.ods`. ObsiGate calcule par ailleurs les formules courantes
|
||||
à l'écran (arithmétique, `SOMME`, `MOYENNE`, `SI`…) ; les fonctions non
|
||||
prises en charge affichent la formule telle qu'enregistrée, Excel la
|
||||
recalcule à l'ouverture.
|
||||
|
||||
Le tableau suit le **thème de l'application** : fond, en-têtes, bordures et
|
||||
lignes alternées sont dérivés du thème actif (tous les thèmes, y compris
|
||||
contrasté élevé et sépia).
|
||||
|
||||
La grille montre toujours les colonnes **A à Z** et **1000 lignes**, comme un
|
||||
tableur : les lignes et colonnes inutilisées restent affichées et leurs
|
||||
cellules vides sont **éditables** comme les autres. Seule la feuille affichée
|
||||
est étendue, à son ouverture.
|
||||
|
||||
### Avertissement avant enregistrement
|
||||
|
||||
@@ -253,7 +308,8 @@ ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
|
||||
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
|
||||
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
|
||||
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
|
||||
contenu.
|
||||
contenu. Chaque entrée porte une **icône** ; le menu se referme dès qu'il perd
|
||||
le focus (clic ailleurs, `Échap`) et se parcourt au clavier (`↑`/`↓`).
|
||||
|
||||
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
|
||||
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
|
||||
@@ -261,18 +317,24 @@ la recherche (`Ctrl+F` du panneau) parcourt **toutes les feuilles** : le compteu
|
||||
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) :
|
||||
La sortie est rassemblée dans un seul menu **Fichier**, placé à droite des
|
||||
onglets de feuilles, juste avant le bouton **Enregistrer**. Il propose cinq
|
||||
actions, 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.
|
||||
- **Télécharger le fichier** — le classeur d'origine, tel qu'il est sur le
|
||||
disque ;
|
||||
- **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** — tableau markdown ou document HTML autonome,
|
||||
mêmes règles de sélection ;
|
||||
- **Imprimer** — 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.
|
||||
Le menu se ferme au clic extérieur, à la perte de focus, avec `Échap` ou au
|
||||
défilement, et se parcourt aux flèches. 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
|
||||
@@ -280,12 +342,23 @@ colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
|
||||
automatique et confirmation, comme pour l'édition des cellules).
|
||||
|
||||
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
|
||||
**sélection courante** (une cellule ou une plage) :
|
||||
**sélection courante** (une cellule ou une plage), repris aussi dans la barre
|
||||
d'outils (gras, italique, barré, souligné, taille, couleurs, bordures,
|
||||
alignements, retour ligne, rotation) :
|
||||
|
||||
- **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) ;
|
||||
- **caractère** — gras, italique, souligné, barré, taille de police,
|
||||
effacer la mise en forme ;
|
||||
- **alignement** — gauche, centré, droite, justifié (+ vertical, retour à la
|
||||
ligne, rotation) ;
|
||||
- **bordures** — toutes, extérieures, aucune ;
|
||||
- **couleurs** — couleur de police et couleur de fond, via un **panneau façon
|
||||
Google Sheets** : *Réinitialiser*, une palette de 80 pastilles (une ligne de
|
||||
gris du noir au blanc, puis huit teintes du clair au foncé), une section
|
||||
**Standard** de huit couleurs prédéfinies et une section **Personnalisé**.
|
||||
Un clic sur une pastille referme le panneau et applique la couleur à la
|
||||
cellule ou à la plage sélectionnée. Le bouton **+** ouvre le sélecteur du
|
||||
système pour une teinte libre. « Mise en forme conditionnelle » et
|
||||
« Couleurs en alternance » sont annoncées mais pas encore disponibles ;
|
||||
- **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
|
||||
@@ -299,8 +372,10 @@ 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.
|
||||
Les **commentaires** se lisent (pastille) et s'éditent (mini-barre ou menu ⋮) ;
|
||||
les liens s'insèrent comme formules `=LIEN(url;libellé)` (ouvr. `Ctrl+clic`).
|
||||
Restent hors périmètre : validation de données, mise en forme conditionnelle,
|
||||
graphiques et tableaux croisés dynamiques.
|
||||
|
||||
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
|
||||
**macros sont préservées** à l'enregistrement (y compris le chargement des
|
||||
@@ -353,11 +428,13 @@ une feuille, insérer/supprimer des lignes ou des colonnes).
|
||||
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.
|
||||
- **Moteur de formule local** : ObsiGate calcule à l'écran l'arithmétique, les
|
||||
références, les plages et une trentaine de fonctions en français et en
|
||||
anglais (`SOMME`, `MOYENNE`, `SI`, `LIEN`…). Une saisie commençant par `=`
|
||||
ou `@` reste 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.
|
||||
Les fonctions non prises en charge affichent la valeur mise en cache par
|
||||
Excel quand elle existe. 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.
|
||||
|
||||
@@ -210,6 +210,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-100* | Retour à l'accueil incomplet : le clic sur le titre perd la section « Raccourcis & Astuces » et laisse la recherche globale ainsi que le filtre de la sidebar actifs | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`showWelcome`), `frontend/js/legacy.js` (`goHome`) | Ouvrir un fichier (le dashboard est détruit), puis cliquer le titre ObsiGate | `viewer.js` : `#quick-help` capturé au chargement du module et réinjecté dans le template reconstruit de `showWelcome()` ; `legacy.js::goHome` clique `#search-clear-btn` (chips + barre de résultats) et `#sidebar-filter-clear-btn` avant d'afficher l'accueil | **Cause racine :** `showWelcome()` reconstruit `#dashboard-home` depuis un template qui n'embarque pas le bloc `#quick-help` présent dans `index.html` — il ne disparaissait qu'après la première reconstruction (ouverture de fichier, puis retour). Livré avec #158 A3 |
|
||||
| *BUG-101* | Raccourcis d'onglets déclarés deux fois : un seul `Ctrl+W` fermait deux onglets (le second fermait celui ré-activé par la fermeture précédente) et `Ctrl+Tab` sautait un onglet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (bloc « Keyboard shortcuts for tabs » dupliqué en fin de fichier) | Ouvrir deux onglets puis `Ctrl+W` une fois | Suppression du second bloc `document.addEventListener("keydown", …)` dupliqué (fin de `ui.js`) — un seul listener reste | Exposé par #158 : le second onglet n'était plus jamais vide auparavant (la 2ᵉ fermeture tombait sur `_activeTabId === null` et devenait sans effet). Vérifié : repro Playwright (`closes: ["fichier", "nav"]` avant → `["fichier"]` après) + suite E2E |
|
||||
| *BUG-102* | Menu contextuel qui se referme ~10 ms après son ouverture : la reflow des icônes (remplacement `<i>` → `<svg>` par lucide) déclenche un événement `scroll` capturé par `ContextMenuManager` qui referme le menu | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/ui.js` (`ContextMenuManager.init/show`) | Clic droit sur un fichier de la page de navigation (ou de l'arbre) juste après un rendu d'icônes | `show()` mémorise `_shownAt` ; le listener `scroll` (capture) ignore un déclenchement dans les 250 ms suivant l'ouverture | Exposé par #158 A5 (test E2E `nav-tab.spec.js` : menu résolu « hidden » alors que 6 items étaient construits). Vérifié : instrumentation Playwright (display block conservé après le scroll) + suite E2E |
|
||||
| *BUG-103* | Mobile : le bas du sidebar de navigation est masqué par la barre d'outils du bas (`z-index` 200 < 900) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` (règle `.sidebar` du bloc `@media (max-width: 768px)`) | Vue mobile (≤768 px), sidebar ouvert depuis « Explorateur » | Passé à `z-index: 950` (au-dessus de `.mobile-toolbar`, 900) | Exposé après #158 (retour utilisateur). Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`) |
|
||||
| *BUG-104* | Desktop : toutes les commandes Tauri invoquées depuis la page backend (`http://127.0.0.1`) rejetées « not allowed by ACL » — le bouton « Choisir mon dossier » du wizard ne fait rien | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | Installeur 2.49.2 : clic sur « Choisir mon dossier » → aucune fenêtre ; console `[desktop] invoke(pick_vault_folder) failed: Command … not allowed by ACL` (capturée via CDP, WebView2 `--remote-debugging-port`) | Manifeste de permissions applicatives créé (`allow-app-commands`, 19 commandes) et référencé par la capability : Tauri v2 ACL gate toute commande invoquée depuis une origine *remote*, y compris celles de l'`invoke_handler` | `cargo test` 25 passed (nouveau garde-fou `test_frontend_invokes_are_acl_allowed`) + vérif live CDP : `get_version`→2.49.2, clic → fenêtre native « Select Folder », parcours complet ajout de vault terminé |
|
||||
| *BUG-105* | Desktop : crash 2-15 s après l'ouverture de la fenêtre (`APPCRASH c0000374`, heap corruption détectée dans ntdll) — absent de v2.0.0, apparu en 2.49.2 | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/src/jumplist.rs` (`set_link_title`) | Installeur 2.49.2 : fenêtre visible quelques secondes puis fermeture (3/3), Event Log Windows `APPCRASH obsigate-desktop.exe … c0000374`, rien dans stderr | `set_link_title` (#77) construisait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust et le confiait au property store du shell, qui le libère avec l'allocateur Windows → corruption du tas ; fonction supprimée (`SetDescription` fournit déjà le libellé) | Bisect 3 états (jumplist off = stable, titre off = stable, d'origine = crash) ; run 9 min sans crash + sortie propre `EXIT=0`, zéro événement WER (avant : 3 crashes ≤20 s) |
|
||||
| *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 |
|
||||
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
|
||||
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -316,6 +322,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-02 | #158 (A4-A6) | Fonctionnalité | `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/legacy.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/e2e/nav-tab.spec.js` (nouveau), `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/navigation-tab-158.md` | **A4** : `TabManager.openHome()` — le clic sur le titre ouvre la page d'accueil dans un **onglet Accueil** (icône Maison, `common.home` FR/EN) remonté en position 0 à chaque fois ; `deactivate()` conserve cet onglet actif, `pane-manager.js` a son propre `openHome` pour le split. **A5** : menus contextuels répertoires **et** fichiers de la page de navigation via `ContextMenuManager.show()` (avec `stopPropagation()`, le handler global referme sinon le menu) — listes vérifiées identiques à la sidebar ; la ligne d'un vault rouvre la page de navigation. **A6** : icône des répertoires à `var(--accent)` (couleur de l'arbre). **Correctif annexé** : l'effacement du filtre sidebar au retour à l'accueil n'est déclenché que s'il était actif (sinon `restoreSidebarTree()` repliait l'arbre). Vérifié : Playwright jetable 16/16, `nav-tab.spec.js` 4 tests, validate-imports/unit/i18n parity verts. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-101 | Correction | `frontend/js/ui.js` | **BUG-101 — le bloc « Keyboard shortcuts for tabs » existait en double dans `ui.js`** (fin de fichier) : un seul `Ctrl+W` déclenchait **deux** `close()`, le second refermant l'onglet ré-activé par le premier — observé avec #158 quand un onglet navigation survit à la fermeture du fichier ; `Ctrl+Tab` sautait également un onglet. Bloc dupliqué supprimé (un seul listener). Vérifié : repro Playwright (`close()` appelé 2× puis 1×) et suite E2E. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-102 | Correction | `frontend/js/ui.js` | **BUG-102 — menu contextuel refermé par sa propre ouverture** : la reflow des icônes lucide à l'affichage déclenche un `scroll` capturé qui refermait le menu ~10 ms après son apparition (constaté sur les fichiers de la page de navigation, #158 A5). `show()` mémorise `_shownAt` et le listener `scroll` ignore un déclenchement dans les 250 ms. Vérifié : instrumentation Playwright + suite E2E (`nav-tab.spec.js`). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-103 | Correction | `frontend/style.css` | **BUG-103 — sidebar mobile sous la barre d'outils du bas** : en mobile le `.sidebar` (`z-index: 200`) passait sous `.mobile-toolbar` (`z-index: 900`, 64 px de haut), masquant les dernières entrées de l'arbre ; passé à `950`. Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`). | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-104 | Correction | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | **BUG-104 — commandes Tauri rejetées par l'ACL depuis la page backend** : la fenêtre redirige vers `http://127.0.0.1:<port>`, origine *remote* pour Tauri v2, et aucune commande applicative n'était déclarée → `Command … not allowed by ACL` sur `pick_vault_folder`, `get_wizard_state`, etc. ; les erreurs sont avalées par `desktop.js:invoke()` → bouton du wizard silencieusement inopérant depuis #77. Correctif : manifeste `desktop/permissions/commands.toml` (`allow-app-commands`, 19 commandes) référencé par la capability + `rerun-if-changed=permissions` dans `build.rs`. Vérifié : `cargo test` 25 passed (nouveau garde-fou ACL), CDP live : `get_version` OK, clic → fenêtre native « Select Folder », ajout de vault complété de bout en bout. | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-105 | Correction | `desktop/src/jumplist.rs` | **BUG-105 — crash heap (c0000374) à l'ouverture du desktop** : `set_link_title` (#77) passait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust au property store du shell, qui le libère avec l'allocateur Windows → `STATUS_HEAP_CORRUPTION` détectée dans ntdll ≤20 s après le lancement (3/3, Event Log WER, même empreinte de pile). Fonction supprimée : le libellé des entrées vient de `SetDescription`. Vérifié : bisect sur 3 états de build, run 9 min `EXIT=0`, zéro événement WER. | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-106 | Correction | `backend/main.py`, `tests/test_csp_nonce.py` | **BUG-106 — page blanche sur /docs** : la CSP `#87 T5c` (`script-src` sans `unsafe-inline`, nonce frais par réponse) s'applique aussi aux pages générées par FastAPI (`/docs`, `/redoc`) dont le balisage n'est jamais passé par `inject_csp_nonce` → le navigateur refusait le script inline `SwaggerUIBundle(…)`, la div restait vide. `SecurityHeadersMiddleware` injecte désormais le nonce dans ce HTML avec le helper existant ; le corps est décompressé/recompressé car `SSESafeGZipMiddleware` est plus proche de la route (les réponses arrivent gunzippées). `/redoc` n'a aucun script inline (bundle jsdelivr seul, couvert par `script-src`) — vérifié tel quel. Vérifié : `test_docs_inline_script_nonced`, `test_redoc_served_with_csp`, `test_docs_external_bundle_not_nonced` + 45 passed sur les 3 suites sécurité, ruff/mypy 0. | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
|
||||
| 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+470
-5
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.49.0 | **Dernière mise à jour :** 2026-10-02
|
||||
> **Version :** 2.55.0 | **Dernière mise à jour :** 2026-10-08
|
||||
> **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,9 +40,29 @@
|
||||
- [ ] **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)
|
||||
## ✅ Terminé — Visionneuse & édition Excel (P0/P1/P2)
|
||||
|
||||
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
|
||||
|
||||
@@ -92,7 +112,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Refonte UI/UX tableur (P2)
|
||||
## ✅ Terminé — Refonte UI/UX tableur (P2)
|
||||
|
||||
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
@@ -119,7 +139,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Ergonomie tableur
|
||||
## ✅ Terminé — Ergonomie tableur
|
||||
|
||||
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
|
||||
|
||||
@@ -137,6 +157,260 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Polish menus tableur
|
||||
|
||||
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-04** (v2.53.0) — ouvert le 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é — Ergonomie & grille du tableur
|
||||
|
||||
### 186. Création d'un fichier Excel depuis « Créer un fichier »
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
- **Statut :** ✅ **livré le 2026-10-05** - ouvert le 2026-10-05
|
||||
- **Fiche :** [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md)
|
||||
- **Description :** l'éditeur Excel étant disponible, la modale « Créer un fichier » ne
|
||||
proposait que des formats texte (`.md`, `.txt`, `.py`, `.js`, `.json`, `.yaml`, `.sh`,
|
||||
`.ps1`) + Excalidraw : impossible de démarrer un classeur depuis l'UI. Il fallait
|
||||
passer par l'assistant IA (`create_xlsx`) ou déposer un fichier existant.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Option **Excel (.xlsx)** dans la liste des types, et les 10 libellés du
|
||||
`<select>` passés en i18n FR/EN (`modal.ext_*`) — ils étaient codés en dur
|
||||
- [x] **A2** `create_file()` backend : un `.xlsx` est une archive OPC, pas du texte —
|
||||
le classeur est **construit avec openpyxl** (une feuille `Feuille1`) au lieu
|
||||
d'écrire la charge utile vide, sinon le fichier est illisible par la visionneuse
|
||||
- [x] **A3** Tests : non-régression pytest (`test_api_main.py` — classeur relu par
|
||||
openpyxl et servi en `is_xlsx`), E2E `xlsx-viewer.spec.js` (option présente,
|
||||
création depuis le menu contextuel, ouverture dans la grille)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Assistant IA
|
||||
|
||||
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
- **Statut :** ✅ **livré le 2026-10-07** (v2.53.2) — 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. Un
|
||||
utilisateur pouvait donc voir l'assistant agir sans jamais avoir activé le
|
||||
bouton. 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 désormais 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é — Barre de menus & grille du tableur
|
||||
|
||||
### 185. Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-menus-185.md](./features/xlsx-menus-185.md)
|
||||
- **Description :** l'éditeur n'avait **aucune barre de menus** (ni Fichier, ni
|
||||
Modifier, ni Affichage, ni Insertion, ni Format, ni Données, ni Outils, ni
|
||||
Gemini, ni Extensions, ni Aide) : beaucoup de fonctions n'étaient atteignables
|
||||
que par clic droit. Le ruban gardait un chromer bordersé qui ne ressemblait à
|
||||
rien et la grille n'était bordée qu'en bas avec une couleur quasi identique au
|
||||
fond des cellules — le quadrillage se lisait comme une tache.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** `frontend/js/xlsx/menus.js` : les **dix menus de Google Sheets**,
|
||||
dans l'ordre et avec les libellés exacts (161 entrées) ; sections en
|
||||
accordéon ; le menu **Fichier** quitte la barre d'onglets pour la barre de
|
||||
menus en **conservant ses identifiants**, seul *Enregistrer* reste
|
||||
épinglé
|
||||
- [x] **A2** **Majorité des fonctions câblées** : Partager, Renommer,
|
||||
Corbeille, Historique, Détails ; Annuler/Rétablir/Couper/Copier/Coller,
|
||||
**Collage spécial (valeurs)**, Supprimer, Tout sélectionner,
|
||||
Rechercher-et-remplacer ; Figer, Afficher (barre de formule / grille /
|
||||
en-têtes / tableau de bord), Zoom, Plein écran ; Lignes / Colonnes /
|
||||
Cellules, Lien, Commentaire, Fonction ; Nombre, Thème, B/I/U/S, Taille,
|
||||
Alignement, Retour ligne, Rotation, **Couleurs alternées**, Effacer ;
|
||||
Trier, **Filtre**, Plages nommées, **Nettoyage (doublons / espaces)**,
|
||||
**Diviser le texte en colonnes** ; Autocomplétion, Accessibilité ;
|
||||
Gemini ; Aide, Mises à jour, Raccourcis, Signalement. Les 36 options
|
||||
absentes d'ObsiGate **sont listées et le disent** au clic
|
||||
- [x] **A3** **Ruban façon Sheets** : boutons plats 30 × 30 sans chromer,
|
||||
survol discret, groupes par filets ; nouveau bouton grille piloté par le
|
||||
menu Affichage
|
||||
- [x] **A4** **Grille unie et visible** : 1 px **sur les quatre côtés** de
|
||||
chaque cellule (en-têtes compris) et un seul `--grid-border` dérivé du
|
||||
thème (`color-mix` texte → surface) au lieu de `--border` invisible
|
||||
- [x] Tests JSDOM (`xlsx-menus` + `xlsx-viewer`), E2E `xlsx-viewer.spec.js`,
|
||||
i18n FR/EN (134 clés `xlsx.m*` / `xlsx.mi_*`)
|
||||
|
||||
---
|
||||
|
||||
### 184. Tableur — peinture de format, multi-lignes/colonnes, grille A→Z × 1000
|
||||
|
||||
- **Effort :** 1 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§8)
|
||||
- **Description :** « Peindre le format » échouait quand la source n'avait pas de
|
||||
format propre ; il manquait la sélection de plusieurs lignes/colonnes depuis
|
||||
les marges ; la grille s'arrêtait aux dimensions réelles du fichier au lieu
|
||||
d'aller jusqu'à A → Z et 1000 lignes.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Peinture de format **complète** (toutes propriétés) + source sans
|
||||
format ⇒ **réinitialisation** de la cible ; fuite du handler annulé
|
||||
- [x] **A2** Sélection multi-lignes / multi-colonnes par glissement sur les
|
||||
marges et `Maj`+clic ; le geste de redimensionnement garde la priorité
|
||||
- [x] **A3** Grille étendue à **A → Z × 1000 lignes**, cellules vides
|
||||
éditables, padding à l'activation de l'onglet, pas de doublons
|
||||
- [x] **A4** Performance : index de cellules `findTd`, court-circuits
|
||||
formule/styles, marqueur `data-wired`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Panneau de couleurs du tableur
|
||||
|
||||
### 183. Panneau de sélection de couleurs façon Google Sheets (texte et fond)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§7)
|
||||
- **Description :** le sélecteur natif `<input type="color">` du ruban et du menu
|
||||
Mise en forme est remplacé par un panneau conforme aux maquettes : *Réinitialiser*,
|
||||
palette 8 × 10, STANDARD, PERSONNALISÉ, pied de page. Un clic sur une pastille
|
||||
referme le panneau et applique la couleur.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Module `frontend/js/xlsx/color-picker.js` : palette 8 × 10
|
||||
(8 gris puis 8 teintes × 9 nuances), **sortie en `#rrggbb`**
|
||||
- [x] **A2** Coche de sélection blanche/noire selon la luminance
|
||||
- [x] **A3** Câblage ruban + menu Mise en forme, fermeture au clic, application
|
||||
sur cellule **ou plage**, réinitialisation, « + » / crayons vers le
|
||||
sélecteur natif
|
||||
- [x] **A4** Chrome du panneau thémé (variables CSS), i18n FR/EN (21 clés),
|
||||
pied de page honnête (message explicite pour les fonctions absentes)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Correctifs éditeur tableur
|
||||
|
||||
### 182. Tableur — désélection fiable & couleurs sur une plage
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§6)
|
||||
- **Description :** suite de #181 — la sélection multiple se dessinait mais ne
|
||||
s'effaçait plus, et l'application d'une couleur de texte / de fond sur une
|
||||
plage paraissait inerte.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Le clic simple efface toujours la plage, sans dépendre de
|
||||
l'événement `focus` (cause : `Maj`+clic + `preventDefault()` figeait le
|
||||
focus, donc aucun `focus` ne partait au clic suivant)
|
||||
- [x] **A2** Après `Maj`+clic, le focus suit la cellule cliquée ; la zone Nom
|
||||
affiche la plage après un glissement
|
||||
- [x] **A3** Couleurs de texte et de fond vérifiées sur les 3 chemins
|
||||
(menu Mise en forme, nuanciers du ruban, `Maj`+flèches et glissement)
|
||||
- [x] **A4** Pastille de couleur sur les nuanciers du ruban (retour visuel)
|
||||
- [x] **A5** **Contours de plage qui s'empilaient** : le sélecteur de
|
||||
nettoyage oubliait le `.` des classes (`querySelectorAll("xlsx-sel-top")`
|
||||
cherche une balise) — rien n'était effacé, les encadrés restaient et
|
||||
s'ajoutaient à chaque changement de cellule. Même famille de faute sur
|
||||
les en-têtes reflétés
|
||||
- [x] **A6** Pendant la saisie, les flèches **valident et déplacent** la
|
||||
sélection (au lieu de déplacer le curseur), comme Google Sheets ;
|
||||
`Alt`+flèche garde le saut de ligne
|
||||
- [x] Tests JSDOM `xlsx-viewer` **152/152** (+6), suites frontend vertes
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Polish éditeur tableur (mise en page & thèmes)
|
||||
|
||||
### 181. Éditeur tableur — modèle de saisie Google Sheets (sélection ≠ édition) & écarts fonctionnels
|
||||
|
||||
- **Effort :** 1 jour (A1-A3) + inventaire des écarts (A4) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md)
|
||||
- **Description :** l'éditeur ne distingue pas **sélectionner** une cellule et
|
||||
**l'éditer** : le curseur de texte est visible en permanence, `Entrée` valide
|
||||
puis perd le focus, et l'indicateur de plage est discret. Aligner le modèle de
|
||||
saisie sur Google Sheets / Excel (A1-A3) et **inventorier** précisément ce qui
|
||||
manque encore face à un tableur de référence (A4) — le rapprochement exhaustif
|
||||
n'est pas réalisable en une tranche.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Deux états distincts : *sélection* (clic / flèches, pas de
|
||||
curseur via `caret-color: transparent`, contour visible) et *édition*
|
||||
(double-clic en place, première frappe qui **remplace**, `F2`,
|
||||
`Alt`+flèche pour un saut de ligne, `Échap` qui restaure)
|
||||
- [x] **A2** Sélection multiple : glisser, `Maj`+clic, `Maj`+flèches, avec
|
||||
contour de plage (`.xlsx-sel-*` sur les 4 bords réels) + en-têtes
|
||||
`A`/`B`… et `1`/`2`… reflétés
|
||||
- [x] **A3** `Entrée` contextuelle : en édition → valide et passe **à la
|
||||
cellule du dessous** ; en sélection → bascule en **mode édition**
|
||||
- [x] **A4** Inventaire des écarts vs Google Sheets / Excel et priorisation
|
||||
(P1 poignée de recopie / remplacer / validation de données / filtre
|
||||
auto / tri multi-critères ; P2 mise en forme conditionnelle / création
|
||||
de graphiques / TCD éditables / protection ; P3 analyse de données,
|
||||
historique, macros) — voir [fiche](./features/xlsx-sheet-input-181.md)
|
||||
- [x] Tests JSDOM (`xlsx-viewer` **146/146**, +10 cas #181), suites frontend
|
||||
vertes, build + déploiement de l'instance Docker locale
|
||||
|
||||
---
|
||||
|
||||
### 180. Éditeur tableur — redimensionnement au curseur, grille thémée, menu Fichier & barre épinglée
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md)
|
||||
- **Description :** retours utilisateur après #179 — (1) le redimensionnement des
|
||||
colonnes/lignes par les marges (`A, B, C…` / `1, 2, 3…`) doit afficher un
|
||||
curseur iconique (`col-resize` / `row-resize`) quand le pointeur est sur le
|
||||
bord ; (2) la grille doit suivre le thème de l'utilisateur (15 thèmes ×
|
||||
4 modes + importés : tokens dérivés, plus de fond figé) ; (3) regrouper
|
||||
Télécharger / CSV / Exporter dans un **menu Fichier** en haut à droite, au
|
||||
niveau des onglets de feuilles, avec **Enregistrer** juste à côté ;
|
||||
(4) retirer l'indicateur « Formules calculées localement » ; (5) épingler la
|
||||
barre de boutons en haut, au bord de la marge, et étendre l'éditeur à la
|
||||
largeur de la page.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Curseur de redimensionnement sur les marges d'en-têtes
|
||||
(en-tête de bord miroité en ligne + classe de repaint `.xlsx-resize-col/row`)
|
||||
- [x] **A2** Grille compatible tous thèmes (`--grid-*` dérivés dans
|
||||
`themes.js`, replis CSS, modes contraste élevé / sépia, menu Export sur `--surface`)
|
||||
- [x] **A3** Menu Fichier + Enregistrer au niveau des onglets (`.xlsx-sheetbar`,
|
||||
identifiants des contrôles conservés)
|
||||
- [x] **A4** Retrait de l'indicateur de formules + barre épinglée pleine largeur
|
||||
(`position: sticky`, `:has(.xlsx-viewer)` en plein cadre)
|
||||
- [x] Tests JSDOM (`xlsx-viewer` 136/136, `xlsx-menus` 12/12), E2E `xlsx-viewer.spec.js`,
|
||||
i18n FR/EN, 8 tests réalignés sur l'application locale des écritures
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Navigation
|
||||
|
||||
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
|
||||
@@ -220,12 +494,52 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Secrets masqués
|
||||
|
||||
### 188. Secrets masqués — couverture universelle des clés API & mots de passe + clic pour copier
|
||||
|
||||
- **Effort :** 1 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** — ouvert le 2026-10-08
|
||||
- **Fiche :** [features/secret-mask-188.md](./features/secret-mask-188.md)
|
||||
- **Description :** le redacteur (`secret_redactor.py`) ne couvrait que les
|
||||
JWT, clés `sk-`/`AKIA`/`ghp_`, connection strings et affectations
|
||||
`key=value` de 20+ caractères — la plupart des clés API réelles et **tous
|
||||
les mots de passe courts** passaient en clair dans l'aperçu markdown, et
|
||||
un masque affiché n'était pas récupérable sans ouvrir la source. #188
|
||||
couvre la majorité des formats de clés, masque les mots de passe quelle
|
||||
que soit leur longueur et rend chaque masque **cliquable** dans l'aperçu
|
||||
authentifié (copie dans le presse-papiers) sans jamais exposer la valeur
|
||||
à l'extérieur (partages, PDF, IA/MCP).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Détection universelle : table de formats fournisseur (OpenAI,
|
||||
Stripe, GitLab, GitHub, Google, AWS, Slack, SendGrid, Hugging Face,
|
||||
npm, Docker, Resend, Square, Atlassian, Discord, Telegram,
|
||||
`Bearer …`) + plancher des affectations génériques 20 → 8 caractères
|
||||
- [x] **A2** Mots de passe : `[MOT DE PASSE MASQUÉ]` toute longueur /
|
||||
quote (`password=`, `"passwd":`, `db_password=`, `mot de passe=`) ;
|
||||
`PWD=` shell épargné
|
||||
- [x] **A3** Clic → copie : placeholders markdown-safe (blocs de code
|
||||
compris) réécrits en `<span class="secret-mask" data-secret>`
|
||||
côté aperçu authentifié (listener délégué, toast + infobulle
|
||||
i18n FR/EN) ; libellé seul pour partages / PDF / IA / MCP ;
|
||||
code masqué non re-coloré (highlight.js perdrait le badge)
|
||||
- [x] **A4** Guide in-app FR/EN, `GUIDES/AUTHENTIFICATION_SECURITE.md`,
|
||||
README FR/EN, tests `TestSecretRedactor` (+33)
|
||||
|
||||
---
|
||||
|
||||
## ⚪ 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
|
||||
@@ -236,6 +550,9 @@
|
||||
- [ ] Conflits : UI de résolution manuelle (diff côte à côte entre version locale et distante)
|
||||
- [ ] Chiffrement : optionnel, chiffrement AES-256-GCM avant transmission
|
||||
- [ ] Pairing : échange de clé publique + code QR pour appairage des appareils
|
||||
- [ ] Périmètre v1.2 : sync des **favoris** (#161) et des **préférences** (thème, langue,
|
||||
historique, recherches sauvegardées) avec résolution de conflits (priorité à la dernière
|
||||
modification) + indicateur « préférences synchronisées »
|
||||
|
||||
---
|
||||
|
||||
@@ -259,6 +576,130 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Améliorations produit & évolutions agent IA (liste v1.2) — P1/P2
|
||||
|
||||
> **Ouvert le 2026-10-04** à partir de la liste « Roadmap ObsiGate – Développement des
|
||||
> Fonctionnalités » v1.2 (phases 1-3 + suggestions agent IA numérotées 161-180 dans la liste
|
||||
> source). Évaluation croisée avec le code réel (2026-10-04) : **7 propositions sont déjà
|
||||
> livrées** et n'ouvrent donc **pas** d'ID ; seul le périmètre manquant reçoit un ID ObsiGate
|
||||
> stable `#161` → `#178`. Les numéros de la liste source ne sont **pas** repris tels quels
|
||||
> (règle du dépôt : un ID n'est jamais attribué à du déjà-fait ni réutilisé).
|
||||
> Chaque item doit passer par [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md) (DoD) à son ouverture.
|
||||
|
||||
### Table de correspondance (liste v1.2 → ObsiGate)
|
||||
|
||||
| Proposition v1.2 | Verdict 2026-10-04 (vérifié dans le code) | ID ObsiGate |
|
||||
|---|---|---|
|
||||
| Ph.1 — Sauvegarde des recherches fréquentes | Sauvegarde **déjà livrée** (`backend/saved_searches.py`, store `data/{user}_saved_searches.json`, bouton « Sauver » #158) ; fréquence & rappels manquants | ⚪ #162 |
|
||||
| Ph.1 — Liens de répertoires aux favoris | Marque-page existant sur **fichiers** uniquement (dashboard) ; répertoires absents | ⚪ #161 |
|
||||
| Ph.1 — Recherche dans la page | **Déjà livrée** en vue lecture (`FindInPageManager`, Ctrl+F + navigation + regex, tableur via #153-A13) ; reste l'intégration **Forge** | ⚪ #163 |
|
||||
| Ph.2 — Auto-complétion intelligente | **Déjà livrée** (`frontend/js/autocomplete.js` : Mermaid, code fence, frontmatter, wikilinks, table — partagé Forge/CodeMirror) | ✅ |
|
||||
| Ph.2 — Suggestions contextuelles (historique & préférences) | Non livré | ⚪ #164 |
|
||||
| Ph.3 — Synchronisation favoris + préférences multiplateforme | Fusionné dans le périmètre de **#73** (reporté P4, décision 2026-09-26) | ⚪ #73 |
|
||||
| 161 — Synchronisation Git automatique des vaults | Non livré (les outils `git_*` #92 ciblent Gitea/GitHub pour l'IA, pas la sync de vault) | ⚪ #165 |
|
||||
| 162 — Génération de résumés automatiques | **Déjà livrée** (`POST /api/ai/summarize`, actions instantanées #106, skills `backend/skills.py`) | ✅ |
|
||||
| 163 — Détection & fusion de doublons | Non livré (brique embeddings/TF-IDF #70 disponible) | ⚪ #166 |
|
||||
| 164 — Traduction automatique | **Déjà livrée** (toolbar IA « Traduire », #106) | ✅ |
|
||||
| 165 — Analyse de ton/style | **Déjà livrée** (toolbar IA « Ton », #106) | ✅ |
|
||||
| 166 — APIs tierces (Google Drive, Notion) | Partiel : sources connectées **Gitea/GitHub** livrées (#103, `backend/tools/connected.py`) ; Drive/Notion manquants | ⚪ #167 |
|
||||
| 167 — Notifications externes (Slack, Discord, Email) | Partiel : webhooks #9 + Push API #67 livrés ; canaux Slack/Discord/SMTP et choix des déclencheurs à câbler | ⚪ #168 |
|
||||
| 168 — Édition collaborative temps réel | **Déjà livrée** (#62 — Yjs/CRDT, WebSocket, awareness, v2.3.0) | ✅ |
|
||||
| 169 — Scripts personnalisés sandboxés | **Déjà livrée** (#61 — plugins en sandbox Web Worker, hooks, 9 endpoints, v2.2.0) | ✅ |
|
||||
| 170 — Planification de tâches automatiques (cron) | Non livré | ⚪ #170 |
|
||||
| 171 — Audit des accès | Partiel : `backend/audit.py` + `/api/admin/audit` + dashboard admin #71 existent ; historique **par fichier**, filtres avancés et export CSV manquants | ⚪ #171 |
|
||||
| 172 — Chiffrement/déchiffrement de fichiers | Non livré (AES-GCM interne réservé aux tokens) | ⚪ #172 |
|
||||
| 173 — Vues personnalisées (Kanban, Calendrier) | Non livré | ⚪ #173 |
|
||||
| 174 — Optimisation mobile | Base **largement livrée** (#69 éditeur mobile, #83 ruban mobile, #114 config responsive 44 px) ; reste un audit du solde | ⚪ #178 |
|
||||
| 175 — Chat intégré | Non livré (transport WebSocket/rooms de #62 réutilisable) | ⚪ #169 |
|
||||
| 176 — Thèmes dynamiques (règles heure/date) | Base livrée (#65 thèmes + import/export) ; règles automatiques manquantes | ⚪ #174 |
|
||||
| 177 — Export formats propriétaires (OneNote, Evernote) | Base livrée (#66 HTML/MD bundle/ePub + PDF #74) ; formats tiers manquants | ⚪ #175 |
|
||||
| 178 — Webhooks pour événements | **Déjà livrée** (#9 — `backend/webhooks.py`, événements + secrets + validation d'URL SSRF-safe) | ✅ |
|
||||
| 179 — Gestion fine des permissions | Non livré (ACLs par vault/dossier racine seulement) | ⚪ #176 |
|
||||
| 180 — Historique des révisions visuel | Partiel : gestion des backups **avec diff** livrée (#40-46, outil IA `diff_backup`) ; UI côte à côte manquante | ⚪ #177 |
|
||||
|
||||
---
|
||||
|
||||
### 161. Favoris — répertoires et liens de vault (menu contextuel)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Description :** le marque-page actuel ne couvre que les fichiers ; ajouter les **répertoires**
|
||||
aux favoris (proposition « liens de répertoires », liste v1.2 Ph.1).
|
||||
- **Sous-tâches :**
|
||||
- [ ] Étendre le modèle de favoris aux répertoires (vérification d'accès backend via
|
||||
`_resolve_safe_path()`, permissions)
|
||||
- [ ] Entrée « Ajouter aux favoris » dans le menu contextuel de l'arbre **et** de la page de
|
||||
navigation (#158-A5)
|
||||
- [ ] Section « Favoris » dans la sidebar (répertoires + fichiers)
|
||||
- [ ] i18n FR/EN, tests (unit + E2E si UI), fiche `docs/features/`
|
||||
|
||||
### 162. Recherches fréquentes — fréquence d'utilisation & rappels
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Cadrage :** la **sauvegarde** des recherches existe déjà (`backend/saved_searches.py`, bouton
|
||||
« Sauver » de #158). Cet item n'ouvre que l'incrément manquant de la liste v1.2 Ph.1.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Compteur d'utilisation par recherche sauvegardée (horodatage + fréquence)
|
||||
- [ ] Section dédiée « Recherches fréquentes » (page de résultats + sidebar) et épinglage
|
||||
- [ ] Rappel optionnel (« Vous avez souvent cherché X ») via notifications #67, interruptible
|
||||
en Configuration
|
||||
- [ ] i18n FR/EN + tests
|
||||
|
||||
### 163. Recherche dans la page — éditeur Forge
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Cadrage :** `FindInPageManager` (Ctrl+F, surbrillance, navigation ↑/↓, casse / mot entier /
|
||||
regex) est livré pour la vue lecture ; le tableur a sa propre recherche (#153-A13). Reste à
|
||||
raccorder la recherche à l'éditeur **Forge** et à l'exposer en commande `/`.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Raccorder `FindInPageManager` à Forge (textarea **et** CodeMirror) : surbrillance dans le
|
||||
contenu édité, navigation avec boucle, conservation du curseur
|
||||
- [ ] Commande `/recherche-dans-page` (mécanisme de commandes #81)
|
||||
- [ ] Non-régression des raccourcis existants (Ctrl+F vs recherche globale, Échap) ; i18n ;
|
||||
tests JSDOM + E2E
|
||||
|
||||
### 164. Suggestions contextuelles dans l'éditeur (historique & préférences)
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟢 | **Ouvert :** 2026-10-04 | **Statut :** ⚪ non commencé
|
||||
- **Cadrage :** l'auto-complétion contextuelle (Mermaid, code, Markdown, wikilinks, frontmatter)
|
||||
est **déjà livrée** (`frontend/js/autocomplete.js`) — la proposition v1.2 Ph.2
|
||||
« Auto-complétion intelligente » est couverte. Cet item n'ouvre que les suggestions apprises
|
||||
du contexte utilisateur.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Mesure locale des snippets les plus utilisés par l'utilisateur (fréquence, sans cloud)
|
||||
- [ ] Suggestions basées sur l'historique de modification (termes récurrents, patterns du type
|
||||
« souvent inséré après … ») — option locale, IA légère si disponible
|
||||
- [ ] Bascule on/off en Configuration ; intégration à la complétion existante
|
||||
- [ ] i18n FR/EN + tests
|
||||
|
||||
---
|
||||
|
||||
### ⚪ Évolutions agent IA (liste v1.2 « Phase 4 », IDs ObsiGate #165 → #178)
|
||||
|
||||
| ID | Fonctionnalité | Effort | Impact | Dépendances / socle à étendre |
|
||||
|---|---|---|---|---|
|
||||
| 165 | Synchronisation automatique des vaults avec Git (add/commit/push/pull, résolution de conflits, auth SSH/token, option planification X heures) | 4-6 j | 🟡 | subprocess/`gitpython` ; UI dans Configuration (#160) ; distinct des outils `git_*` #92 (IA, Gitea/GitHub) |
|
||||
| 166 | Détection & fusion de doublons (score de similarité, liste des paires potentielles, outil de fusion) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md)) | 3-4 j | 🟢 | embeddings/TF-IDF #70 ; **jamais de fusion sans confirmation explicite** (+ backup préalable) |
|
||||
| 167 | Connexions tierces — Google Drive & Notion (import/export, OAuth2, respect des quotas) | 5-7 j | 🟡 | page « sources connectées » #103 ; rate-limit existant |
|
||||
| 168 | Notifications externes — canaux Slack/Discord (webhooks) et Email (SMTP) + choix des déclencheurs — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; périmètre : Discord, Telegram, SMTP, webhook générique — Slack via webhook générique) | 3-4 j | 🟢 | `backend/webhooks.py` #9 ; Push API #67 |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) | 3-4 j | 🟢 | transport WebSocket/rooms de la collab #62 |
|
||||
| 170 | Planification de tâches automatiques (type cron ; actions = outils existants `create_file`/`append_to_file` ; notifications d'échec) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; tick asyncio 60 s, API + outils, sans UI dédiée) | 3-4 j | 🟢 | scheduler backend (APScheduler ou asyncio) ; UI de création de tâches |
|
||||
| 171 | Audit des accès — extension : historique lecture/édition par fichier, filtres (utilisateur/fichier/date), export CSV | 2-3 j | 🟡 | `backend/audit.py` + `/api/admin/audit` (#71) |
|
||||
| 172 | Chiffrement/déchiffrement de fichiers sensibles (AES-256-GCM, gestion de clés, confirmation) | 3-4 j | 🟡 | **backup automatique préalable** ; clés hors dépôt (jamais de secret committé) |
|
||||
| 173 | Vues personnalisées Kanban & Calendrier (glisser-déposer, colonnes/date par tags & métadonnées) | 5-6 j | 🟢 | facettes/tags #157-#158 ; store JSON par utilisateur (pattern `data/*.json`) |
|
||||
| 174 | Thèmes dynamiques (règles heure/date, ex. mode sombre 18h-8h, règles personnalisables) | 2-3 j | 🟢 | thèmes #65 ; préférence persistée (candidate à la sync #73) |
|
||||
| 175 | Export vers formats propriétaires (OneNote, Evernote) avec conservation des métadonnées (tags, liens, images) | 3-4 j | 🟢 | `export.py` #66 (HTML/MD bundle/ePub) + PDF #74 |
|
||||
| 176 | Permissions fines par fichier/dossier (utilisateur/groupe + actions, héritage, journal des changements) | 4-5 j | 🟡 | ACLs vaults/dossiers existantes ; passerelle obligatoire par `_resolve_safe_path()` |
|
||||
| 177 | Historique des révisions visuel (diff côte à côte, surlignage ajouts/suppressions, navigation Précédent/Suivant) | 3-4 j | 🟢 | backups + diff #40-46 (`diff_backup`) ; `difflib` backend |
|
||||
| 178 | Optimisation mobile — solde (audit cibles tactiles ≥ 44 px, lazy-loading images/médias, raccourcis clavier virtuel) | 4-5 j | 🟡 | base livrée #69/#83/#114 ; lazy-loading déjà partiel (#153-A9) |
|
||||
|
||||
- **Critères d'acceptation transverses (liste v1.2 harmonisée avec la DoD du dépôt) :** test
|
||||
desktop **et** mobile ; documentation utilisateur FR/EN dans `docs/GUIDES/` (l'appel
|
||||
« Guide_MCP_ObsiGate.md » de la liste source est ramené à la cartographie documentaire du
|
||||
dépôt) ; **backup automatique avant toute modification destructive** ; i18n FR/EN systématique ;
|
||||
passage de l'ID à « en cours » avant codage (AGENTS.md, « Avant de commencer »).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Complété — index
|
||||
|
||||
> Détail complet dans [docs/archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) et
|
||||
@@ -271,6 +712,17 @@
|
||||
| 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) |
|
||||
| 179 | Tableur — icônes des menus & boutons, fermeture au focus, polish mobile | 2.53.0 | [features/xlsx-menus-179.md](./features/xlsx-menus-179.md) |
|
||||
| 180 | Tableur — redimensionnement au curseur, grille thémée, menu Fichier, barre épinglée | 2.54.0 | [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md) |
|
||||
| 181 | Tableur — modèle de saisie Google Sheets (sélection ≠ édition) & inventaire des écarts | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 182 | Tableur — désélection fiable, contours non empilés, couleurs sur une plage | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 183 | Tableur — panneau de couleurs façon Google Sheets (texte et fond) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 184 | Tableur — peinture de format complète, multi-lignes/colonnes, grille A → Z × 1000 (croissance par blocs) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 185 | Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie | 2.54.0 | [features/xlsx-menus-185.md](./features/xlsx-menus-185.md) |
|
||||
| 186 | Création d'un fichier Excel (`.xlsx`) depuis la modale « Créer un fichier » | 2.54.0 | [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) |
|
||||
| 188 | Secrets — détection universelle des clés API & mots de passe, masque cliquable (clic = copie) | Unreleased | [features/secret-mask-188.md](./features/secret-mask-188.md) |
|
||||
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
|
||||
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
|
||||
@@ -340,6 +792,9 @@
|
||||
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
|
||||
| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) + panneau repliable | 2.46.0→2.47.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 158 | Navigation — clic répertoire → **onglet de navigation** (sous-répertoires, facettes Vaults/Tags/Extensions, tri Pertinence/Date, Sauver), **onglet Accueil**, menus contextuels + retour Home complet (**BUG-100** → **BUG-102**) | 2.48.0→2.49.0 | [features/navigation-tab-158.md](./features/navigation-tab-158.md) |
|
||||
| 166 | Assistant IA — détection & fusion de doublons (score déterministe, paires candidates, fusion backup + confirmation) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||
| 168 | Assistant IA — notifications externes Discord / Telegram / SMTP / webhook (déclencheurs, secrets hors config, SSRF-safe) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||
| 170 | Assistant IA — tâches planifiées type cron (create/append/notify, interval/daily/once, tick 60 s, échec notifié) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -349,7 +804,9 @@
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–86, #88–93, #94–100, #102–115, #117, #92 | ~141 jours réalisés |
|
||||
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
|
||||
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
|
||||
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique ; périmètre élargi 2026-10-04 (favoris #161 + préférences) | 6-8 jours si réactivé |
|
||||
| ⚪ P1/P2 nouvelles (liste v1.2) | #161 favoris répertoires · #162 recherches fréquentes · #163 recherche Forge · #164 suggestions contextuelles | ~5-9 jours |
|
||||
| ⚪ Évolutions agent IA (#165 → #178) | Git sync, doublons, Drive/Notion, notifications, chat, cron, audit, chiffrement, Kanban, thèmes dynamiques, OneNote/Evernote, permissions, diff visuel, mobile | ~45-63 jours si tout activé (à prioriser par tranches) |
|
||||
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
|
||||
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
|
||||
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
|
||||
@@ -368,6 +825,14 @@
|
||||
- **Clôture 2026-09-30 :** #156 **livré (P0 → P3)** — **A8** mise en forme en écriture (bouton **Mise en forme** : gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — via `PUT …/xlsx/style`), **A9** décision « pas de moteur de formule, annoncée dans l'UI », **A10** undo/redo unifié conservé au re-rendu, **A11** export de la sélection / Markdown / HTML / impression + recherche sur toutes les feuilles, **A12** concurrence optimiste (`If-Match`, **409** réparable), **A13** cache des métadonnées par `mtime`, **A14** outils IA `.xlsm`/`.csv` + `search_workbook`/`analyze_range`/`edit_xlsx_structure` — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Ajout 2026-09-29 :** #156 **P1 livré** — **A5** presse-papiers de plage (copier/couper/coller un bloc, presse-papiers interne + système, entrées du menu contextuel, remplissage multi-cellules), **A6** clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) et **A7** zone Nom éditable + aide à la saisie ; restent P2 (mise en forme, calcul, undo unifié) et P3 (sortie, concurrence optimiste, performances, outils IA).
|
||||
- **Ajout 2026-09-29 :** #156 ouvert — audit de complétude de l'éditeur Excel : **4 défauts recensés** (BUG-096 enregistrement `.csv`, BUG-097 lazy-load `.xlsm`, BUG-098 délimiteur CSV, BUG-099 sonde de perte), **corrigés le jour même (P0 ✅)** avec tests de non-régression ; restent P1-P3 (presse-papiers, clavier, mise en forme, calcul, export, concurrence optimiste) puis presse-papiers de plage, clavier complet, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Ajout 2026-10-04 :** intégration de la liste « Roadmap – Développement des Fonctionnalités »
|
||||
v1.2 (phases 1-3 + suggestions agent IA). Évaluation croisée avec le code : **7 propositions déjà
|
||||
livrées** (auto-complétion `autocomplete.js`, résumés/traduction/ton #106, édition collaborative
|
||||
#62, scripts/plugins #61, webhooks #9, recherche dans la page en vue lecture) → pas d'ID ;
|
||||
**périmètre manquant** ouvert en backlog **#161 → #178** avec **table de correspondance** en tête
|
||||
de la nouvelle section ; sync favoris/préférences **fusionnée dans #73** (reporté P4). Les numéros
|
||||
161-180 de la liste source ne sont pas repris tels quels (règle dépôt : ID jamais attribué au
|
||||
déjà-fait) ; la correspondance figure item par item.
|
||||
- **Clôture #85 (v2.27.13) :** monolithe découpé (T1→T9), stores verrouillés + rate-limit SQLite (T10), fiche `docs/features/archi-refonte-85.md`.
|
||||
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
|
||||
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
|
||||
|
||||
@@ -0,0 +1,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 |
@@ -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).
|
||||
@@ -0,0 +1,96 @@
|
||||
# #186 — Création d'un fichier Excel depuis « Créer un fichier »
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-05 | **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [Guide Recherche, PDF, Excel & Excalidraw](../GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) ·
|
||||
> [#153 — Visionneuse XLSX](./xlsx-viewer.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
ObsiGate sait **lire et éditer** les classeurs depuis #152/#153, et l'assistant IA sait
|
||||
en **produire** un (`create_xlsx`, #92). Mais la modale « Créer un fichier » — celle
|
||||
du menu contextuel d'un vault / d'un dossier et de la palette de commandes —
|
||||
ne proposait que des formats **texte** :
|
||||
|
||||
`.md`, `.txt`, `.py`, `.js`, `.json`, `.yaml`, `.sh`, `.ps1` (+ Excalidraw).
|
||||
|
||||
Démarrer un classeur depuis l'UI imposait donc de passer par l'IA (avec
|
||||
confirmation) ou de déposer un fichier existant dans le vault. Le point de départ
|
||||
naturel manquait alors que l'éditeur complet était disponible.
|
||||
|
||||
## 2. Livré (A1-A3)
|
||||
|
||||
### A1 — Option Excel dans la liste des types
|
||||
|
||||
`frontend/js/ui.js`, `FileOperations.showCreateFileModal()` : ajout de
|
||||
`<option value=".xlsx">` dans le `<select id="file-ext-select">`, après `.json`
|
||||
(ordre : formats texte, puis formats enrichis, comme `.excalidraw`).
|
||||
|
||||
Au passage, les **dix libellés** du sélecteur passent en i18n
|
||||
(`modal.ext_md`, `modal.ext_txt`, …, `modal.ext_xlsx`) : ils étaient des chaînes
|
||||
françaises codées en dur, ce qui exposait un texte non traduit en mode EN et
|
||||
violait la règle « tout texte d'interface existe en FR et en EN ». Les deux
|
||||
locales sont alignées (`tests/test_i18n_parity.py`).
|
||||
|
||||
Aucun changement pour les autres formats : le nom saisi sans extension reçoit
|
||||
celle choisie (`name.endsWith(ext) ? name : name + ext`), donc un nom déjà
|
||||
suffiché n'est jamais doublé.
|
||||
|
||||
### A2 — Un vrai classeur côté backend
|
||||
|
||||
Le point non évident : `POST /api/file/{vault}` **écrit du texte**
|
||||
(`file_path.write_text(content)`), et la modale poste `content: ""`. Un `.xlsx`
|
||||
n'est pas un fichier texte mais une **archive OPC** (un ZIP) : écrire la charge
|
||||
utile vide produisait un fichier de 0 octet que ni `openpyxl`, ni la visionneuse,
|
||||
ni Excel n'ouvrent — l'utilisateur aurait vu une erreur à l'ouverture, ou pire,
|
||||
un fichier corrompu.
|
||||
|
||||
`backend/services/mutations.py` :
|
||||
|
||||
- `_empty_xlsx_bytes()` construit l'archive avec openpyxl (une feuille `Feuille1`,
|
||||
le titre par défaut déjà utilisé par l'outil IA `create_xlsx`) — import paresseux,
|
||||
comme le reste du module qui garde openpyxl optionnel à l'import ;
|
||||
- `create_file()` écrit ces octets (`write_bytes`) pour cette extension et ignore
|
||||
`content`, qui n'a pas de sens pour un classeur ; `size` renvoie le nombre
|
||||
d'octets réellement écrits (et non `len(content)`, qui aurait valu 0) ;
|
||||
- même garde-fou que `.excalidraw` : le squelette est injecté par le **service**,
|
||||
donc la palette, le glisser-déposer, l'IA et l'API en bénéficient, pas seulement
|
||||
la modale.
|
||||
|
||||
`openpyxl` reste importé à la demande : un vault sans tableur ne paie pas l'import.
|
||||
|
||||
### A3 — Tests
|
||||
|
||||
- `tests/test_api_main.py::TestFileCRUD` — `test_create_xlsx_empty_workbook` : le
|
||||
fichier créé est relu par `openpyxl` (`sheetnames == ["Feuille1"]`) **et** servi
|
||||
par `GET /api/file/{vault}` avec `is_xlsx: true` (donc lisible par la visionneuse) ;
|
||||
`test_create_xlsx_in_new_subdir` couvre la création dans un dossier inexistant
|
||||
(auto-créé). Non-régression : sans A2, l'archive serait vide et les deux tests
|
||||
échoueraient.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` — bloc « création depuis l'UI » : l'option
|
||||
`.xlsx` existe et son libellé vient des locales, la création passe par le menu
|
||||
contextuel d'un dossier, et le classeur s'ouvre dans la grille (`.xlsx-table` +
|
||||
onglet `Feuille1`) ; l'API est interrogée pour confirmer `is_xlsx`. Le fichier est
|
||||
supprimé en fin de test (le run ne doit pas salir `test_vault/`).
|
||||
|
||||
## 3. Limites assumées
|
||||
|
||||
- **Le nom de feuille est `Feuille1`**, en dur. Un vrai *nouveau classeur* de
|
||||
Google Sheets le demande ; ici le classeur s'ouvre sur sa seule feuille et
|
||||
l'utilisateur la renomme via le menu Structure. La modale reste celle d'un
|
||||
éditeur de texte, volontairement simple.
|
||||
- **`.csv` reste absent de la liste**, alors que le backend l'accepte
|
||||
(`SUPPORTED_EXTENSIONS`) et que la visionneuse sait l'éditer comme tableur
|
||||
(#156-A14). L'écart est réel mais hors périmètre de #186 ; le même correctif
|
||||
s'appliquerait (une ligne dans le `<select>`).
|
||||
- **`frontend/app.js` (monolithe obsolète, non chargé par `index.html`)**
|
||||
duplique l'ancienne liste sans `.xlsx`. Le code vivant est dans `frontend/js/`;
|
||||
ce fichier n'a pas été touché.
|
||||
- L'outil IA `create_xlsx` reste le chemin pour un classeur **rempli** : la modale
|
||||
crée une grille vierge, comme un `.md` vierge.
|
||||
|
||||
## 4. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-10-05 | #186 ouvert puis livré dans la foulée : option `.xlsx` + squelette openpyxl dans `create_file()` |
|
||||
@@ -86,3 +86,40 @@
|
||||
- Python 3.11 embed — téléchargé depuis python.org
|
||||
- NSIS (Windows) — pour le générateur d'installateur `.exe`
|
||||
- AppImageKit (Linux) — pour le packaging portable
|
||||
|
||||
## G. Gestion des vaults & dossiers — #159 — ✅ livré (2026-10-02, v2.50.0)
|
||||
|
||||
- [x] Retrait vault/dossier racine par menu contextuel (`ContextMenuManager`,
|
||||
branche `vault`, gate `isTauriEnv()`) → `removeRoot()` dans `desktop.js` :
|
||||
résolution vault/dossier via `list_vaults`/`list_dirs`, confirmation i18n,
|
||||
`remove_vault`/`remove_dir` + `restart_backend` + reload — déregistration
|
||||
seule, zéro suppression disque.
|
||||
- [x] Section Configuration `cfg-desktop-roots` (+ entrée TOC i18n FR/EN) :
|
||||
liste des roots injectés, retrait par ligne, ajout vault
|
||||
(`pickAndAddVault`, existant) et ajout dossier (nouvelle commande Rust
|
||||
`pick_folder` — sélecteur sans effet de bord, contrairement à
|
||||
`pick_vault_folder` réservé au wizard). Section masquée hors desktop.
|
||||
- [x] Jump list rafraîchie après chaque ajout/retrait de vault
|
||||
(`refresh_jumplist()`).
|
||||
- [x] i18n FR/EN (7 clés `config.*`/`desktop.*`), garde-fou ACL automatique
|
||||
(`test_frontend_invokes_are_acl_allowed` : toute commande invoquée par le
|
||||
frontend doit figurer dans `permissions/commands.toml`).
|
||||
- [x] Tests : `tests/frontend/desktop-roots.test.mjs` (4 — helpers purs +
|
||||
gating hors desktop), inscrit au CI ; suites frontend/JSDOM vertes,
|
||||
`cargo test` 25 passed.
|
||||
|
||||
## H. Premier lancement & section Configuration harmonisée — #160 — ✅ livré (2026-10-02, v2.51.0)
|
||||
|
||||
- [x] `default_first_run_config(home)` (pure, testée) : `<home>/ObsiGate`
|
||||
monté comme vault « ObsiGate » ET comme `vault_path`, racine home nommée
|
||||
d'après son dernier segment — remplace `voute_obsidian` + le « bruno »
|
||||
codé en dur ; les champs fenêtre de la config existante sont préservés.
|
||||
- [x] `Prise en main.md` : contenu embarqué (`include_str!("prise_en_main.md")`),
|
||||
écrit au premier lancement **si absent** (jamais d'écrasement).
|
||||
- [x] Section `cfg-desktop-roots` refondue : markup à classes (zéro style
|
||||
inline), bloc CSS `#160` sur variables (`desktop-roots-*`, motif
|
||||
`webauthn-key-item`), boutons `.config-btn-sm` (primaire/secondaire),
|
||||
override mobile 44px dans le bloc `#config-modal`.
|
||||
- [x] Tests : `test_default_first_run_config`, `test_welcome_doc_embedded`
|
||||
— `cargo test` 28 passed ; suites frontend vertes (validate-imports,
|
||||
unit, config-mobile, desktop-roots, settings-order) + E2E locale.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Fiche — #188 · Secrets masqués : couverture universelle + clic pour copier
|
||||
|
||||
- **Statut** : ✅ livré le 2026-10-08 (ouvert le 2026-10-08)
|
||||
- **Effort** : ~1 jour · **Impact** : 🟡
|
||||
- **Zones** : `backend/secret_redactor.py`, `backend/render.py`,
|
||||
`backend/routers/files_read.py`, `frontend/js/viewer.js`,
|
||||
`frontend/style.css`, `frontend/locales/{fr,en}.json`
|
||||
|
||||
## Problème
|
||||
|
||||
Le redacteur (`secret_redactor.py`) ne masquait que les JWT, les clés
|
||||
`sk-`/`pk-`/`rk-`, `AKIA…`, `ghp_…`, les connection strings et les
|
||||
affectations `api_key=`/`token=`/`secret=` de **20 caractères ou plus**.
|
||||
Résultat : la majorité des clés API réelles (Google, Slack, Stripe, GitLab,
|
||||
Hugging Face, npm…) et **tous les mots de passe courts** passaient en clair
|
||||
dans l'aperçu markdown, et un masque affiché (`[CLÉ API MASQUÉE]`) n'était
|
||||
pas récupérable : il fallait ouvrir la source pour relire la valeur.
|
||||
|
||||
## Solution
|
||||
|
||||
1. **Détection universelle** — une alternation unique de formats
|
||||
fournisseur (OpenAI/Anthropic `sk-`, Stripe `sk_live_`/`whsec_`, GitLab
|
||||
`glpat-`, GitHub `ghp_`/`github_pat_`, Google `AIza…`/`ya29.`, AWS
|
||||
`AKIA`/`ASIA`, Slack `xoxb-`, SendGrid `SG.`, Hugging Face `hf_`, npm,
|
||||
Docker, Resend, Square, Atlassian, Discord, Telegram, `Bearer …`) en
|
||||
plus des motifs déjà présents ; le plancher des affectations génériques
|
||||
passe de 20 à 8 caractères.
|
||||
2. **Mots de passe** — motif dédié (`[MOT DE PASSE MASQUÉ]`) valable toute
|
||||
longueur et toute quote (`password=`, `"passwd": "…"`, `db_password=`,
|
||||
`passphrase=`, `mot de passe=`), qualificateur inclus. `PWD=` (dossier de
|
||||
travail shell) reste épargné : `pwd` n'est reconnu que qualifié.
|
||||
3. **Clic → copie** — le pipeline de rendu passe par des
|
||||
**placeholders** (`\uE000n\uE001`) qui survivent à la conversion markdown
|
||||
(un `<span>` littéral dans un bloc de code serait affiché tel quel), puis
|
||||
`restore_masks()` les réécrit après le rendu :
|
||||
- **aperçu authentifié** (`GET /api/file/…`, `click_to_copy=True`) :
|
||||
`<span class="secret-mask" data-secret="valeur">[CLÉ API MASQUÉE]</span>` —
|
||||
la valeur n'est jamais affichée, un clic la copie (listener délégué
|
||||
unique sur la zone de contenu, toast i18n FR/EN, infobulle) ;
|
||||
- **partages publics, exports PDF, contexte IA / MCP** : libellé seul,
|
||||
la valeur ne quitte pas le serveur.
|
||||
Les blocs `<pre><code>` portant un masque ne sont pas re-colorés
|
||||
(highlight.js reconstruirait le markup et perdrait le badge).
|
||||
|
||||
## Hors périmètre (volontaire)
|
||||
|
||||
- La vue « source » (fichier brut) et les aperçus de fichiers
|
||||
**non-markdown** ne sont pas masqués : c'est le fichier lui-même qui est
|
||||
affiché, le redacteur ne s'y appliquait jamais.
|
||||
- Le serveur MCP / l'assistant IA continuent de recevoir les libellés seuls
|
||||
(inchangé).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_api_main.py::TestSecretRedactor` : formats fournisseurs
|
||||
(paramétrés), mots de passe (paramétrés), `PWD=` non masqué, round-trip
|
||||
placeholder → span cliquable, rendu markdown cliquable vs libellé seul.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,119 @@
|
||||
# #185 — Tableur : barre de menus Google Sheets, boutons façon Sheets, grille unie
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#179](./xlsx-menus-179.md) ·
|
||||
> [#180](./xlsx-sheetbar-180.md) · [#183](./xlsx-sheet-input-181.md))
|
||||
|
||||
## 1. Constat
|
||||
|
||||
L'éditeur tableur avait un ruban de boutons et trois menus (Structure, Mise en
|
||||
forme, Export) mais **aucune barre de menus** : ni Fichier, ni Modifier, ni
|
||||
Affichage, ni Insertion, ni Format, ni Données, ni Outils, ni Gemini, ni
|
||||
Extensions, ni Aide. Beaucoup de fonctions existentes n'étaient atteignables que
|
||||
par un clic droit ou par un bouton à connaître. Le ruban lui-même gardait un
|
||||
chromer bordersé qui ne ressemblait à rien, et la grille n'était bordée qu'en
|
||||
bas avec une couleur (`--border`) quasi identique au fond des cellules : le
|
||||
quadillage se lisait comme une tache, pas comme une grille.
|
||||
|
||||
## 2. Livré
|
||||
|
||||
### A1 — Barre de menus (les 10 menus de Google Sheets)
|
||||
|
||||
`frontend/js/xlsx/menus.js` (nouveau) décrit **les dix menus, dans l'ordre et avec
|
||||
les libellés exacts de Google Sheets** (Fichier · Modifier · Affichage ·
|
||||
Insertion · Format · Données · Outils · Gemini · Extensions · Aide), puis
|
||||
`buildMenuBar()` produit la barre et ses dix menus déroulants. 161 entrées, dont
|
||||
**125 wirables** (67 actions distinctes) et **36 honestement déclarées
|
||||
indisponibles** (elles s'affichent mais répondent « *« X » n'est pas disponible
|
||||
dans ObsiGate* »** — jamais un clic muet). Les sections imbriquées (Nombre,
|
||||
Thème, Alignement, Zoom, Nettoyage…) sont des **accordéons** comme dans Sheets :
|
||||
une seule ouverte à la fois par niveau.
|
||||
|
||||
Le menu **Fichier** a quitté la barre d'onglets pour la barre de menus, en
|
||||
conservant ses identifiants (`#xlsx-file-btn`, `#xlsx-file-menu`,
|
||||
`#xlsx-download-btn`, `#xlsx-csv-btn`, `[data-xlsx-export]`). Seul
|
||||
**Enregistrer** reste épinglé à droite de la barre d'onglets.
|
||||
|
||||
### A2 — Fonctions réellement câblées
|
||||
|
||||
- **Fichier** : Partager, Télécharger (original · CSV · Markdown · HTML),
|
||||
Imprimer, **Renommer** (`PATCH /api/file/{vault}`), **Corbeille**
|
||||
(`DELETE /api/file/{vault}`), **Historique des versions** (visionneuse de
|
||||
sauvegardes), Détails (vault, chemin, feuilles, cellules, révision, ·
|
||||
modifications en attente via `/api/health` pour le numéro de version).
|
||||
- **Modifier** : Annuler, Rétablir, Couper, Copier, Coller, **Collage spécial →
|
||||
valeurs uniquement** (les formules sont évaluées puis collées comme texte),
|
||||
**Supprimer → lignes / colonnes / contenu**, Tout sélectionner,
|
||||
**Rechercher et remplacer** (toutes les feuilles).
|
||||
- **Affichage** : Figer (ici / ligne / colonne / libérer), **Afficher → barre de
|
||||
formule · grille · en-têtes · tableau de bord**, Grouper (déclaré), Feuilles
|
||||
masquées (déclaré), **Zoom** (50 → 200 %, même état que le ruban), **Plein
|
||||
écran**.
|
||||
- **Insertion** : Lignes (au-dessus / en dessous), Colonnes (à gauche / à
|
||||
droite), Cellules, Lien, Commentaire, **Fonction** (insère l'appel et place le
|
||||
curseur entre les parenthèses).
|
||||
- **Format** : Nombre (6 formats), **Thème** (liste des 15 thèmes construite à la
|
||||
demande), Gras / Italique / Souligné / Barré, Taille de police, Alignement
|
||||
(horizontal + vertical), Retour à la ligne, Rotation, **Couleurs alternées**
|
||||
(une écriture par ligne, lots de 50 — plafond du backend), Effacer la mise en
|
||||
forme.
|
||||
- **Données** : Trier la plage (croissant / décroissant / réinitialiser),
|
||||
**Créer un filtre** (sur la valeur de la cellule active), **Plages nommées**
|
||||
(panneau du tableau de bord), **Nettoyage → supprimer les doublons ·trimmer**,
|
||||
**Diviser le texte en colonnes**.
|
||||
- **Outils** : **Saisie semi-automatique** (bascule la liste de fonctions de la
|
||||
barre de formule), **Accessibilité** (cellules plus grandes, grille plus
|
||||
contrastée).
|
||||
- **Gemini** : les six entrées ouvrent le panneau de l'assistant IA.
|
||||
- **Aide** : aide intégrée, **Mises à jour** (version), **Raccourcis clavier**
|
||||
(liste), **Signaler un problème** (diagnostic copié dans le presse-papiers).
|
||||
|
||||
### A3 — Ruban façon Google Sheets
|
||||
|
||||
Boutons **plats** : 30 × 30, sans bordure ni fond au repos, survol discret, état
|
||||
actif teinté, groupes séparés par des filets verticaux. Les sélecteurs (zoom,
|
||||
format de nombre, taille de police) suivent le même traitement. **Enregistrer**
|
||||
reste le seul pilier plein. Nouveau bouton **grille** (`#xs-grid`), piloté par le
|
||||
menu Affichage et inversement.
|
||||
|
||||
### A4 — Grille unie et visible
|
||||
|
||||
- Chaque cellule est encadrée sur **les quatre côtés** par **la même** ligne
|
||||
1 px (les en-têtes compris : ils se distinguent par leur fond, plus par une
|
||||
bordure plus lourde).
|
||||
- `--grid-border` n'est plus `--border` (1-2 % d'écart avec le fond sur la plupart
|
||||
des thèmes) mais un `color-mix` **du texte vers la surface** calculé par
|
||||
`themes.js` : une seule valeur pour toute la grille, lisible en sombre comme
|
||||
en clair et dans les modes générés (contraste élevé, sépia).
|
||||
- Les valeurs de repli des 4 blocs `:root` ont été remontées en contraste.
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-menus.test.mjs` : i18n amorcé, barre de menus (10 libellés,
|
||||
un déroulant par bouton, accordéon), ** exhaustivité des actions déclarées**,
|
||||
disparition des entrées d'édition en lecture seule, absence de HTML dans les
|
||||
libellés, ids historiques du menu Fichier ; ruban (save, « + », grille).
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` : barre présente au-dessus du ruban,
|
||||
entrée indisponible qui répond (et n'envoie rien), bascules Affichage, Zoom,
|
||||
écriture de structure (Insertion), écriture de style (Format ▸ Nombre),
|
||||
**Couleurs alternées** (3 lignes → 3 ops), Accessibilité, Raccourcis clavier,
|
||||
classeur en lecture seule, jeton `--grid-border`.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` : les 10 menus dans le bon ordre et **au-dessus**
|
||||
du ruban, accordéon Format ▸ Nombre, option indisponible, **grille 1 px sur
|
||||
les 4 côtés** puis masquée à 0 px.
|
||||
|
||||
## 4. Limites assumées
|
||||
|
||||
- Les 36 options Google Sheets qu'ObsiGate n'implémente pas (macros, Apps
|
||||
Script, TCD, graphiques, formulaires,/modules, AppSheet, Looker Studio,
|
||||
training…) sont **listées pour rester fidèle** à la demande, et **annoncées**
|
||||
comme indisponibles au clic. Elles ne sont pas masquées : la parité visuelle
|
||||
prime, l'honnêteté prime sur le silence.
|
||||
- **Déplacer** lignes/colonnes est déclaré indisponible : leivinguer
|
||||
(supprimer + insérer + réécrire) sur un classeur avec fusions, styles et
|
||||
formules n'est pas sûr en un seul geste.
|
||||
- Le menu **Thème** est construit à la demande (import dynamique de
|
||||
`themes.js`) : 15 entrées, pas de coût au rendu.
|
||||
- Les couleurs de la grille sont dérivées via `color-mix` : un moteur qui ne le
|
||||
supporte pas retombe sur `--grid-border` (feuille de style).
|
||||
@@ -0,0 +1,356 @@
|
||||
# #181 — Éditeur tableur : modèle de saisie Google Sheets & inventaire des écarts
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) ·
|
||||
> [#179](./xlsx-menus-179.md) · [#180](./xlsx-sheetbar-180.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
L'éditeur ne distinguait pas **sélectionner** une cellule et **l'éditer** :
|
||||
|
||||
- le curseur de texte clignotait en permanence dans la cellule focalisée
|
||||
(tout est `contenteditable`, `caret-color` par défaut) ;
|
||||
- `Entrée` validait puis faisait `blur()` — le focus **quittait la grille** ;
|
||||
- l'indicateur de plage était un simple lavis, sans contour, et les en-têtes
|
||||
de la plage sélectionnée n'étaient pas reflétés ;
|
||||
- la première frappe **ajoutait** au contenu existant au lieu de le remplacer.
|
||||
|
||||
Un utilisateur habitué à Google Sheets ou Excel perd le repère : il ne sait
|
||||
plus s'il est en train de naviguer ou de saisir.
|
||||
|
||||
## 2. Livré (A1-A3)
|
||||
|
||||
### A1 — Deux états distincts : sélection et édition
|
||||
|
||||
Toutes les cellules portent la classe `xlsx-cell` et démarrent en
|
||||
**sélection** (`data-editing=""`) : contour visible, **aucun curseur**. Le
|
||||
curseur est masqué via `caret-color: transparent` — donc sans laisser
|
||||
`contenteditable`, ce qui préserve le collage, le clic et la frappe native.
|
||||
|
||||
L'**édition** s'atteint par trois voies, comme dans Google Sheets :
|
||||
|
||||
| Geste | Effet |
|
||||
|---|---|
|
||||
| Clic / flèches | sélectionne (pas de curseur) |
|
||||
| **Double-clic** | édite **sur place** — `caretRangeFromPoint` place le curseur sous le pointeur |
|
||||
| **Première frappe** | édite et **remplace** le contenu (jamais d'ajout) |
|
||||
| `F2` | édite, curseur en fin de texte |
|
||||
| `Alt`+flèche | saut de ligne dans la cellule |
|
||||
| `Échap` | quitte l'édition et **restaure** la valeur |
|
||||
|
||||
`enterEditing()` / `exitEditing()` centralisent la bascule. Un garde
|
||||
`editingRequested` empêche le handler `focus` de la cellule d'annuler l'état
|
||||
d'édition que `enterEditing()` vient d'installer (il se déclenche au moment du
|
||||
`focus`).
|
||||
|
||||
### A2 — Sélection multiple et indicateur visuel
|
||||
|
||||
- `Maj`+flèches étend depuis l'ancre, glisser avec la souris aussi, plus
|
||||
`Maj`+clic — le mécanisme existait déjà, il est désormais complet et lisible.
|
||||
- Les cellules portent `.xlsx-selected` **et** quatre classes de bord
|
||||
(`.xlsx-sel-top/bottom/left/right`) : le bloc est **contouré** exactement
|
||||
comme dans Excel/Sheets, au lieu d'un lavis uniforme. Chaque cellule ne
|
||||
reçoit que les bords qu'elle porte réellement (les coins n'héritent pas d'un
|
||||
bord intérieur).
|
||||
- Les en-têtes couverts (lettres `A`, `B`… et numéros `1`, `2`…) reçoivent
|
||||
`.xlsx-hdr-selected` : la plage se lit aussi dans les marges.
|
||||
|
||||
### A3 — `Entrée` contextuelle
|
||||
|
||||
| État | `Entrée` |
|
||||
|---|---|
|
||||
| **édition** | valide, puis le focus passe **à la cellule du dessous** |
|
||||
| **sélection** | bascule la cellule courante en **mode édition** |
|
||||
|
||||
`Tab` suit la même logique (validation + déplacement latéral en édition,
|
||||
déplacement simple en sélection), et un clic simple après une édition abandonne
|
||||
l'état d'édition sans laisser de curseur.
|
||||
|
||||
## 3. A4 — Inventaire des écarts vs Google Sheets / Excel
|
||||
|
||||
**Rapprochement exhaustif impossible en une tranche.** L'éditeur couvre déjà
|
||||
l'essentiel ; l'inventaire ci-dessous est l'état réel vérifié dans le code au
|
||||
2026-10-05.
|
||||
|
||||
### 3.1 Déjà couvert
|
||||
|
||||
Édition de cellules · types et formats · recherche **et** remplacement ·
|
||||
trier / filtrer · gel des volets · fusions · annuler / rétablir · presse-papiers
|
||||
de plage · insertion / suppression de lignes, colonnes et feuilles ·
|
||||
graphiques KPI du tableau de bord · commentaires · liens hypertexte (formule
|
||||
`=LIEN`) · export (CSV, Markdown, HTML, impression) · impression ·
|
||||
collaboration temps réel · import de formats · lecture seule `.xls` / `.ods`.
|
||||
|
||||
### 3.2 Manquants, par priorité
|
||||
|
||||
**P1 — usage quotidien**
|
||||
|
||||
- **Poignée de recopie** (fill handle) : le petit carré en bas à droite de la
|
||||
plage, glisser pour remplir séries et formules. Très attendu, absent.
|
||||
- **Remplacer** (`Ctrl+H`) : seule la *recherche* existe.
|
||||
- **Validation de données** : listes déroulantes, bornes numériques.
|
||||
- **Filtre automatique** : l filtrage texte existe, pas les entêtes de filtre
|
||||
par colonne.
|
||||
- **Tri multi-critères** et options de tri (couleur, taille, valeurs
|
||||
personnalisées).
|
||||
- **Déplacer lignes / colonnes** par glisser-déposer (l'insertion existe).
|
||||
|
||||
**P2 — présentation et analyse**
|
||||
|
||||
- Mise en forme conditionnelle (couleurs, barres de données, jeu d'icônes).
|
||||
- **Graphiques** dans le tableur : KPI et relevé de graphiques existants sont
|
||||
lus, mais on ne **crée pas** de graphique.
|
||||
- Tableaux croisés : lus et affichés, non modifiables.
|
||||
- Groupement / dégroupement de lignes et colonnes.
|
||||
- Images et formes dans les cellules (ObsiGate a Excalidraw séparé).
|
||||
- Protection d'une feuille ou de cellules (lecture seule).
|
||||
- Masquer des lignes / colonnes.
|
||||
|
||||
**P3 — avancé**
|
||||
|
||||
- Analyse de données : supprimer les doublons, convertir du texte en colonnes,
|
||||
supprimer les espaces superflus.
|
||||
- Fusion des cellules à l'identique, annotations, suivi des modifications,
|
||||
historique des révisions.
|
||||
- Macros (les `.xlsm` sont lus et réécrits avec `keep_vba`).
|
||||
- Simulation de scénarios, recherche de cible (goal seek).
|
||||
- Options de tri avancées (insensible à la casse, séparateur décimal
|
||||
personnalisé).
|
||||
- Peintures de cellules, texte en art.
|
||||
|
||||
## 4. Tests
|
||||
|
||||
`tests/frontend/xlsx-viewer.test.mjs` — **146/146** (10 nouveaux cas #181) :
|
||||
sélection sans curseur au clic et aux flèches · double-clic qui édite en place ·
|
||||
première frappe qui **remplace** · deuxième frappe qui n'efface pas ·
|
||||
`Entrée` qui passe en édition · `Entrée` qui valide et descend sous la cellule ·
|
||||
extension `Maj`+flèches avec les quatre bords du contour et les en-têtes
|
||||
reflétés · glisser qui peint le même contour · clic simple qui fait tomber
|
||||
l'état d'édition · `Échap` qui restaure la valeur.
|
||||
|
||||
Suites frontend également vertes : `xlsx-menus` 12/12, `unit` 13, `editor-inline`
|
||||
44, `toolbar-order`, `mobile-editor` 35, `ai` 100, `pane-manager` 9,
|
||||
`validate-imports` 41 modules / 355 exports.
|
||||
|
||||
## 5. Point d'attention
|
||||
|
||||
Le `caret-color: transparent` repose sur la prise en charge navigateur
|
||||
(`caret-color` est largement disponible). Sur un très ancien moteur, le curseur
|
||||
resterait visible en sélection — l'état reste correct, seul l'indicateur
|
||||
disparaît.
|
||||
|
||||
## 6. #182 — Désélection & couleurs sur une plage
|
||||
|
||||
### 6.1 La sélection « collée »
|
||||
|
||||
**Symptôme** : après un `Maj`+clic ou un glissement, la plage restait affichée et
|
||||
un clic simple ne la supprimait pas.
|
||||
|
||||
**Cause racine** — la désélection reposait entièrement sur l'événement `focus` de
|
||||
la cellule, qui appelle `setActiveCell()` et donc réduit la plage à la cellule
|
||||
focalisée. Or le handler `Maj`+clic appelle `e.preventDefault()`, ce qui
|
||||
**supprime le déplacement de focus natif du navigateur**. Résultat : après un
|
||||
`Maj`+clic, `activeTd` valait la cellule cliquée mais `document.activeElement`
|
||||
restait sur l'ancienne. Le clic suivant **sur la cellule déjà focalisée** ne
|
||||
déclenchait donc aucun `focus` : la plage ne pouvait plus s'effacer.
|
||||
|
||||
**Correctif**
|
||||
|
||||
- le clic simple appelle `setActiveCell(td)` dans `mousedown` : la plage est
|
||||
effacée de façon déterministe, sans dépendre du focus ;
|
||||
- le `Maj`+clic déplace réellement le focus, sous `suppressSelectionReset` pour
|
||||
que le handler `focus` ne rétrécisse pas la plage qui vient d'être étendue ;
|
||||
- le glissement ne vole **pas** le focus (comme dans un navigateur) — c'est
|
||||
précisément ce vol qui aurait fait disparaître la plage tracée ;
|
||||
- `endDrag` restaure la zone Nom sur la **plage** (`A1:B2`) au lieu de la
|
||||
cellule d'origine.
|
||||
|
||||
Le même correctif a guéri le symptôme connexe « la mini-barre de sélection ne
|
||||
se cachait plus après un glissement ».
|
||||
|
||||
### 6.2 Couleurs de texte et de fond
|
||||
|
||||
Les trois chemins ont été **vérifiés** (charge utile `PUT …/xlsx/style` puis
|
||||
peinture locale) sur une plage `A1:B2` obtenue par `Maj`+flèches **et** par
|
||||
glissement :
|
||||
|
||||
| Chemin | Charge utile | Résultat |
|
||||
|---|---|---|
|
||||
| Menu **Mise en forme** → couleur | `range: "A1:B2"` | 4 cellules peintes |
|
||||
| Nuancier du ruban (police) | `range: "A1:B2"` | 4 cellules peintes |
|
||||
| Nuancier du ruban (fond) | `range: "A1:B2"` | 4 cellules peintes |
|
||||
|
||||
Le mécanisme d'écriture était donc correct ; ce qui manquait réellement, c'était
|
||||
le **retour visuel** : les deux nuanciers du ruban n'affichaient rien, d'où
|
||||
l'impression que la couleur n'était pas appliquée. Ils portent désormais une
|
||||
**pastille** (`.xlsx-color-swatch`) qui prend la couleur choisie.
|
||||
|
||||
### 6.3 Tests
|
||||
|
||||
`tests/frontend/xlsx-viewer.test.mjs` — **149/149** (+3 cas #182) :
|
||||
|
||||
- un clic simple réduit toujours une plage « collée » (y compris sur la cellule
|
||||
qui porte déjà le focus) ;
|
||||
- la zone Nom affiche la plage après un glissement ;
|
||||
- couleurs de texte et de fond appliquées aux 4 cellules d'une plage, charge
|
||||
utile vérifiée, pastilles synchronisées.
|
||||
|
||||
> Note d'implémentation : la peinture locale est **asynchrone** (elle suit
|
||||
> l'écriture `PUT`), un test qui lit les styles inline doit donc attendre le
|
||||
> tour de boucle d'événements.
|
||||
|
||||
### 6.4 Les contours qui s'empilaient (le point manquant)
|
||||
|
||||
**Symptôme** : en déplaçant le focus de cellule en cellule, les encadrés de
|
||||
l'ancienne cellule **restaient** et s'accumulaient — la grille finit quadrillée.
|
||||
|
||||
**Cause racine** — le sélecteur de nettoyage était écrit **sans le point** :
|
||||
|
||||
```js
|
||||
// ✘ avant : en CSS, un nom nu est un sélecteur de BALISE (<xlsx-sel-top>)
|
||||
panel.querySelectorAll("xlsx-sel-top,xlsx-sel-bottom,...")
|
||||
// ✔ après
|
||||
panel.querySelectorAll(".xlsx-sel-top,.xlsx-sel-bottom,...")
|
||||
```
|
||||
|
||||
La requête ne renvoyait **aucun** élément, donc `classList.remove(...)` n'était
|
||||
jamais appelé et aucun contour n'était effacé. La même famille de faute
|
||||
touchait `classList.remove(".xlsx-hdr-selected")` (un point passé comme jeton
|
||||
de classe ne correspond à rien) : les en-têtes reflétés n'étaient jamais effacés
|
||||
non plus.
|
||||
|
||||
Le test de non-régression compte les contours dans toute la grille après une
|
||||
randonnée au clavier et exige qu'**une seule** cellule soit encadrée.
|
||||
|
||||
### 6.5 Flèches pendant la saisie
|
||||
|
||||
Dans Google Sheets comme dans Excel, une flèche pendant la saisie **valide la
|
||||
cellule et déplace la sélection** ; le curseur n'est pas parcouru dans le texte
|
||||
(`Alt`+flèche sert à insérer un saut de ligne). Notre implémentation laissait
|
||||
le curseur se déplacer et la cellule restait figée sous l'encadré — ce qui
|
||||
amplifiait la confusion. Corrigé.
|
||||
|
||||
## 7. #183 — Panneau de couleurs façon Google Sheets
|
||||
|
||||
### 7.1 Ce qui remplace quoi
|
||||
|
||||
Le `<input type="color">` natif (ruban **A** / seau, et les deux lignes du menu
|
||||
**Mise en forme**) est remplacé par un bouton ouvrant un panneau construit par
|
||||
`frontend/js/xlsx/color-picker.js` :
|
||||
|
||||
| Section | Contenu |
|
||||
|---|---|
|
||||
| En-tête | icône + **Réinitialiser** (gomme pour le fond, pinceau pour le texte) |
|
||||
| Palette | **8 × 10 = 80** pastilles — ligne 1 : 8 gris du noir au blanc ; lignes 2-10 : 8 teintes (rouge, orange, jaune, vert clair, cyan, bleu, violet, magenta) × 9 nuances, du clair au foncé |
|
||||
| **STANDARD** | 8 couleurs prédéfinies + crayon |
|
||||
| **PERSONNALISÉ** | pastille de la couleur personnalisée, bouton « + », crayon |
|
||||
| Pied | **Mise en forme conditionnelle**, et **Couleurs en alternance** (n'apparaît qu'une fois une couleur choisie, comme sur la maquette) |
|
||||
|
||||
Un clic sur une pastille **referme le panneau** et applique la couleur à la
|
||||
cellule ou à la plage sélectionnée. La coche de sélection est blanche ou noire
|
||||
selon la luminance de la pastille (`luminanceOf`).
|
||||
|
||||
### 7.2 Le piège d'encodage
|
||||
|
||||
La palette est calculée en HSL (lisible) mais **doit sortir en `#rrggbb`** :
|
||||
`normHex()` côté viewer n'accepte que l'hexadécimal à 6 chiffres, et une
|
||||
valeur `hsl(0, 0%, 1429%)` était donc rejetée **sans erreur** — la pastille se
|
||||
peignait à l'écran mais la couleur n'était jamais écrite sur la cellule.
|
||||
`hslToHex()` est doncexporté et testé par le contrôle de fumée.
|
||||
|
||||
### 7.3 Choix assumés
|
||||
|
||||
- **« + » et les crayons ouvrent le sélecteur natif** : le panneau propose 88
|
||||
couleurs curées, le natif reste le seul moyen d'atteindre une teinte
|
||||
arbitraire.
|
||||
- **Les deux entrées de pied de page sont annoncées, pas fonctionnelles** :
|
||||
« Mise en forme conditionnelle » et « Couleurs en alternance » ne sont pas
|
||||
livrées (cf. §3.2). Elles appellent `onUnavailable` et affichent un message
|
||||
explicite plutôt que d'être des boutons inertes.
|
||||
- **Chrome thémé** : uniquement des variables CSS, donc le panneau suit le
|
||||
thème actif ; seules les pastilles portent une couleur en ligne, ce sont des
|
||||
données.
|
||||
|
||||
## 8. #184 — Peinture de format, multi-lignes/colonnes, grille A → Z × 1000
|
||||
|
||||
### 8.1 Peindre le format
|
||||
|
||||
**Symptôme** : l'outil refusait toute cellule source sans mise en forme
|
||||
explicite (« *Veuillez sélectionner une cellule formatée* »). Peindre une
|
||||
cellule simple sur une cellule colorée et en grand corps ne produisait donc
|
||||
**rien** — c'est le cas le plus courant en pratique.
|
||||
|
||||
**Deux défauts distincts**
|
||||
|
||||
1. **Copie partielle** : seuls gras/italique/souligné/barré, couleurs, taille,
|
||||
alignement et format de nombre étaient capturés. valign, retour à la ligne,
|
||||
rotation et **bordures** ne l'étaient pas — une cellule bordée perdait ses
|
||||
bordures en « peignant » la même mise en forme ailleurs.
|
||||
2. **Source « neutre » rejetée** : sans propriété capturée, l'arme était
|
||||
annulée. Dans Sheets/Excel, une cellule sans mise en forme est une source
|
||||
**valide** : elle *réinitialise* la cible.
|
||||
|
||||
**Correctif** — `captureFormat()` renvoie toujours un jeu **complet** de
|
||||
propriétés (y compris `border: "none"`, `font_color: ""`, `fill_color: ""`,
|
||||
`number_format: "General"`), donc la cible finit toujours identique à la source,
|
||||
et une source neutre vide la cible.
|
||||
|
||||
**Fuite d'écouteur** : `Échap` ou un second clic sur le bouton désarmaient
|
||||
l'arme mais laissaient le gestionnaire « appliquer au prochain clic » en place —
|
||||
le clic suivant dans la grille repeignait une vieille mise en forme. Le
|
||||
gestionnaire est maintenant retiré par `stopPaint()`.
|
||||
|
||||
### 8.2 Sélection de plusieurs lignes / colonnes
|
||||
|
||||
Cliquer un numéro de ligne sélectionnait déjà la ligne entière ; il manquait
|
||||
l'extension à **plusieurs** lignes. Le mécanisme :
|
||||
|
||||
- `mousedown` sur un en-tête sélectionne la ligne / colonne **entière** ;
|
||||
- glisser au-dessus d'autres en-tête étend (`A1:Z5`), `Maj`+clic aussi
|
||||
(`A1:Z5`) — l'ancre est mémorisée après le glisser ;
|
||||
- la sélection réutilise le modèle `{anchor, focus}` d'une plage, donc
|
||||
*copier*, *effacer*, *insérer/supprimer*, le menu contextuel et la
|
||||
suppression de contenu fonctionnent sans code supplémentaire ;
|
||||
- le `click` sur en-tête a été **retiré** : il écrasait la multi-sélection
|
||||
juste après le glisser ;
|
||||
- une pression **près du bord** de l'en-tête reste le geste de
|
||||
**redimensionnement** (partage explicite avec `wireResize`).
|
||||
|
||||
### 8.3 Grille A → Z, et 1000 lignes à la demande
|
||||
|
||||
La grille de la feuille affichée est complétée jusqu'à **26 colonnes** (A → Z)
|
||||
d'emblée ; les **lignes s'ajoutent par blocs de 100** quand le lecteur approche
|
||||
du bas, jusqu'à 1000. Les cellules vides sont de vraies cellules éditables
|
||||
(`contentEditable`, `tabIndex`, écouteurs, `data-cell`). L'extension a lieu à
|
||||
l'activation d'un onglet, pas au montage : la faire pour toutes les feuilles
|
||||
multiplierait les cellules par le nombre d'onglets.
|
||||
|
||||
**Une feuille tronquée garde son chargement serveur** (« Charger la suite »,
|
||||
fenêtre par fenêtre) : ses lignes manquantes sont des données, pas du
|
||||
remplissage. Étendre la grille par-dessus aurait dupliqué les lignes et
|
||||
supprimait le pied de page — régression de **#153-A9bis**, corrigée en gardant
|
||||
les deux mécanismes séparés (les colonnes et les blocs vides pour une feuille
|
||||
complète, la fenêtre serveur pour une feuille tronquée).
|
||||
|
||||
**Pourquoi pas les 1000 lignes d'un coup.** Matérialiser A → Z × 1000 revient à
|
||||
**~26 000 cellules câblées** pour la seule feuille visible. Mesuré : ~0,3 Go
|
||||
retenus par rendu, non récupérables même après `innerHTML = ""` et GC forcé — la
|
||||
suite JSDOM `xlsx-viewer` (163 cas) épuisait le tas vers le 45ᵉ montage. La
|
||||
croissance par blocs offre la même étendue sans payer le coût d'un coup.
|
||||
|
||||
**Trois goulots levés** — toujours utiles sur la grille atteinte :
|
||||
|
||||
| Goulot | Avant | Après |
|
||||
|---|---|---|
|
||||
| `findTd` | un `querySelector` par appel → 26 000 requêtes pour une colonne | **index `ref → td`** avec auto-réparation |
|
||||
| `refreshPanelFormulas` | `cloneNode` sur chaque cellule, à chaque frappe | court-circuit sur cellule vide sans formule |
|
||||
| `applySheetMeta` | toutes les cellules touchées | court-circuit sur celles que le classeur ne style pas |
|
||||
|
||||
**Sorties.** L'export (CSV / Markdown / HTML / impression) écarte les lignes et
|
||||
colonnes vides de queue : sans cela un classeur de deux colonnes exportait les
|
||||
26 colonnes du quadrillage.
|
||||
|
||||
> **Reste à faire.** La virtualisation par fenêtre (ne garder que les lignes
|
||||
> visibles et recycler au défilement) remplacerait la croissance par blocs si la
|
||||
> grille de 1000 lignes redevenait lourde dans le navigateur.
|
||||
@@ -0,0 +1,107 @@
|
||||
# #180 — Éditeur tableur : redimensionnement au curseur, grille thémée, menu Fichier & barre épinglée
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) ·
|
||||
> [#179](./xlsx-menus-179.md) · [#156](./xlsx-editor-completeness.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
Retours utilisateur après #179, quatre défauts d'ergonomie et un défaut de thème :
|
||||
|
||||
1. **Le curseur de redimensionnement n'apparaît pas.** Le redimensionnement
|
||||
par les marges existe depuis #156-A8 (`wireResize`), mais l'indicateur est
|
||||
invisible : `.xlsx-table td[contenteditable]` impose `cursor: cell` et les
|
||||
en-têtes reçoivent `cursor: pointer` **en ligne** (`paintHeaders()`). Les
|
||||
deux beat la règle `table.style.cursor` posée par le `mousemove`, y compris
|
||||
pendant le glissement. L'utilisateur n'a aucun retour sur la zone cliquable.
|
||||
2. **La grille ne suit pas le thème.** `--grid-*` n'est déclaré que pour le
|
||||
thème par défaut (dark + light) dans `style.css`. Les 13 autres thèmes et
|
||||
les modes générés *contraste élevé* / *sépia* n'en fournissent pas : la
|
||||
grille y reste en couleurs GitHub. `themes.js` applique pourtant bien les
|
||||
variables via `setProperty`, sans les jetons de grille.
|
||||
3. **Les sorties de fichier sont dispersées.** Télécharger, CSV et Exporter
|
||||
occupent trois commandes du ruban, à gauche du bouton Enregistrer.
|
||||
4. **La barre n'est pas épinglée** et l'éditeur ne prend pas toute la largeur
|
||||
du contenu quand la sidebar est masquée (contrairement aux viewers
|
||||
PDF / images / Excalidraw, cf. la règle `:has(...)` de `.content-area`).
|
||||
5. La pastille « Formules calculées localement » occupe une ligne entière
|
||||
pour annoncer un comportement normal, pas une limite.
|
||||
|
||||
## 2. Livré (A1-A4)
|
||||
|
||||
### A1 — Curseur de redimensionnement sur les marges
|
||||
|
||||
`wireResize()` (viewer.js) remplace le `table.style.cursor = …` isolé par
|
||||
`paintResizeCursor(kind, th)` :
|
||||
|
||||
- la table reçoit `.xlsx-resize-col` / `.xlsx-resize-row`, repint par le CSS
|
||||
sur la table **et** ses cellules (surplus des deux curseurs concurrents) ;
|
||||
- l'en-tête de bord est **miroité en ligne** (`th.style.cursor`), seul moyen
|
||||
de passer devant le `cursor: pointer` inline — l'ancienne valeur est
|
||||
mémorisée puis restaurée quand le pointeur quitte le bord (`mouseleave`) ;
|
||||
- l'état est remis à zéro au `mouseup` global : le curseur ne reste pas
|
||||
« coincé » après un glissement.
|
||||
|
||||
### A2 — Grille compatible tous les thèmes
|
||||
|
||||
- `themes.js` : `applyTheme()` dérive les jetons `--grid-bg`,
|
||||
`--grid-header-bg`, `--grid-header-text`, `--grid-border` et `--grid-zebra`
|
||||
des surfaces du thème actif. Le zébré passe par `color-mix(… 4%, transparent)`
|
||||
pour rester subtil sur fond clair **et** sombre. Un thème importé sans ces
|
||||
jetons est donc couvert automatiquement.
|
||||
- `style.css` : jetons de repli (`var(--grid-bg, var(--surface))`, …) pour que
|
||||
la grille reste lisible même sans `applyTheme()`, plus les deux modes
|
||||
*contraste élevé* et *sépia* déclarés explicitement.
|
||||
- Le fond du menu Export passe de `var(--bg, #fff)` (repli clair cassé en
|
||||
thème sombre) à `var(--surface)`.
|
||||
|
||||
### A3 — Menu Fichier + Enregistrer au niveau des onglets
|
||||
|
||||
- `buildCommandBar()` renvoie un fragment `fileHtml` supplémentaire : menu
|
||||
**Fichier** (`download`, `file-spreadsheet`, `file-text`, `code`,
|
||||
`printer`) + bouton **Enregistrer** primary. Les trois commandes de sortie
|
||||
sortent du ruban, qui ne garde que l'édition.
|
||||
- La coquille (`viewer.js`) rend une barre de feuilles `.xlsx-sheetbar` :
|
||||
onglets à gauche, `.xlsx-sheetbar-end` (menu + Enregistrer) à droite.
|
||||
- Câblage : `trackDismissable()` (même contrat que #179), navigation clavier,
|
||||
fermeture au clic sur Télécharger / CSV, `aria-haspopup` / `aria-expanded`.
|
||||
- **Identifiants stables** : `#xlsx-download-btn`, `#xlsx-csv-btn`,
|
||||
`#xlsx-save-btn` conservés — seuls le conteneur (`#xlsx-file-btn` /
|
||||
`#xlsx-file-menu`) et la classe (`.xlsx-file-menu`) sont nouveaux.
|
||||
|
||||
### A4 — Retrait de la pastille + barre épinglée pleine largeur
|
||||
|
||||
- La pastille « Formules calculées localement » est retirée ; la barre de
|
||||
statut ne subsiste que pour la pastille *lecture seule* (format `.xls` /
|
||||
`.ods`), et disparaît entièrement quand elle n'a rien à afficher.
|
||||
- `.xlsx-toolbar` devient `position: sticky; top: 0` avec des marges négatives
|
||||
qui neutralizing le padding de `.content-area` : la barre se colle au bord
|
||||
haut de la page et l'éditeur occupe la largeur totale du contenu.
|
||||
- `.content-area:has(.xlsx-viewer)` rejoint les viewers plein cadre.
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` — 136/136 :
|
||||
- barre de feuilles (onglets / menu / Enregistrer à droite, onglets sortis
|
||||
du ruban), contenu exact du menu Fichier (5 icônes dans l'ordre), toggle ;
|
||||
- curseur `col-resize` / `row-resize` sur les deux types de bord, classes de
|
||||
repaint, restauration du curseur d'en-tête à la sortie ;
|
||||
- jetons `--grid-*` pour 4 thèmes (Spotify dark, Notion light, GitHub
|
||||
contrasté élevé, GitHub sépia) + zébré `color-mix` ;
|
||||
- absence de pastille formules (xlsx **et** csv).
|
||||
- `tests/frontend/xlsx-menus.test.mjs` — 12/12 : icônes de `fileHtml`.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` : le menu Fichier s'ouvre, expose les 5
|
||||
entrées, se ferme au clic extérieur ; Enregistrer présent dans
|
||||
`.xlsx-sheetbar-end`.
|
||||
- 8 tests préexistants réalignés sur la sémantique « écriture verrouillée
|
||||
puis application locale » (le viewer n'est plus re-lu après une écriture
|
||||
structure / mise en forme) : le second appel PUT n'a plus lieu, les
|
||||
assertions portent sur la grille peinte en place.
|
||||
|
||||
## 4. Points d'attention
|
||||
|
||||
- La copie des cellules vers le presse-papiers système et l'export sont
|
||||
inchangés : seul le **point d'entrée** des sorties de fichier a bougé.
|
||||
- `viewer.js` reste le propriétaire du câblage ; `command-bar.js` reste un
|
||||
constructeur de chaînes pur, donc testable sans DOM.
|
||||
+49
-5
@@ -1587,6 +1587,7 @@
|
||||
<li><a href="#cfg-recent" class="help-nav-link" data-i18n="config.section_recent"></a></li>
|
||||
<li><a href="#cfg-tags" class="help-nav-link" data-i18n="config.section_tags"></a></li>
|
||||
<li><a href="#cfg-hidden-files" class="help-nav-link" data-i18n="config.section_hidden"></a></li>
|
||||
<li><a href="#cfg-desktop-roots" class="help-nav-link" data-i18n="config.section_desktop"></a></li>
|
||||
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
|
||||
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
|
||||
<li><a href="#cfg-diags" class="help-nav-link" data-i18n="settings.diagnostics"></a></li>
|
||||
@@ -1950,6 +1951,45 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Desktop vaults & root directories (#159, #160) -->
|
||||
<section
|
||||
id="cfg-desktop-roots"
|
||||
class="config-section help-section"
|
||||
>
|
||||
<h2 data-i18n="config.section_desktop">🖥️ Vaults & dossiers (Desktop)</h2>
|
||||
<p
|
||||
class="config-description"
|
||||
data-i18n="config.desktop_roots_desc"
|
||||
>
|
||||
Ajoutez ou retirez les vaults et dossiers
|
||||
racine chargés par l'application desktop.
|
||||
</p>
|
||||
|
||||
<div
|
||||
id="desktop-roots-list"
|
||||
class="desktop-roots-list"
|
||||
>
|
||||
<!-- Racines injectées par renderDesktopRoots() -->
|
||||
</div>
|
||||
|
||||
<div class="config-actions-row desktop-roots-actions">
|
||||
<button
|
||||
class="config-btn-primary config-btn-sm"
|
||||
id="cfg-desktop-add-vault"
|
||||
data-i18n="desktop.add_vault"
|
||||
>
|
||||
📁 Ajouter un vault
|
||||
</button>
|
||||
<button
|
||||
class="config-btn-secondary config-btn-sm"
|
||||
id="cfg-desktop-add-dir"
|
||||
data-i18n="desktop.add_dir"
|
||||
>
|
||||
📂 Ajouter un dossier
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Watcher / Synchronisation -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
@@ -4634,9 +4674,9 @@
|
||||
conversation.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_agent">
|
||||
Le bouton « mode agent » active les outils (lire, lister,
|
||||
chercher) ; les actions de modification demandent une
|
||||
confirmation avec aperçu des changements.
|
||||
L'assistant utilise toujours ses outils (lire, lister,
|
||||
chercher, modifier) ; les actions de modification demandent
|
||||
une confirmation avec aperçu des changements.
|
||||
</li>
|
||||
<li data-i18n="help.assistant_agent_run">
|
||||
Les actions s'affichent dans le fil : l'assistant regroupe les
|
||||
@@ -5262,8 +5302,12 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Secret redactor</strong><span data-i18n="help.desc_9846fc07"> : Masque
|
||||
JWT, clés API, tokens GitHub dans les
|
||||
aperçus</span></li>
|
||||
JWT, mots de passe et clés API (OpenAI,
|
||||
Anthropic, GitHub, Google, AWS, Slack,
|
||||
Stripe, GitLab, Hugging Face…) dans les
|
||||
aperçus markdown ; cliquez sur un masque
|
||||
pour copier la valeur dans le
|
||||
presse-papiers</span></li>
|
||||
<li>
|
||||
<strong>Path traversal</strong> : Validation
|
||||
des chemins contre les attaques
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -587,6 +587,26 @@ function applyTheme(themeKey, mode) {
|
||||
Object.keys(vars).forEach(function(key) {
|
||||
root.style.setProperty(key, vars[key]);
|
||||
});
|
||||
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
||||
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
|
||||
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
||||
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
||||
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
||||
// cell background on most themes, so the grid was invisible). The line is now
|
||||
// a blend of the text colour into the sheet surface: the SAME single value
|
||||
// for every line, and legible on both dark and light grounds.
|
||||
var _gridSurface = vars['--surface'] || vars['--bg-secondary'] || '#000000';
|
||||
var _gridInk = vars['--text-primary'] || vars['--text'] || '#888888';
|
||||
var _grid = {
|
||||
'--grid-bg': _gridSurface,
|
||||
'--grid-header-bg': vars['--bg-hover'] || _gridSurface,
|
||||
'--grid-header-text': _gridInk,
|
||||
'--grid-border': 'color-mix(in srgb, ' + _gridInk + ' 22%, ' + _gridSurface + ')',
|
||||
'--grid-zebra': 'color-mix(in srgb, ' + _gridInk + ' 4%, transparent)'
|
||||
};
|
||||
Object.keys(_grid).forEach(function(key) {
|
||||
if (_grid[key]) root.style.setProperty(key, _grid[key]);
|
||||
});
|
||||
// Store resolved vars on the theme for export/preview
|
||||
if (!theme.modes[mode]) theme.modes[mode] = vars;
|
||||
|
||||
|
||||
+20
-9
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -794,15 +795,16 @@ const FileOperations = {
|
||||
<div class="modal-form-group">
|
||||
<label class="modal-label">${t('modal.file_type')}</label>
|
||||
<select class="modal-select" id="file-ext-select">
|
||||
<option value=".md">Markdown (.md)</option>
|
||||
<option value=".txt">Texte (.txt)</option>
|
||||
<option value=".py">Python (.py)</option>
|
||||
<option value=".js">JavaScript (.js)</option>
|
||||
<option value=".json">JSON (.json)</option>
|
||||
<option value=".excalidraw">Excalidraw (.excalidraw)</option>
|
||||
<option value=".yaml">YAML (.yaml)</option>
|
||||
<option value=".sh">Shell (.sh)</option>
|
||||
<option value=".ps1">PowerShell (.ps1)</option>
|
||||
<option value=".md">${t('modal.ext_md')}</option>
|
||||
<option value=".txt">${t('modal.ext_txt')}</option>
|
||||
<option value=".py">${t('modal.ext_py')}</option>
|
||||
<option value=".js">${t('modal.ext_js')}</option>
|
||||
<option value=".json">${t('modal.ext_json')}</option>
|
||||
<option value=".xlsx">${t('modal.ext_xlsx')}</option>
|
||||
<option value=".excalidraw">${t('modal.ext_excalidraw')}</option>
|
||||
<option value=".yaml">${t('modal.ext_yaml')}</option>
|
||||
<option value=".sh">${t('modal.ext_sh')}</option>
|
||||
<option value=".ps1">${t('modal.ext_ps1')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1806,6 +1808,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);
|
||||
|
||||
@@ -695,6 +695,21 @@ async function reloadExternalWrite(vault, path, force = false) {
|
||||
}
|
||||
// Not editing: refresh the read view when it shows the written document.
|
||||
if (state.currentVault === vault && state.currentPath === path) {
|
||||
// The spreadsheet viewer applies its own writes dynamically (values,
|
||||
// styles and structure stay in the DOM): reloading it here would wipe the
|
||||
// grid behind a loading spinner on every save. Skip the reload when the
|
||||
// event is the watcher echo of our own write (recent stamp) or when
|
||||
// unsaved edits are still on screen (they would be clobbered). Forced
|
||||
// reloads (assistant writes) still go through.
|
||||
const xlsxOpen = !force
|
||||
&& document.querySelector("#content-area .xlsx-viewer");
|
||||
if (xlsxOpen) {
|
||||
const own = window.__xlsxLastOwnWrite;
|
||||
const recentOwn = own && own.vault === vault && own.path === path
|
||||
&& (Date.now() - (own.at || 0)) < 15000;
|
||||
const hasDirty = Boolean(document.querySelector("#content-area td.xlsx-dirty"));
|
||||
if (recentOwn || hasDirty) return;
|
||||
}
|
||||
// Excalidraw owns its iframe: re-rendering would recreate it (visible page
|
||||
// refresh) and discard the in-editor scene. Its own save already persisted
|
||||
// the file, so there is nothing to reload here.
|
||||
|
||||
+3767
-215
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
/* ObsiGate — XLSX colour picker panel (#183).
|
||||
*
|
||||
* Replaces the native `<input type="color">` of the spreadsheet ribbon and of
|
||||
* the Mise en forme menu with a Google-Sheets-like panel:
|
||||
*
|
||||
* ┌ header ─ « Réinitialiser » ───────────────────┐
|
||||
* │ palette 8 x 10 : ligne 1 = échelles de gris, │
|
||||
* │ lignes 2-10 = 8 teintes x 9 │
|
||||
* │ nuances (clair -> foncé) │
|
||||
* │ STANDARD 8 pastilles prédéfinies │
|
||||
* │ PERSONNALISÉ pastille + bouton « + » + crayon │
|
||||
* │ pied Mise en forme conditionnelle, │
|
||||
* │ Couleurs en alternance │
|
||||
* └────────────────────────────────────────────────┘
|
||||
*
|
||||
* Picking a colour closes the panel and applies it (the caller owns the
|
||||
* write). The panel markup is built here, positioning and dismissal are the
|
||||
* viewer's (it already owns `anchorUnder` + `trackDismissable` for the other
|
||||
* spreadsheet menus), so this module stays a pure builder.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
// 8 columns = 8 hues, in the Google Sheets order: red, orange, yellow, light
|
||||
// green, cyan, blue, violet, magenta.
|
||||
const HUES = [0, 30, 55, 100, 180, 215, 270, 320];
|
||||
// 9 value rows, light (top) -> dark (bottom).
|
||||
const ROWS = 9;
|
||||
const GREYS = 8;
|
||||
|
||||
/**
|
||||
* HSL → `#rrggbb`.
|
||||
*
|
||||
* The palette is generated in HSL for readability, but it MUST leave this
|
||||
* module as hex: the write path (`normHex` in viewer.js) and the backend only
|
||||
* accept `#rrggbb`, so an `hsl(...)` swatch would be silently dropped on save.
|
||||
*/
|
||||
export function hslToHex(h, s, l) {
|
||||
const hue = ((h % 360) + 360) % 360;
|
||||
const sat = Math.min(1, Math.max(0, s));
|
||||
const lig = Math.min(1, Math.max(0, l));
|
||||
const c = (1 - Math.abs(2 * lig - 1)) * sat;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = lig - c / 2;
|
||||
const seg = Math.floor(hue / 60) % 6;
|
||||
const rgb = [
|
||||
[c, x, 0], [x, c, 0], [0, c, x],
|
||||
[0, x, c], [x, 0, c], [c, 0, x],
|
||||
][seg] || [0, 0, 0];
|
||||
return `#${rgb.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
/** Relative luminance (WCAG-ish) of a `#rrggbb` string, 0 (black) → 1 (white). */
|
||||
export function luminanceOf(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "").trim());
|
||||
if (!m) return 0;
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = ((n >> 16) & 255) / 255;
|
||||
const g = ((n >> 8) & 255) / 255;
|
||||
const b = (n & 255) / 255;
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 80 swatches: a first row of greys then 9 x 8 hues.
|
||||
* @returns {Array<Array<string>>} 10 rows of 8 CSS colours
|
||||
*/
|
||||
export function buildPalette() {
|
||||
const rows = [];
|
||||
const greys = [];
|
||||
for (let i = 0; i < GREYS; i++) {
|
||||
// black -> white in 8 steps (l stays a 0..1 ratio, converted by hslToHex)
|
||||
greys.push(hslToHex(0, 0, i / (GREYS - 1)));
|
||||
}
|
||||
rows.push(greys);
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const l = 0.90 - r * (0.60 / (ROWS - 1)); // 0.90 -> 0.30
|
||||
const s = 0.60 + r * (0.25 / (ROWS - 1)); // 0.60 -> 0.85
|
||||
rows.push(HUES.map((h) => hslToHex(h, s, l)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The 8 predefined colours of the STANDARD row. */
|
||||
export const STANDARD_COLOURS = [
|
||||
{ key: "black", hex: "#000000" },
|
||||
{ key: "white", hex: "#ffffff" },
|
||||
{ key: "blue", hex: "#1a73e8" },
|
||||
{ key: "red", hex: "#d93025" },
|
||||
{ key: "yellow", hex: "#f4b400" },
|
||||
{ key: "green", hex: "#188038" },
|
||||
{ key: "orange", hex: "#e8710a" },
|
||||
{ key: "cyan", hex: "#00bcd4" },
|
||||
];
|
||||
|
||||
const CHECK_SVG =
|
||||
'<svg class="xlsx-colour-check" viewBox="0 0 24 24" width="12" height="12" aria-hidden="true" focusable="false">'
|
||||
+ '<path fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5"/></svg>';
|
||||
|
||||
const icon = (name) =>
|
||||
`<i data-lucide="${name}" class="xlsx-colour-icon" aria-hidden="true"></i>`;
|
||||
|
||||
const button = (cls, label, ariaLabel) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `btn-action ${cls}`;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", ariaLabel || label);
|
||||
return b;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single round swatch.
|
||||
* @param {string} colour - CSS colour
|
||||
* @param {string} label - tooltip / aria-label
|
||||
* @param {boolean} selected - draw the check mark
|
||||
*/
|
||||
const swatchButton = (colour, label, selected) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "xlsx-colour-swatch-btn";
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.style.background = colour;
|
||||
b.dataset.colour = colour;
|
||||
if (selected) {
|
||||
b.classList.add("selected");
|
||||
b.setAttribute("aria-pressed", "true");
|
||||
// White check on dark grounds, black on light ones — otherwise the mark
|
||||
// disappears (the same contrast rule as the design).
|
||||
b.style.color = luminanceOf(colour) > 0.55 ? "#000000" : "#ffffff";
|
||||
b.innerHTML = CHECK_SVG;
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the panel.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {"font"|"fill"} opts.kind - drives the panel label and the footer
|
||||
* @param {string} [opts.value] - current colour (`""` = none)
|
||||
* @param {string} [opts.custom] - colour shown in the PERSONNALISÉ slot
|
||||
* @param {Function} opts.onPick - (cssColour) => void; the panel is closed by
|
||||
* the caller right after
|
||||
* @param {Function} [opts.onReset] - clear the colour entirely
|
||||
* @param {Function} [opts.onCustom] - open the native picker for a custom
|
||||
* colour, called with no argument
|
||||
* @param {Function} [opts.onUnavailable] - (i18nLabel) => void for the footer
|
||||
* entries that ObsiGate does not implement yet
|
||||
* @returns {{ el: HTMLElement, sync: Function }}
|
||||
*/
|
||||
export function createColourPicker({
|
||||
kind = "font",
|
||||
value = "",
|
||||
custom = "",
|
||||
onPick,
|
||||
onReset,
|
||||
onCustom,
|
||||
onUnavailable,
|
||||
} = {}) {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "xlsx-colour-panel";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute(
|
||||
"aria-label",
|
||||
kind === "fill" ? t("xlsx.colour_panel_fill") : t("xlsx.colour_panel_font")
|
||||
);
|
||||
const isFill = kind === "fill";
|
||||
const norm = (c) => String(c || "").trim().toLowerCase();
|
||||
|
||||
// ── Header : Réinitialiser ────────────────────────────────────────────────
|
||||
const head = document.createElement("div");
|
||||
head.className = "xlsx-colour-head";
|
||||
const reset = button(
|
||||
"xlsx-colour-reset",
|
||||
t("xlsx.colour_reset"),
|
||||
isFill ? t("xlsx.colour_reset_fill") : t("xlsx.colour_reset_font")
|
||||
);
|
||||
reset.innerHTML = `${icon(isFill ? "eraser" : "paintbrush")}<span>${t("xlsx.colour_reset")}</span>`;
|
||||
reset.addEventListener("click", () => {
|
||||
if (onReset) onReset();
|
||||
});
|
||||
head.appendChild(reset);
|
||||
panel.appendChild(head);
|
||||
|
||||
// ── Palette 8 x 10 ───────────────────────────────────────────────────────
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "xlsx-colour-grid";
|
||||
grid.setAttribute("role", "group");
|
||||
grid.setAttribute("aria-label", t("xlsx.colour_palette"));
|
||||
const current = norm(value);
|
||||
buildPalette().forEach((row) => {
|
||||
row.forEach((colour) => {
|
||||
const b = swatchButton(colour, colour, norm(colour) === current);
|
||||
b.addEventListener("click", () => {
|
||||
if (onPick) onPick(colour);
|
||||
});
|
||||
grid.appendChild(b);
|
||||
});
|
||||
});
|
||||
panel.appendChild(grid);
|
||||
|
||||
// ── STANDARD ─────────────────────────────────────────────────────────────
|
||||
const standard = document.createElement("div");
|
||||
standard.className = "xlsx-colour-section";
|
||||
const stdHead = document.createElement("div");
|
||||
stdHead.className = "xlsx-colour-title";
|
||||
stdHead.appendChild(document.createTextNode(t("xlsx.colour_standard")));
|
||||
const stdEdit = button(
|
||||
"xlsx-colour-edit",
|
||||
t("xlsx.colour_edit_standard"),
|
||||
t("xlsx.colour_edit_standard")
|
||||
);
|
||||
stdEdit.innerHTML = icon("pencil");
|
||||
stdEdit.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
stdHead.appendChild(stdEdit);
|
||||
standard.appendChild(stdHead);
|
||||
|
||||
const stdRow = document.createElement("div");
|
||||
stdRow.className = "xlsx-colour-row";
|
||||
STANDARD_COLOURS.forEach(({ key, hex }) => {
|
||||
const label = t(`xlsx.colour_${key}`);
|
||||
const b = swatchButton(hex, label, norm(hex) === current);
|
||||
b.addEventListener("click", () => {
|
||||
if (onPick) onPick(hex);
|
||||
});
|
||||
stdRow.appendChild(b);
|
||||
});
|
||||
standard.appendChild(stdRow);
|
||||
panel.appendChild(standard);
|
||||
|
||||
// ── PERSONNALISÉ ─────────────────────────────────────────────────────────
|
||||
const customSection = document.createElement("div");
|
||||
customSection.className = "xlsx-colour-section";
|
||||
const cusHead = document.createElement("div");
|
||||
cusHead.className = "xlsx-colour-title";
|
||||
cusHead.appendChild(document.createTextNode(t("xlsx.colour_custom")));
|
||||
customSection.appendChild(cusHead);
|
||||
|
||||
const cusRow = document.createElement("div");
|
||||
cusRow.className = "xlsx-colour-row";
|
||||
const customColour = custom || value;
|
||||
const cusSwatch = swatchButton(
|
||||
customColour || "transparent",
|
||||
t("xlsx.colour_custom"),
|
||||
Boolean(customColour) && norm(customColour) === current
|
||||
);
|
||||
cusSwatch.addEventListener("click", () => {
|
||||
if (customColour && onPick) onPick(customColour);
|
||||
});
|
||||
cusRow.appendChild(cusSwatch);
|
||||
|
||||
const add = button("xlsx-colour-add", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
|
||||
add.innerHTML = icon("plus");
|
||||
add.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
cusRow.appendChild(add);
|
||||
|
||||
const cusEdit = button("xlsx-colour-edit", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
|
||||
cusEdit.innerHTML = icon("pencil");
|
||||
cusEdit.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
cusRow.appendChild(cusEdit);
|
||||
customSection.appendChild(cusRow);
|
||||
panel.appendChild(customSection);
|
||||
|
||||
// ── Footer ───────────────────────────────────────────────────────────────
|
||||
// Both entries are announced by ObsiGate but not implemented yet: they call
|
||||
// back with their label so the caller can tell the user instead of leaving a
|
||||
// dead button. « Couleurs en alternance » only shows once a colour is set,
|
||||
// exactly like the reference screenshots.
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "xlsx-colour-foot";
|
||||
const footEntry = (label, key) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "xlsx-colour-foot-item";
|
||||
b.textContent = label;
|
||||
b.dataset.colourAction = key;
|
||||
b.addEventListener("click", () => {
|
||||
if (onUnavailable) onUnavailable(label);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
foot.appendChild(footEntry(t("xlsx.colour_conditional"), "conditional"));
|
||||
if (value) foot.appendChild(footEntry(t("xlsx.colour_banded"), "banded"));
|
||||
panel.appendChild(foot);
|
||||
|
||||
/** Repaint the check mark after the value changed (custom colour applied). */
|
||||
const sync = (next, nextCustom) => {
|
||||
const want = norm(next);
|
||||
panel.querySelectorAll(".xlsx-colour-swatch-btn").forEach((b) => {
|
||||
const on = norm(b.dataset.colour) === want;
|
||||
b.classList.toggle("selected", on);
|
||||
b.setAttribute("aria-pressed", String(on));
|
||||
b.innerHTML = on
|
||||
? (b.style.color = luminanceOf(b.dataset.colour) > 0.55 ? "#000000" : "#ffffff", CHECK_SVG)
|
||||
: "";
|
||||
});
|
||||
if (nextCustom) {
|
||||
const slot = panel.querySelector(".xlsx-colour-section:nth-of-type(3) .xlsx-colour-row .xlsx-colour-swatch-btn");
|
||||
if (slot) slot.style.background = nextCustom;
|
||||
}
|
||||
};
|
||||
|
||||
return { el: panel, sync };
|
||||
}
|
||||
+131
-28
@@ -1,19 +1,29 @@
|
||||
/* ObsiGate — XLSX command bar builder (#154-A5).
|
||||
/* ObsiGate — XLSX command bar builder (Sheets-like ribbon).
|
||||
*
|
||||
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
|
||||
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
|
||||
* is testable and the viewer only wires behaviour.
|
||||
*
|
||||
* Order follows Google Sheets: search, undo/redo, print, paint, zoom, number
|
||||
* formats, font size, B/I/S/U, colours, borders, merge, alignments, wrap,
|
||||
* rotation, overflow menu — then the ObsiGate-specific groups (structure,
|
||||
* format, view, gridlines) kept with their stable ids. File outputs
|
||||
* (Download / CSV / Markdown / HTML / Print) moved to the **Fichier** menu of
|
||||
* the menu bar (#185, frontend/js/xlsx/menus.js); only Save stays on the
|
||||
* sheet-tab strip, pinned at its right end.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
const IC = (name) => `<i data-lucide="${name}" style="width:14px;height:14px" aria-hidden="true"></i>`;
|
||||
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {Array<{name: string}>} ctx.sheets
|
||||
* @param {boolean} ctx.isCsv
|
||||
* @param {boolean} ctx.readOnly
|
||||
* @param {boolean} ctx.editable - not a CSV and not read-only
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string }}
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string, fileHtml: string }}
|
||||
*/
|
||||
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
|
||||
@@ -23,22 +33,26 @@ 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.
|
||||
// Status pills make the viewer's limits visible up front (#180: the
|
||||
// "formulas computed locally" pill is gone — local computation is the
|
||||
// normal behaviour, not a limit worth a badge).
|
||||
const statusPills = [
|
||||
readOnly
|
||||
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
|
||||
: "",
|
||||
!isCsv
|
||||
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
|
||||
: "",
|
||||
].filter(Boolean).join("");
|
||||
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
|
||||
|
||||
// Command groups, separated by thin rules.
|
||||
// Command groups, separated by thin rules (Sheets order).
|
||||
const actionGroups = [];
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="search">
|
||||
<button class="btn-action" id="xs-search" type="button" title="${escapeHtml(t("xlsx.tb_search"))}" aria-label="${escapeHtml(t("xlsx.tb_search"))}">
|
||||
${IC("search")}
|
||||
</button>
|
||||
</span>`);
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
|
||||
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
|
||||
@@ -48,6 +62,97 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="print">
|
||||
<button class="btn-action" id="xs-print" type="button" title="${escapeHtml(t("xlsx.print"))}" aria-label="${escapeHtml(t("xlsx.print"))}">
|
||||
${IC("printer")}
|
||||
</button>
|
||||
</span>`);
|
||||
if (editable) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="paint">
|
||||
<button class="btn-action" id="xs-paint" type="button" title="${escapeHtml(t("xlsx.tb_paint"))}" aria-label="${escapeHtml(t("xlsx.tb_paint"))}" aria-pressed="false">
|
||||
${IC("paint-roller")}
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="zoom">
|
||||
<select class="xlsx-select" id="xs-zoom" title="${escapeHtml(t("xlsx.tb_zoom"))}" aria-label="${escapeHtml(t("xlsx.tb_zoom"))}">
|
||||
${[50, 75, 90, 100, 125, 150, 200].map((z) =>
|
||||
`<option value="${z}"${z === 100 ? " selected" : ""}>${z} %</option>`).join("")}
|
||||
</select>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="number">
|
||||
<button class="btn-action" id="xs-cur" type="button" title="${escapeHtml(t("xlsx.tb_currency"))}" aria-label="${escapeHtml(t("xlsx.tb_currency"))}">
|
||||
${IC("dollar-sign")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-pct" type="button" title="${escapeHtml(t("xlsx.tb_percent"))}" aria-label="${escapeHtml(t("xlsx.tb_percent"))}">
|
||||
${IC("percent")}
|
||||
</button>
|
||||
<button class="btn-action xlsx-dec" id="xs-decminus" type="button" title="${escapeHtml(t("xlsx.tb_dec_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_minus"))}">.0←</button>
|
||||
<button class="btn-action xlsx-dec" id="xs-decplus" type="button" title="${escapeHtml(t("xlsx.tb_dec_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_plus"))}">.00→</button>
|
||||
<button class="btn-action" id="xs-num123" type="button" title="${escapeHtml(t("xlsx.tb_number"))}" aria-label="${escapeHtml(t("xlsx.tb_number"))}">123</button>
|
||||
<select class="xlsx-select" id="xs-numfmt" title="${escapeHtml(t("xlsx.tb_numfmt"))}" aria-label="${escapeHtml(t("xlsx.tb_numfmt"))}">
|
||||
<option value="" selected>${escapeHtml(t("xlsx.tb_numfmt"))}</option>
|
||||
<option value="General">${escapeHtml(t("xlsx.format_num_general"))}</option>
|
||||
<option value="0.00">${escapeHtml(t("xlsx.format_num_number"))}</option>
|
||||
<option value="0.0%">${escapeHtml(t("xlsx.format_num_percent"))}</option>
|
||||
<option value="#,##0.00 "€"">${escapeHtml(t("xlsx.format_num_currency"))}</option>
|
||||
<option value="DD/MM/YYYY">${escapeHtml(t("xlsx.format_num_date"))}</option>
|
||||
<option value="@">${escapeHtml(t("xlsx.format_num_text"))}</option>
|
||||
</select>
|
||||
</span>`);
|
||||
// #183 — colour commands open the Sheets-like panel built by
|
||||
// xlsx/color-picker.js (the native <input type="color"> is kept hidden and
|
||||
// only driven by the panel's « + » / crayon buttons).
|
||||
const colourPick = (id, label, iconName, swatchId) =>
|
||||
`<span class="xlsx-colorpick-wrap">`
|
||||
+ `<button class="btn-action xlsx-colorpick" id="${id}" type="button"`
|
||||
+ ` data-xlsx-colour="${swatchId === "xs-fgcolor" ? "font" : "fill"}"`
|
||||
+ ` title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}"`
|
||||
+ ` aria-haspopup="dialog" aria-expanded="false">`
|
||||
+ (iconName === "fill" ? IC("paint-bucket") : '<span class="xlsx-colorletter" aria-hidden="true">A</span>')
|
||||
+ `<span class="xlsx-color-swatch" data-swatch-for="${swatchId}" aria-hidden="true"></span>`
|
||||
+ `</button>`
|
||||
+ `<input type="color" class="xlsx-color-native" data-xlsx-native-for="${swatchId}" tabindex="-1" aria-hidden="true">`
|
||||
+ `</span>`;
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="font">
|
||||
<button class="btn-action" id="xs-fsminus" type="button" title="${escapeHtml(t("xlsx.tb_font_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_minus"))}">−</button>
|
||||
<select class="xlsx-select xlsx-fontsize" id="xs-fssize" title="${escapeHtml(t("xlsx.tb_font_size"))}" aria-label="${escapeHtml(t("xlsx.tb_font_size"))}">
|
||||
${[6, 7, 8, 9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) =>
|
||||
`<option value="${s}"${s === 11 ? " selected" : ""}>${s}</option>`).join("")}
|
||||
</select>
|
||||
<button class="btn-action" id="xs-fsplus" type="button" title="${escapeHtml(t("xlsx.tb_font_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_plus"))}">+</button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-b" type="button" title="${escapeHtml(t("xlsx.format_bold"))}" aria-label="${escapeHtml(t("xlsx.format_bold"))}" aria-pressed="false"><b>B</b></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-i" type="button" title="${escapeHtml(t("xlsx.format_italic"))}" aria-label="${escapeHtml(t("xlsx.format_italic"))}" aria-pressed="false"><i>I</i></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-s" type="button" title="${escapeHtml(t("xlsx.format_strike"))}" aria-label="${escapeHtml(t("xlsx.format_strike"))}" aria-pressed="false"><s>S</s></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-u" type="button" title="${escapeHtml(t("xlsx.format_underline"))}" aria-label="${escapeHtml(t("xlsx.format_underline"))}" aria-pressed="false"><u>U</u></button>
|
||||
${colourPick("xs-fgcolor", t("xlsx.format_font_color"), "font", "xs-fgcolor")}
|
||||
${colourPick("xs-bgcolor", t("xlsx.format_fill_color"), "fill", "xs-bgcolor")}
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="arrange">
|
||||
<button class="btn-action" id="xs-borders" type="button" title="${escapeHtml(t("xlsx.tb_borders"))}" aria-label="${escapeHtml(t("xlsx.tb_borders"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("grid-2x2")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-merge" type="button" title="${escapeHtml(t("xlsx.tb_merge"))}" aria-label="${escapeHtml(t("xlsx.tb_merge"))}">
|
||||
${IC("combine")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-alignh" type="button" title="${escapeHtml(t("xlsx.tb_alignh"))}" aria-label="${escapeHtml(t("xlsx.tb_alignh"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("align-left")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-alignv" type="button" title="${escapeHtml(t("xlsx.tb_alignv"))}" aria-label="${escapeHtml(t("xlsx.tb_alignv"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("align-center-vertical")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-wrap" type="button" title="${escapeHtml(t("xlsx.tb_wrap"))}" aria-label="${escapeHtml(t("xlsx.tb_wrap"))}" aria-pressed="false">
|
||||
${IC("wrap-text")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-rot" type="button" title="${escapeHtml(t("xlsx.tb_rotation"))}" aria-label="${escapeHtml(t("xlsx.tb_rotation"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("rotate-cw")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-more" type="button" title="${escapeHtml(t("xlsx.tb_more"))}" aria-label="${escapeHtml(t("xlsx.tb_more"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("more-vertical")}
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
|
||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||
@@ -55,35 +160,33 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
}
|
||||
if (editable) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
|
||||
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}">
|
||||
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
<i data-lucide="paintbrush" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
// #185 — the ribbon now reads like Google Sheets': flat icon buttons, no
|
||||
// chrome, grouped by thin vertical rules. The gridline switch belongs to it
|
||||
// (the View ▸ Show menu toggles the very same state).
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="grid">
|
||||
<button class="btn-action active" id="xs-grid" type="button" aria-pressed="true"
|
||||
title="${escapeHtml(t("xlsx.mi_show_grid"))}" aria-label="${escapeHtml(t("xlsx.mi_show_grid"))}">
|
||||
${IC("grid-3x3")}
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<span class="export-dropdown xlsx-export-dropdown">
|
||||
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}">
|
||||
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
|
||||
</button>
|
||||
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none">
|
||||
<button class="export-menu-item" type="button" data-xlsx-export="md">${escapeHtml(t("xlsx.export_md"))}</button>
|
||||
<button class="export-menu-item" type="button" data-xlsx-export="html">${escapeHtml(t("xlsx.export_html"))}</button>
|
||||
<button class="export-menu-item" type="button" data-xlsx-export="print">${escapeHtml(t("xlsx.print"))}</button>
|
||||
</div>
|
||||
</span>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||||
</span>`);
|
||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
|
||||
|
||||
return { tabs, actionsHtml, statusBar };
|
||||
// #185 — file outputs moved to the **Fichier** menu of the menu bar
|
||||
// (frontend/js/xlsx/menus.js), which keeps the `#xlsx-download-btn` /
|
||||
// `#xlsx-csv-btn` ids and the `[data-xlsx-export]` markers. Only **Save**
|
||||
// stays on the sheet-tab strip, pinned at its right end.
|
||||
const fileHtml =
|
||||
`<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>`;
|
||||
|
||||
return { tabs, actionsHtml, statusBar, fileHtml };
|
||||
}
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
|
||||
/* ObsiGate — generic themed context menu for the XLSX grid (#155, #179).
|
||||
*
|
||||
* Builds a small menu anchored at a screen position, closes it on outside
|
||||
* click / Escape / window blur. Labels and callbacks come from the caller, so
|
||||
* no i18n lives here.
|
||||
* Builds a small menu anchored at a screen position. Labels, icons and
|
||||
* callbacks come from the caller, so no i18n lives here.
|
||||
*
|
||||
* #179 — every menu entry carries a representative Lucide icon, the menu
|
||||
* closes as soon as the focus leaves it (outside click, focusout, Escape,
|
||||
* window blur, scroll, resize) and arrow keys walk through the entries.
|
||||
* trackDismissable() exposes the same dismissal contract to the other
|
||||
* spreadsheet menus (Structure, Mise en forme, Export), which keep their
|
||||
* own markup.
|
||||
*/
|
||||
import { safeCreateIcons } from '../utils.js';
|
||||
|
||||
let _menu = null;
|
||||
let _openedAt = 0;
|
||||
// Only a touch long-press can emit a click right after the menu opened.
|
||||
let _openedByTouch = false;
|
||||
let _dismissBound = false;
|
||||
|
||||
export function closeContextMenu() {
|
||||
if (_menu) {
|
||||
_menu.remove();
|
||||
_menu = null;
|
||||
}
|
||||
// Drop the reference BEFORE touching the DOM: removing a menu that holds
|
||||
// the focus fires blur/focusout synchronously, which re-enters this very
|
||||
// function — a second remove() on the mid-flight node throws NotFoundError
|
||||
// and aborts the caller's click handler before its action runs.
|
||||
const menu = _menu;
|
||||
_menu = null;
|
||||
if (menu) menu.remove();
|
||||
}
|
||||
|
||||
export function isContextMenuOpen() {
|
||||
@@ -24,25 +37,149 @@ function bindDismiss() {
|
||||
_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;
|
||||
// Ignore the very gesture that opened the menu — but only for a touch
|
||||
// long-press, which is the one that emits a trailing click. A mouse
|
||||
// right-click does not, so a deliberate outside click made right after
|
||||
// must close the menu instead of being swallowed.
|
||||
if (_openedByTouch && 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();
|
||||
});
|
||||
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
|
||||
// #179 — never linger once the focus moves outside the menu (toolbar
|
||||
// button, editor, another panel…). A null relatedTarget (click on a
|
||||
// non-focusable cell) is already covered by the click-outside handler.
|
||||
document.addEventListener("focusout", (e) => {
|
||||
if (!_menu) return;
|
||||
const next = e.relatedTarget;
|
||||
if (next && _menu.contains(next)) return;
|
||||
closeContextMenu();
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("blur", closeContextMenu);
|
||||
// A scroll detaches the viewport-anchored menu from its cell — except the
|
||||
// tail of the smooth scroll that revealed the right-clicked cell (global
|
||||
// `scroll-behavior: smooth` keeps firing after the menu opened).
|
||||
window.addEventListener("scroll", () => {
|
||||
if (Date.now() - _openedAt > 400) 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();
|
||||
};
|
||||
// A re-render can detach the menu without dismissing it: unbind silently
|
||||
// instead of leaking listeners on a dead node.
|
||||
const dropIfDetached = () => {
|
||||
if (!menuEl.isConnected) {
|
||||
if (!done) { done = true; unbind(); }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const onDocClick = (e) => {
|
||||
if (done || dropIfDetached()) return;
|
||||
const t = e.target;
|
||||
if (menuEl.contains(t)) return;
|
||||
if (toggle && (toggle === t || toggle.contains(t))) return;
|
||||
dismiss();
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (dropIfDetached()) return;
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
const onFocusOut = (e) => {
|
||||
if (done || dropIfDetached()) return;
|
||||
const next = e.relatedTarget;
|
||||
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
|
||||
dismiss();
|
||||
};
|
||||
// Toolbar-anchored menus travel WITH their button: a scroll inside the
|
||||
// hosting toolbar (focus scroll-into-view, horizontal scroll, the menu's
|
||||
// own overflow) must not dismiss them — only an outside scroll detaches.
|
||||
// Same grace as the grid menu for the smooth-scroll tail of the opening.
|
||||
// A body-level menu has no travelling anchor: every scroll detaches it.
|
||||
const rawAnchor = (toggle && toggle.closest && toggle.closest(".xlsx-toolbar"))
|
||||
|| menuEl.parentElement
|
||||
|| null;
|
||||
const anchor = rawAnchor && rawAnchor !== document && rawAnchor !== document.body
|
||||
? rawAnchor
|
||||
: null;
|
||||
const openedAt = Date.now();
|
||||
const onScroll = (e) => {
|
||||
if (dropIfDetached()) return;
|
||||
if (Date.now() - openedAt < 400) return;
|
||||
const t = e && e.target;
|
||||
if (t && anchor && (t === anchor || (anchor.contains && anchor.contains(t)))) return;
|
||||
dismiss();
|
||||
};
|
||||
document.addEventListener("click", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("focusout", onFocusOut);
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onScroll);
|
||||
}
|
||||
return dismiss;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x - viewport X of the pointer
|
||||
* @param {number} y - viewport Y of the pointer
|
||||
* @param {Array<{label?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||
* @returns {HTMLElement} the menu element
|
||||
*/
|
||||
export function openContextMenu(x, y, items) {
|
||||
export function openContextMenu(x, y, items, { byTouch = false } = {}) {
|
||||
bindDismiss();
|
||||
closeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
@@ -52,6 +189,7 @@ export function openContextMenu(x, y, items) {
|
||||
if (item.separator) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "xlsx-context-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
menu.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
@@ -59,7 +197,17 @@ export function openContextMenu(x, y, items) {
|
||||
btn.type = "button";
|
||||
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
|
||||
btn.setAttribute("role", "menuitem");
|
||||
btn.textContent = item.label;
|
||||
if (item.icon) {
|
||||
const ic = document.createElement("i");
|
||||
ic.setAttribute("data-lucide", item.icon);
|
||||
ic.className = "xlsx-menu-icon";
|
||||
ic.setAttribute("aria-hidden", "true");
|
||||
btn.appendChild(ic);
|
||||
}
|
||||
const lab = document.createElement("span");
|
||||
lab.className = "xlsx-menu-label";
|
||||
lab.textContent = item.label;
|
||||
btn.appendChild(lab);
|
||||
btn.disabled = Boolean(item.disabled);
|
||||
btn.addEventListener("click", () => {
|
||||
closeContextMenu();
|
||||
@@ -67,6 +215,7 @@ export function openContextMenu(x, y, items) {
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
enableArrowNav(menu, ".xlsx-context-item");
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
|
||||
@@ -81,5 +230,13 @@ export function openContextMenu(x, y, items) {
|
||||
|
||||
_menu = menu;
|
||||
_openedAt = Date.now();
|
||||
_openedByTouch = byTouch;
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
/* ObsiGate — minimal client-side spreadsheet formula engine.
|
||||
*
|
||||
* Evaluates `=…` / `@…` cell content locally (same sheet, plus loaded
|
||||
* cross-sheet panels) so functions work without a round-trip: arithmetic,
|
||||
* comparisons, %, cell/range references and ~30 FR/EN functions.
|
||||
*
|
||||
* Safe by construction: own tokenizer + recursive-descent parser, no eval().
|
||||
* Anything the parser rejects keeps its original rendering (Excel stays the
|
||||
* source of truth on save); evaluation failures surface as Excel-style error
|
||||
* codes instead.
|
||||
*
|
||||
* Limits (documented): same-workbook only, no whole-column (A:A) references,
|
||||
* TODAY()/NOW() refresh on edit/render (no timer), array results show their
|
||||
* top-left value.
|
||||
*/
|
||||
|
||||
const MAX_COL = 18278; // XFD
|
||||
const MAX_ROW = 1048576;
|
||||
|
||||
/** FR aliases accepted next to the EN canonical names. */
|
||||
const ALIASES = {
|
||||
SOMME: "SUM", MOYENNE: "AVERAGE", NB: "COUNT", NBVAL: "COUNTA",
|
||||
PRODUIT: "PRODUCT", MEDIANE: "MEDIAN", ARRONDI: "ROUND", ENT: "INT",
|
||||
RACINE: "SQRT", PUISSANCE: "POWER", SI: "IF", ET: "AND", OU: "OR",
|
||||
NON: "NOT", SIERREUR: "IFERROR", CONCATENER: "CONCAT",
|
||||
MAJUSCULES: "UPPER", MINUSCULES: "LOWER", NBCAR: "LEN",
|
||||
SUPPRESPACE: "TRIM", GAUCHE: "LEFT", DROITE: "RIGHT", STXT: "MID",
|
||||
AUJOURDHUI: "TODAY", MAINTENANT: "NOW", JOUR: "DAY", MOIS: "MONTH",
|
||||
ANNEE: "YEAR", LIEN: "HYPERLINK",
|
||||
};
|
||||
|
||||
const ERRORS = {
|
||||
fr: { DIV: "#DIV/0!", VALUE: "#VALEUR!", NAME: "#NOM?", REF: "#REF!", CYCLE: "#CYCLE!" },
|
||||
en: { DIV: "#DIV/0!", VALUE: "#VALUE!", NAME: "#NAME?", REF: "#REF!", CIRC: "#CIRC!" },
|
||||
};
|
||||
|
||||
export function errorCodes(locale) {
|
||||
return ERRORS[locale === "fr" ? "fr" : "en"];
|
||||
}
|
||||
|
||||
export function isFormulaText(s) {
|
||||
if (typeof s !== "string") return false;
|
||||
const t = s.trim();
|
||||
return t.length > 1 && (t[0] === "=" || t[0] === "@");
|
||||
}
|
||||
|
||||
/** Column letters (no $) → 1-based index, or 0 when invalid. */
|
||||
export function colToIndex(letters) {
|
||||
let n = 0;
|
||||
const up = String(letters || "").toUpperCase();
|
||||
if (!/^[A-Z]{1,3}$/.test(up)) return 0;
|
||||
for (const ch of up) n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
return n > MAX_COL ? 0 : n;
|
||||
}
|
||||
|
||||
export function indexToCol(n) {
|
||||
let s = "";
|
||||
while (n > 0) {
|
||||
const m = (n - 1) % 26;
|
||||
s = String.fromCharCode(65 + m) + s;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Parse "[$]A[$]1" (already stripped of any sheet prefix) → {col,row} | null. */
|
||||
export function parseA1(ref) {
|
||||
const m = /^\$?([A-Za-z]{1,3})\$?([0-9]+)$/.exec(String(ref || "").trim());
|
||||
if (!m) return null;
|
||||
const col = colToIndex(m[1]);
|
||||
const row = Number(m[2]);
|
||||
if (!col || !(row >= 1 && row <= MAX_ROW)) return null;
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
// ── Tokenizer ─────────────────────────────────────────────────────────────
|
||||
|
||||
function tokenize(src, sep) {
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
while (i < n) {
|
||||
const c = src[i];
|
||||
if (isSpace(c)) { i++; continue; }
|
||||
if (c === '"') {
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n) {
|
||||
if (src[j] === '"') {
|
||||
if (src[j + 1] === '"') { out += '"'; j += 2; }
|
||||
else break;
|
||||
} else { out += src[j]; j++; }
|
||||
}
|
||||
if (j >= n) throw new Error("string");
|
||||
tokens.push({ t: "str", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
// Quoted sheet name: 'Mon Sheet'!A1
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n && src[j] !== "'") { out += src[j]; j++; }
|
||||
if (j >= n) throw new Error("sheet");
|
||||
tokens.push({ t: "sheet", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
const numMatch = /^[0-9]+(?:[.,][0-9]+)?/.exec(src.slice(i));
|
||||
if (numMatch && (sep === ";" || !numMatch[0].includes(","))) {
|
||||
tokens.push({ t: "num", v: numMatch[0].replace(",", ".") });
|
||||
i += numMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
const absRef = /^\$?[A-Za-z]{1,3}\$?[0-9]+/.exec(src.slice(i));
|
||||
if (absRef) {
|
||||
tokens.push({ t: "ref", v: absRef[0] });
|
||||
i += absRef[0].length;
|
||||
continue;
|
||||
}
|
||||
const two = src.slice(i, i + 2);
|
||||
if (["<=", ">=", "<>"].includes(two)) {
|
||||
tokens.push({ t: "op", v: two === "<>" ? "!=" : two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ("+-*/^%=()<>,;:&!".includes(c)) {
|
||||
tokens.push({ t: c === "," || c === ";" ? "sep" : "op", v: c });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const word = /^[A-Za-zÀ-ÿ_][A-Za-zÀ-ÿ0-9_.]*/.exec(src.slice(i));
|
||||
if (word) {
|
||||
const w = word[0];
|
||||
// A bare cell reference (A1, $B$12) — but not TRUE/FALSE yet (bools).
|
||||
if (/^\$?[A-Za-z]{1,3}\$?[0-9]+$/.test(w)) {
|
||||
tokens.push({ t: "ref", v: w });
|
||||
} else {
|
||||
const up = w.toUpperCase();
|
||||
if (up === "TRUE" || up === "VRAI") tokens.push({ t: "bool", v: true });
|
||||
else if (up === "FALSE" || up === "FAUX") tokens.push({ t: "bool", v: false });
|
||||
else tokens.push({ t: "word", v: w });
|
||||
}
|
||||
i += w.length;
|
||||
continue;
|
||||
}
|
||||
throw new Error("char:" + c);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── Parser (recursive descent, builds a small AST) ─────────────────────────
|
||||
|
||||
function makeParser(tokens, sep) {
|
||||
let pos = 0;
|
||||
const peek = () => tokens[pos] || null;
|
||||
const next = () => tokens[pos++] || null;
|
||||
const expectOp = (v) => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "op" || t.v !== v) throw new Error("expected " + v);
|
||||
};
|
||||
const expectSep = () => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "sep") throw new Error("expected separator");
|
||||
};
|
||||
|
||||
function parseExpr() { return parseComparison(); }
|
||||
function parseComparison() {
|
||||
let left = parseConcat();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || !["=", "!=", "<", "<=", ">", ">="].includes(t.v)) return left;
|
||||
next();
|
||||
left = [t.v, left, parseConcat()];
|
||||
}
|
||||
}
|
||||
function parseConcat() {
|
||||
let left = parseAdd();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || t.v !== "&") return left;
|
||||
next();
|
||||
left = ["&", left, parseAdd()];
|
||||
}
|
||||
}
|
||||
function parseAdd() {
|
||||
let left = parseMul();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "+" && t.v !== "-")) return left;
|
||||
next();
|
||||
left = [t.v, left, parseMul()];
|
||||
}
|
||||
}
|
||||
function parseMul() {
|
||||
let left = parsePower();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "*" && t.v !== "/")) return left;
|
||||
next();
|
||||
left = [t.v, left, parsePower()];
|
||||
}
|
||||
}
|
||||
// Excel-compatible: a leading sign binds to the base (-2^2 = (-2)^2 = 4),
|
||||
// and to the exponent on the right (2^-2).
|
||||
function parseSigned() {
|
||||
let neg = false;
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && (t.v === "+" || t.v === "-")) {
|
||||
if (t.v === "-") neg = !neg;
|
||||
next();
|
||||
} else break;
|
||||
}
|
||||
const node = parsePostfix();
|
||||
return neg ? ["neg", node] : node;
|
||||
}
|
||||
function parsePower() {
|
||||
const base = parseSigned();
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "^") {
|
||||
next();
|
||||
return ["^", base, parseSigned()];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
function parsePostfix() {
|
||||
let node = parsePrimary();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "%") { next(); node = ["%", node]; }
|
||||
else return node;
|
||||
}
|
||||
}
|
||||
function parsePrimary() {
|
||||
const t = next();
|
||||
if (!t) throw new Error("empty");
|
||||
if (t.t === "num") return ["n", Number(t.v)];
|
||||
if (t.t === "str") return ["s", t.v];
|
||||
if (t.t === "bool") return ["b", t.v];
|
||||
if (t.t === "ref") {
|
||||
const after = peek();
|
||||
if (after && after.t === "op" && after.v === ":") {
|
||||
next();
|
||||
const t2 = next();
|
||||
if (!t2 || t2.t !== "ref") throw new Error("range");
|
||||
return ["range", normRef(t.v), normRef(t2.v)];
|
||||
}
|
||||
return ["ref", normRef(t.v)];
|
||||
}
|
||||
if (t.t === "word" || t.t === "sheet") {
|
||||
const isSheet = t.t === "sheet";
|
||||
let name = t.v;
|
||||
if (!isSheet) {
|
||||
// Unquoted sheet name: Name!A1
|
||||
const bang = peek();
|
||||
if (bang && bang.t === "op" && bang.v === "!") {
|
||||
next();
|
||||
} else {
|
||||
// Function call?
|
||||
const paren = peek();
|
||||
if (paren && paren.t === "op" && paren.v === "(") {
|
||||
next();
|
||||
const args = [];
|
||||
const closing = peek();
|
||||
if (!(closing && closing.t === "op" && closing.v === ")")) {
|
||||
for (;;) {
|
||||
args.push(parseExpr());
|
||||
const s = peek();
|
||||
if (s && s.t === "sep") { next(); continue; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
expectOp(")");
|
||||
return ["call", canonName(name), args];
|
||||
}
|
||||
throw new Error("name:" + name);
|
||||
}
|
||||
} else {
|
||||
const bang = peek();
|
||||
if (!bang || bang.t !== "op" || bang.v !== "!") throw new Error("sheetref");
|
||||
next();
|
||||
}
|
||||
const r1 = next();
|
||||
if (!r1 || r1.t !== "ref") throw new Error("sheetref");
|
||||
const colon = peek();
|
||||
if (colon && colon.t === "op" && colon.v === ":") {
|
||||
next();
|
||||
const r2 = next();
|
||||
if (!r2 || r2.t !== "ref") throw new Error("sheetrange");
|
||||
return ["sheetrange", name, normRef(r1.v), normRef(r2.v)];
|
||||
}
|
||||
return ["sheetref", name, normRef(r1.v)];
|
||||
}
|
||||
if (t.t === "op" && t.v === "(") {
|
||||
const e = parseExpr();
|
||||
expectOp(")");
|
||||
return e;
|
||||
}
|
||||
throw new Error("primary");
|
||||
}
|
||||
function normRef(r) {
|
||||
return r.replace(/\$/g, "").toUpperCase();
|
||||
}
|
||||
function canonName(name) {
|
||||
const up = String(name).toUpperCase();
|
||||
return ALIASES[up] || up;
|
||||
}
|
||||
return { parseExpr, atEnd: () => pos >= tokens.length };
|
||||
}
|
||||
|
||||
export function parseFormula(text) {
|
||||
const body = String(text || "").trim().replace(/^[=@]/, "");
|
||||
const sep = body.includes(";") ? ";" : ",";
|
||||
// A comma is a decimal mark (FR style) when ';' is the separator, or when
|
||||
// the expression holds no letters at all ("=1,5+1" can only mean 1.5 + 1).
|
||||
const commaDecimal = sep === ";" || !/[A-Za-zÀ-ÿ]/.test(body);
|
||||
const { parseExpr, atEnd } = makeParser(tokenize(body, commaDecimal ? ";" : sep), sep);
|
||||
const ast = parseExpr();
|
||||
if (!atEnd()) throw new Error("trailing");
|
||||
return ast;
|
||||
}
|
||||
|
||||
// ── Evaluator ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Engine scope: resolve raw cell values + locale + cycle guard. */
|
||||
export function createScope({ readRaw, sheet, locale = "en", seen = null } = {}) {
|
||||
const scope = {
|
||||
sheet: sheet || "",
|
||||
locale: locale === "fr" ? "fr" : "en",
|
||||
seen: seen || new Set(),
|
||||
memo: new Map(),
|
||||
/** Engine value of `sheet!ref` (formulas recurse, cycles guarded). */
|
||||
getCell(targetSheet, ref) {
|
||||
const key = `${targetSheet || scope.sheet}!${String(ref).toUpperCase()}`;
|
||||
const raw = readRaw ? readRaw(targetSheet || scope.sheet, ref) : null;
|
||||
return evalRefValue(raw, scope, key);
|
||||
},
|
||||
};
|
||||
return scope;
|
||||
}
|
||||
|
||||
const err = (code) => ({ t: "e", code });
|
||||
const isErr = (v) => v && v.t === "e";
|
||||
const isBlank = (v) => !v || v.t === "blank";
|
||||
|
||||
function toNumber(v) {
|
||||
if (!v || v.t === "blank") return 0;
|
||||
if (v.t === "n") return v.v;
|
||||
if (v.t === "b") return v.v ? 1 : 0;
|
||||
if (v.t === "s") {
|
||||
const s = v.v.trim().replace(",", ".");
|
||||
if (s === "") return 0;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toText(v, locale) {
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function numText(n) {
|
||||
if (!Number.isFinite(n)) return String(n);
|
||||
if (Number.isInteger(n) && Math.abs(n) < 1e15) return String(n);
|
||||
return String(parseFloat(n.toPrecision(10)));
|
||||
}
|
||||
|
||||
function flattenArgs(vals) {
|
||||
const out = [];
|
||||
const walk = (v) => {
|
||||
if (v && v.t === "a") v.rows.forEach((r) => r.forEach(walk));
|
||||
else out.push(v);
|
||||
};
|
||||
vals.forEach(walk);
|
||||
return out;
|
||||
}
|
||||
|
||||
function evalRefValue(raw, scope, key) {
|
||||
// Raw storage text → engine value (formulas recurse, cycles guarded).
|
||||
if (raw === null || raw === undefined) return { t: "blank" };
|
||||
if (typeof raw === "number") return { t: "n", v: raw };
|
||||
if (typeof raw === "boolean") return { t: "b", v: raw };
|
||||
const s = String(raw);
|
||||
if (scope.memo.has(key)) return scope.memo.get(key);
|
||||
let out;
|
||||
if (isFormulaText(s)) {
|
||||
if (scope.seen.has(key)) {
|
||||
out = err("CYCLE");
|
||||
} else {
|
||||
scope.seen.add(key);
|
||||
try {
|
||||
out = evaluate(parseFormula(s), scope);
|
||||
} catch (_) {
|
||||
out = err("NAME");
|
||||
}
|
||||
scope.seen.delete(key);
|
||||
}
|
||||
} else if (s.trim() === "") {
|
||||
out = { t: "blank" };
|
||||
} else {
|
||||
const n = Number(s.trim().replace(",", "."));
|
||||
out = s.trim() !== "" && Number.isFinite(n) && /^[-+0-9.,\s]+$/.test(s.trim())
|
||||
? { t: "n", v: n }
|
||||
: { t: "s", v: s };
|
||||
}
|
||||
scope.memo.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function rangeValues(r1, r2, scope) {
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rStart = Math.min(a.row, b.row);
|
||||
const rEnd = Math.max(a.row, b.row);
|
||||
const cStart = Math.min(a.col, b.col);
|
||||
const cEnd = Math.max(a.col, b.col);
|
||||
if ((rEnd - rStart + 1) * (cEnd - cStart + 1) > 100000) return err("VALUE");
|
||||
const rows = [];
|
||||
for (let r = rStart; r <= rEnd; r++) {
|
||||
const line = [];
|
||||
for (let c = cStart; c <= cEnd; c++) {
|
||||
const ref = indexToCol(c) + r;
|
||||
line.push(scope.getCell(scope.sheet, ref));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
|
||||
function firstOf(v) {
|
||||
if (v && v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return cell;
|
||||
}
|
||||
return { t: "blank" };
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function cmpValues(a, b) {
|
||||
// Excel-ish: numbers < text; blank sorts with text "".
|
||||
const rank = (v) => (!v || v.t === "blank" ? 0 : v.t === "n" ? 1 : v.t === "b" ? 2 : 3);
|
||||
const ra = rank(a);
|
||||
const rb = rank(b);
|
||||
if (ra !== rb) return ra - rb;
|
||||
if (ra === 1) return a.v - b.v;
|
||||
if (ra === 2) return (a.v ? 1 : 0) - (b.v ? 1 : 0);
|
||||
const sa = toText(a);
|
||||
const sb = toText(b);
|
||||
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
||||
}
|
||||
|
||||
const FUNCTIONS = {
|
||||
SUM: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc + n;
|
||||
}, 0)),
|
||||
AVERAGE: (vals, scope) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
if (!nums.length) return err("DIV");
|
||||
return num(nums.reduce((a, b) => a + b, 0) / nums.length);
|
||||
},
|
||||
MIN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.min(...nums)) : num(0);
|
||||
},
|
||||
MAX: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.max(...nums)) : num(0);
|
||||
},
|
||||
COUNT: (vals) => num(flattenArgs(vals).filter((v) => v && v.t === "n").length),
|
||||
COUNTA: (vals) => num(flattenArgs(vals).filter((v) => !isBlank(v)).length),
|
||||
PRODUCT: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc * n;
|
||||
}, 1)),
|
||||
MEDIAN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null).sort((a, b) => a - b);
|
||||
if (!nums.length) return err("DIV");
|
||||
const m = Math.floor(nums.length / 2);
|
||||
return num(nums.length % 2 ? nums[m] : (nums[m - 1] + nums[m]) / 2);
|
||||
},
|
||||
ABS: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.abs(n));
|
||||
},
|
||||
ROUND: ([v, d]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
const digits = d === undefined ? 0 : toNumber(firstOf(d));
|
||||
if (n === null || digits === null) return err("VALUE");
|
||||
const f = 10 ** Math.trunc(digits);
|
||||
return num(Math.round(n * f) / f);
|
||||
},
|
||||
INT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.floor(n));
|
||||
},
|
||||
SQRT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
if (n === null) return err("VALUE");
|
||||
if (n < 0) return err("VALUE");
|
||||
return num(Math.sqrt(n));
|
||||
},
|
||||
POWER: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null) return err("VALUE");
|
||||
const r = x ** y;
|
||||
return Number.isFinite(r) ? num(r) : err("VALUE");
|
||||
},
|
||||
MOD: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null || y === 0) return y === 0 ? err("DIV") : err("VALUE");
|
||||
return num(((x % y) + y) % y);
|
||||
},
|
||||
CONCAT: (vals, scope) => ({ t: "s", v: flattenArgs(vals).map((v) => toText(v, scope.locale)).join("") }),
|
||||
UPPER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toUpperCase() }),
|
||||
LOWER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toLowerCase() }),
|
||||
LEN: ([v], scope) => num([...toText(firstOf(v), scope.locale)].length),
|
||||
TRIM: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).trim().replace(/\s+/g, " ") }),
|
||||
LEFT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: s.slice(0, Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
RIGHT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: k <= 0 ? "" : s.slice(-Math.trunc(k)) };
|
||||
},
|
||||
MID: ([v, start, len]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const a = toNumber(firstOf(start));
|
||||
const k = len === undefined ? s.length : toNumber(firstOf(len));
|
||||
if (a === null || k === null || a < 1) return err("VALUE");
|
||||
return { t: "s", v: s.slice(Math.trunc(a) - 1, Math.trunc(a) - 1 + Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
TODAY: () => ({ t: "s", v: new Date().toLocaleDateString() }),
|
||||
NOW: () => ({ t: "s", v: new Date().toLocaleString() }),
|
||||
DAY: ([v]) => datePart(v, "day"),
|
||||
MONTH: ([v]) => datePart(v, "month"),
|
||||
YEAR: ([v]) => datePart(v, "year"),
|
||||
NOT: ([v]) => ({ t: "b", v: !toBool(firstOf(v)) }),
|
||||
HYPERLINK: ([url, label], scope) => {
|
||||
const u = toText(firstOf(url), scope.locale).trim();
|
||||
if (!/^https?:\/\//i.test(u)) return err("VALUE");
|
||||
const text = label === undefined ? u : toText(firstOf(label), scope.locale);
|
||||
return { t: "link", url: u, label: text };
|
||||
},
|
||||
};
|
||||
|
||||
function num(n) {
|
||||
return { t: "n", v: n };
|
||||
}
|
||||
|
||||
function toBool(v) {
|
||||
if (!v || v.t === "blank") return false;
|
||||
if (v.t === "b") return v.v;
|
||||
if (v.t === "n") return v.v !== 0;
|
||||
const s = v.t === "s" ? v.v.trim().toUpperCase() : "";
|
||||
if (["TRUE", "VRAI", "1", "YES", "OUI"].includes(s)) return true;
|
||||
if (["FALSE", "FAUX", "0", "", "NO", "NON"].includes(s)) return false;
|
||||
return s !== "";
|
||||
}
|
||||
|
||||
function datePart(v, part) {
|
||||
const first = firstOf(v);
|
||||
let d = null;
|
||||
if (first && first.t === "s") {
|
||||
const parsed = new Date(first.v);
|
||||
if (!Number.isNaN(parsed.getTime())) d = parsed;
|
||||
} else if (first && first.t === "n") {
|
||||
// Excel serial date (1900 system).
|
||||
const base = Date.UTC(1899, 11, 30);
|
||||
d = new Date(base + Math.trunc(first.v) * 86400000);
|
||||
}
|
||||
if (!d) return err("VALUE");
|
||||
if (part === "day") return num(d.getDate());
|
||||
if (part === "month") return num(d.getMonth() + 1);
|
||||
return num(d.getFullYear());
|
||||
}
|
||||
|
||||
function callFunction(name, argNodes, scope) {
|
||||
if (name === "IF") {
|
||||
if (argNodes.length < 2 || argNodes.length > 3) return err("NAME");
|
||||
const cond = evaluate(argNodes[0], scope);
|
||||
if (isErr(cond)) return cond;
|
||||
const branch = toBool(cond) ? argNodes[1] : argNodes[2];
|
||||
if (!branch) return { t: "b", v: false };
|
||||
return evaluate(branch, scope);
|
||||
}
|
||||
if (name === "AND" || name === "OR") {
|
||||
let out = name === "AND";
|
||||
for (const node of argNodes) {
|
||||
const v = evaluate(node, scope);
|
||||
if (isErr(v)) return v;
|
||||
const b = toBool(v);
|
||||
if (name === "AND" && !b) return { t: "b", v: false };
|
||||
if (name === "OR" && b) return { t: "b", v: true };
|
||||
out = b;
|
||||
}
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
if (name === "IFERROR") {
|
||||
if (!argNodes.length || argNodes.length > 2) return err("NAME");
|
||||
const v = evaluate(argNodes[0], scope);
|
||||
if (!isErr(v)) return v;
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") return v;
|
||||
return argNodes[1] ? evaluate(argNodes[1], scope) : { t: "blank" };
|
||||
}
|
||||
const fn = FUNCTIONS[name];
|
||||
if (!fn) return err("NAME");
|
||||
const vals = argNodes.map((node) => evaluate(node, scope));
|
||||
for (const v of vals) {
|
||||
if (isErr(v)) {
|
||||
// Errors propagate, except through aggregations over ranges.
|
||||
if (["SUM", "AVERAGE", "MIN", "MAX", "COUNT", "COUNTA", "PRODUCT", "MEDIAN", "CONCAT"].includes(name)) {
|
||||
continue;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return fn(vals, scope);
|
||||
}
|
||||
|
||||
export function evaluate(node, scope) {
|
||||
if (Array.isArray(node)) {
|
||||
const [kind, ...rest] = node;
|
||||
switch (kind) {
|
||||
case "n": return { t: "n", v: rest[0] };
|
||||
case "s": return { t: "s", v: rest[0] };
|
||||
case "b": return { t: "b", v: rest[0] };
|
||||
case "neg": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(-n);
|
||||
}
|
||||
case "%": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(n / 100);
|
||||
}
|
||||
case "ref": {
|
||||
const p = parseA1(rest[0]);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(scope.sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "range": return rangeValues(rest[0], rest[1], scope);
|
||||
case "sheetref": {
|
||||
const [, sheet, ref] = node;
|
||||
const p = parseA1(ref);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "sheetrange": {
|
||||
const [, sheet, r1, r2] = node;
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rows = [];
|
||||
for (let r = Math.min(a.row, b.row); r <= Math.max(a.row, b.row); r++) {
|
||||
const line = [];
|
||||
for (let c = Math.min(a.col, b.col); c <= Math.max(a.col, b.col); c++) {
|
||||
line.push(scope.getCell(sheet, indexToCol(c) + r));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
case "call": return callFunction(rest[0], rest[1], scope);
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
case "^":
|
||||
case "&": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
if (kind === "&") return { t: "s", v: toText(l, scope.locale) + toText(r, scope.locale) };
|
||||
const a = toNumber(l);
|
||||
const b = toNumber(r);
|
||||
if (a === null || b === null) return err("VALUE");
|
||||
if (kind === "+") return num(a + b);
|
||||
if (kind === "-") return num(a - b);
|
||||
if (kind === "*") return num(a * b);
|
||||
if (kind === "/") return b === 0 ? err("DIV") : num(a / b);
|
||||
if (kind === "^") {
|
||||
const v = a ** b;
|
||||
return Number.isFinite(v) ? num(v) : err("VALUE");
|
||||
}
|
||||
return err("VALUE");
|
||||
}
|
||||
case "=":
|
||||
case "!=":
|
||||
case "<":
|
||||
case "<=":
|
||||
case ">":
|
||||
case ">=": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
const c = cmpValues(l, r);
|
||||
const out = kind === "=" ? c === 0 : kind === "!=" ? c !== 0
|
||||
: kind === "<" ? c < 0 : kind === "<=" ? c <= 0
|
||||
: kind === ">" ? c > 0 : c >= 0;
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
default:
|
||||
return err("NAME");
|
||||
}
|
||||
}
|
||||
return err("NAME");
|
||||
}
|
||||
|
||||
/** Render an evaluated value for display (localized booleans/errors). */
|
||||
export function displayValue(v, locale = "en") {
|
||||
const codes = errorCodes(locale);
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
if (v.t === "e") {
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") {
|
||||
return locale === "fr" ? codes.CYCLE : codes.CIRC;
|
||||
}
|
||||
return codes[v.code] || codes.VALUE;
|
||||
}
|
||||
if (v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return displayValue(cell, locale);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/* ObsiGate — XLSX menu bar (the ten Google Sheets menus).
|
||||
*
|
||||
* Pure declarative data + a string builder, in the same spirit as
|
||||
* `command-bar.js`: the menu tree lives here and stays testable on its own,
|
||||
* while `viewer.js` only resolves each entry's symbolic `action` against its
|
||||
* own flows. No DOM here beyond `innerHTML` of the shell.
|
||||
*
|
||||
* Entry shape
|
||||
* -----------
|
||||
* { labelKey, icon, action, children, unavailable, danger, needs, sep }
|
||||
* - `labelKey` i18n key of the visible label (never raw text).
|
||||
* - `icon` Lucide icon name, hydrated later by `safeCreateIcons()`.
|
||||
* - `action` symbolic name resolved by the viewer. An entry with no
|
||||
* action — or flagged `unavailable` — is rendered honestly and
|
||||
* reports that ObsiGate does not implement it: a documented
|
||||
* dead end, never a silent no-op.
|
||||
* - `children` a Sheets-style accordion section (Nombre, Thème, Zoom, …):
|
||||
* collapsed, expanded by a click on its parent row.
|
||||
* - `needs` `"editable"` (hide unless the workbook can be written) or
|
||||
* `"notReadOnly"` (hide only for .xls/.ods).
|
||||
* - `sep` a thin rule instead of an entry.
|
||||
*
|
||||
* The Fichier menu keeps the historical `#xlsx-file-btn` / `#xlsx-file-menu` /
|
||||
* `#xlsx-download-btn` / `#xlsx-csv-btn` ids and the `[data-xlsx-export]`
|
||||
* markers: the File menu moved from the sheet strip to the menu bar, but every
|
||||
* consumer (viewer wiring, tests, E2E) keeps working untouched.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
const IC = (name) => `<i data-lucide="${name}" class="xlsx-menu-icon" aria-hidden="true"></i>`;
|
||||
|
||||
/** Google Sheets' menu bar: same order, same entries, same French labels. */
|
||||
export const SHEETS_MENUS = [
|
||||
{
|
||||
id: "file",
|
||||
labelKey: "xlsx.mbar_file",
|
||||
btnId: "xlsx-file-btn",
|
||||
dropId: "xlsx-file-menu",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_new", icon: "file-plus", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_open", icon: "folder-open", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_import", icon: "file-input", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_make_copy", icon: "copy", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_share", icon: "share-2", action: "share" },
|
||||
{ labelKey: "xlsx.mi_email", icon: "mail", unavailable: true },
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_download",
|
||||
icon: "download",
|
||||
children: [
|
||||
{ labelKey: "viewer.download", icon: "download", id: "xlsx-download-btn", action: "download" },
|
||||
{ labelKey: "xlsx.export_csv", icon: "file-spreadsheet", id: "xlsx-csv-btn", action: "export-csv", titleKey: "xlsx.csv_export" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.export_md", icon: "file-text", export: "md", action: "export-md" },
|
||||
{ labelKey: "xlsx.export_html", icon: "code", export: "html", action: "export-html" },
|
||||
{ labelKey: "xlsx.print", icon: "printer", export: "print", action: "print" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_rename", icon: "pencil", action: "rename" },
|
||||
{ labelKey: "xlsx.mi_trash", icon: "trash-2", action: "trash", danger: true },
|
||||
{ labelKey: "xlsx.mi_history", icon: "history", action: "history" },
|
||||
{ labelKey: "xlsx.mi_offline", icon: "cloud-off", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_details", icon: "info", action: "details" },
|
||||
{ labelKey: "xlsx.mi_settings", icon: "settings", action: "settings" },
|
||||
{ labelKey: "xlsx.print", icon: "printer", action: "print" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
labelKey: "xlsx.mbar_edit",
|
||||
items: [
|
||||
{ labelKey: "xlsx.undo", icon: "undo-2", action: "undo", needs: "notReadOnly" },
|
||||
{ labelKey: "xlsx.redo", icon: "redo-2", action: "redo", needs: "notReadOnly" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.cut", icon: "scissors", action: "cut", needs: "editable" },
|
||||
{ labelKey: "xlsx.copy", icon: "copy", action: "copy" },
|
||||
{ labelKey: "xlsx.paste", icon: "clipboard-paste", action: "paste", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_paste_special",
|
||||
icon: "clipboard-paste",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_paste_all", icon: "clipboard-paste", action: "paste" },
|
||||
{ labelKey: "xlsx.mi_paste_values", icon: "pilcrow", action: "paste-values" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_move",
|
||||
icon: "move",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_move_rows", icon: "rows", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_move_cols", icon: "columns", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_delete",
|
||||
icon: "trash-2",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_delete_rows", icon: "rows", action: "delete-rows", danger: true },
|
||||
{ labelKey: "xlsx.mi_delete_cols", icon: "columns", action: "delete-cols", danger: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.clear_contents", icon: "eraser", action: "clear-contents" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_select_all", icon: "check-check", action: "select-all" },
|
||||
{ labelKey: "xlsx.mi_replace", icon: "replace", action: "find-replace", needs: "editable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
labelKey: "xlsx.mbar_view",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_freeze",
|
||||
icon: "snowflake",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_freeze_here", icon: "snowflake", action: "freeze" },
|
||||
{ labelKey: "xlsx.mi_freeze_row", icon: "rows", action: "freeze-row" },
|
||||
{ labelKey: "xlsx.mi_freeze_col", icon: "columns", action: "freeze-col" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.format_unfreeze", icon: "sun", action: "unfreeze" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_show",
|
||||
icon: "eye",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_show_formula", icon: "chevrons-right", action: "toggle-formula-bar" },
|
||||
{ labelKey: "xlsx.mi_show_grid", icon: "grid-3x3", action: "toggle-grid" },
|
||||
{ labelKey: "xlsx.mi_show_headers", icon: "rows-3", action: "toggle-headers" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_show_dashboard", icon: "layout-dashboard", action: "dashboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_group",
|
||||
icon: "group",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_group_rows", icon: "rows", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_group_cols", icon: "columns", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_ungroup_rows", icon: "ungroup", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_ungroup_cols", icon: "ungroup", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_hidden_sheets",
|
||||
icon: "eye-off",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_hide_sheet", icon: "eye-off", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_unhide_sheets", icon: "eye", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_zoom",
|
||||
icon: "zoom-in",
|
||||
children: [50, 75, 90, 100, 125, 150, 200].map((z) => ({
|
||||
labelKey: null,
|
||||
label: `${z} %`,
|
||||
icon: z === 100 ? "crosshair" : null,
|
||||
action: "zoom",
|
||||
arg: z,
|
||||
})),
|
||||
},
|
||||
{ labelKey: "xlsx.mi_fullscreen", icon: "fullscreen", action: "fullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "insert",
|
||||
labelKey: "xlsx.mbar_insert",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_insert_rows",
|
||||
icon: "rows",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.insert_row_above", icon: "arrow-up", action: "insert-rows-above" },
|
||||
{ labelKey: "xlsx.insert_row_below", icon: "arrow-down", action: "insert-rows-below" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_insert_cols",
|
||||
icon: "columns",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.insert_col_left", icon: "arrow-left", action: "insert-cols-left" },
|
||||
{ labelKey: "xlsx.insert_col_right", icon: "arrow-right", action: "insert-cols-right" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_insert_cells", icon: "grid-2x2-plus", action: "insert-cells", needs: "editable" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_image", icon: "image", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_chart", icon: "bar-chart-3", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_drawing", icon: "pen-tool", unavailable: true },
|
||||
{ labelKey: "xlsx.link_add", icon: "link", action: "link", needs: "editable" },
|
||||
{ labelKey: "xlsx.comment_add", icon: "message-square", action: "comment", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_function",
|
||||
icon: "function-square",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_function_hint",
|
||||
action: "function",
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_pivot", icon: "table-2", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_smart_chips", icon: "tags", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_form", icon: "clipboard-list", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
labelKey: "xlsx.mbar_format",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_number",
|
||||
icon: "hash",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_num_general", icon: "type", action: "numfmt", arg: "General" },
|
||||
{ labelKey: "xlsx.format_num_number", icon: "hash", action: "numfmt", arg: "0.00" },
|
||||
{ labelKey: "xlsx.format_num_percent", icon: "percent", action: "numfmt", arg: "0.0%" },
|
||||
{ labelKey: "xlsx.format_num_currency", icon: "euro", action: "numfmt", arg: '#,##0.00 "€"' },
|
||||
{ labelKey: "xlsx.format_num_date", icon: "calendar", action: "numfmt", arg: "DD/MM/YYYY" },
|
||||
{ labelKey: "xlsx.format_num_text", icon: "pilcrow", action: "numfmt", arg: "@" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_theme",
|
||||
icon: "swatch-book",
|
||||
// Filled on demand by the viewer (the theme list is built from
|
||||
// themes.js, so it must not be hard-coded here).
|
||||
children: [],
|
||||
action: "theme-menu",
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.format_bold", icon: "bold", action: "bold", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_italic", icon: "italic", action: "italic", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_underline", icon: "underline", action: "underline", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_strike", icon: "strikethrough", action: "strike", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_font_size",
|
||||
icon: "type",
|
||||
needs: "editable",
|
||||
children: [9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) => ({
|
||||
labelKey: null,
|
||||
label: String(s),
|
||||
icon: null,
|
||||
action: "fontsize",
|
||||
arg: s,
|
||||
})),
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_align",
|
||||
icon: "align-justify",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{
|
||||
labelKey: "xlsx.mi_align_h",
|
||||
icon: "align-left",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_align_left", icon: "align-left", action: "align", arg: "left" },
|
||||
{ labelKey: "xlsx.format_align_center", icon: "align-center", action: "align", arg: "center" },
|
||||
{ labelKey: "xlsx.format_align_right", icon: "align-right", action: "align", arg: "right" },
|
||||
{ labelKey: "xlsx.format_align_justify", icon: "align-justify", action: "align", arg: "justify" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_align_v",
|
||||
icon: "align-center-vertical",
|
||||
children: [
|
||||
{ labelKey: "xlsx.valign_top", icon: "align-start-vertical", action: "valign", arg: "top" },
|
||||
{ labelKey: "xlsx.valign_middle", icon: "align-center-vertical", action: "valign", arg: "middle" },
|
||||
{ labelKey: "xlsx.valign_bottom", icon: "align-end-vertical", action: "valign", arg: "bottom" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_wrap",
|
||||
icon: "wrap-text",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_wrap_on", icon: "wrap-text", action: "wrap-on" },
|
||||
{ labelKey: "xlsx.mi_wrap_off", icon: "remove-formatting", action: "wrap-off" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_rotation",
|
||||
icon: "rotate-cw",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.rotation_0", icon: "minus", action: "rotate", arg: 0 },
|
||||
{ labelKey: "xlsx.rotation_45", icon: "trending-up", action: "rotate", arg: 45 },
|
||||
{ labelKey: "xlsx.rotation_315", icon: "trending-down", action: "rotate", arg: -45 },
|
||||
{ labelKey: "xlsx.rotation_90", icon: "arrow-up", action: "rotate", arg: 90 },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
// Tailles de colonnes / de lignes — même emplacement que Google Sheets
|
||||
// (Format ▸ Redimensionner) et Excel (Format ▸ Colonne / Ligne) :
|
||||
// saisie manuelle (unités Excel : caractères / points) + ajustement
|
||||
// automatique au contenu (AutoFit / « Fit to data »).
|
||||
{
|
||||
labelKey: "xlsx.mi_resize",
|
||||
icon: "move-horizontal",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_col_width", icon: "move-horizontal", action: "col-width" },
|
||||
{ labelKey: "xlsx.format_col_fit", icon: "maximize-2", action: "col-fit" },
|
||||
{ labelKey: "xlsx.format_row_height", icon: "move-vertical", action: "row-height" },
|
||||
{ labelKey: "xlsx.format_row_fit", icon: "maximize-2", action: "row-fit" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_conditional", icon: "git-compare", unavailable: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_banded",
|
||||
icon: "rows-3",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_banded_hint",
|
||||
action: "banded",
|
||||
},
|
||||
{ labelKey: "xlsx.format_clear", icon: "remove-formatting", action: "clear-format", needs: "editable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
labelKey: "xlsx.mbar_data",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_sort",
|
||||
icon: "arrow-up-down",
|
||||
children: [
|
||||
{ labelKey: "xlsx.sort_asc", icon: "arrow-up", action: "sort-asc" },
|
||||
{ labelKey: "xlsx.sort_desc", icon: "arrow-down", action: "sort-desc" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.sort_reset", icon: "rotate-ccw", action: "sort-reset" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_filter", icon: "filter", titleKey: "xlsx.mi_filter_hint", action: "filter-column" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_validation", icon: "list-checks", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_named", icon: "bookmark", action: "named-ranges" },
|
||||
{ labelKey: "xlsx.mi_protect", icon: "lock", unavailable: true },
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_cleanup",
|
||||
icon: "wand-2",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_cleanup_dupes", icon: "copy-x", action: "cleanup-dupes" },
|
||||
{ labelKey: "xlsx.mi_cleanup_trim", icon: "scissors", action: "cleanup-trim" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_split",
|
||||
icon: "columns-2",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_split_hint",
|
||||
action: "split-text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
labelKey: "xlsx.mbar_tools",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_form_create", icon: "clipboard-list", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_spellcheck", icon: "spell-check", unavailable: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_autocomplete",
|
||||
icon: "text-cursor-input",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_autocomplete_on", icon: "check", action: "autocomplete", arg: true },
|
||||
{ labelKey: "xlsx.mi_autocomplete_off", icon: "x", action: "autocomplete", arg: false },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_notifications", icon: "bell", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_macros", icon: "workflow", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_script_editor", icon: "code", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_a11y", icon: "accessibility", action: "a11y" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
labelKey: "xlsx.mbar_gemini",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_ask_gemini", icon: "sparkles", action: "gemini" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_gem_table", icon: "table-2", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_formula", icon: "function-square", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_insights", icon: "lightbulb", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_chart", icon: "bar-chart-3", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_gem_summarize", icon: "file-text", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_gem_actions", icon: "wand-sparkles", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_more", icon: "ellipsis", action: "gemini" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "extensions",
|
||||
labelKey: "xlsx.mbar_extensions",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_addons", icon: "blocks", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_apps_script", icon: "code", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_appsheet", icon: "app-window", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_looker", icon: "bar-chart", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
labelKey: "xlsx.mbar_help",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_help_sheets", icon: "life-buoy", action: "help" },
|
||||
{ labelKey: "xlsx.mi_training", icon: "graduation-cap", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_whatsnew", icon: "sparkles", action: "whatsnew" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_shortcuts", icon: "keyboard", action: "shortcuts" },
|
||||
{ labelKey: "xlsx.mi_report", icon: "bug", action: "report" },
|
||||
{ labelKey: "xlsx.mi_abuse", icon: "flag", unavailable: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Drop an entry when the workbook cannot honour it. */
|
||||
const allowed = (entry, { editable, readOnly }) => {
|
||||
if (entry.needs === "editable") return editable;
|
||||
if (entry.needs === "notReadOnly") return !readOnly;
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Label of one entry: an explicit label, else the i18n key, else nothing. */
|
||||
const labelOf = (entry) => (entry.label !== undefined
|
||||
? entry.label
|
||||
: (entry.labelKey ? t(entry.labelKey) : ""));
|
||||
|
||||
/** One clickable row. `depth` > 0 = nested inside an accordion section. */
|
||||
const rowHtml = (entry, depth, ctx) => {
|
||||
if (entry.sep) return `<div class="xlsx-structure-sep" role="separator"></div>`;
|
||||
const unavailable = entry.unavailable || (!entry.children && !entry.action && !entry.export);
|
||||
const id = entry.id ? ` id="${entry.id}"` : "";
|
||||
const exportAttr = entry.export ? ` data-xlsx-export="${entry.export}"` : "";
|
||||
const argAttr = entry.arg !== undefined ? ` data-xlsx-arg="${escapeHtml(String(entry.arg))}"` : "";
|
||||
const title = entry.titleKey ? ` title="${escapeHtml(t(entry.titleKey))}"` : "";
|
||||
const cls = [
|
||||
"btn-action",
|
||||
"xlsx-structure-item",
|
||||
"xlsx-menu-item",
|
||||
unavailable ? "xlsx-menu-item-na" : "",
|
||||
entry.danger ? "xlsx-context-danger" : "",
|
||||
depth ? `xlsx-menu-item-d${depth}` : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const caret = entry.children
|
||||
? `<i data-lucide="chevron-right" class="xlsx-menu-caret" aria-hidden="true"></i>`
|
||||
: "";
|
||||
const body = entry.children
|
||||
// A section may ALSO declare an action: the viewer runs it instead of
|
||||
// toggling, which is how the empty « Thème » section gets filled.
|
||||
? `<button type="button" class="${cls}" role="menuitem" aria-haspopup="menu" aria-expanded="false"`
|
||||
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}${title}${id}>`
|
||||
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span>${caret}</button>`
|
||||
+ `<div class="xlsx-menu-sub" hidden>${entry.children
|
||||
.filter((c) => allowed(c, ctx))
|
||||
.map((c) => rowHtml(c, depth + 1, ctx))
|
||||
.join("")}</div>`
|
||||
: `<button type="button" class="${cls}" role="menuitem"`
|
||||
+ `${unavailable ? ' data-xlsx-unavailable="1"' : ""}`
|
||||
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}`
|
||||
+ `${argAttr}${exportAttr}${title}${id}>`
|
||||
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span></button>`;
|
||||
return body;
|
||||
};
|
||||
|
||||
/** One dropdown: the filtered rows of a menu. */
|
||||
const menuItemsHtml = (menu, ctx) => menu.items
|
||||
.filter((entry) => allowed(entry, ctx))
|
||||
.map((entry) => rowHtml(entry, 0, ctx))
|
||||
.join("");
|
||||
|
||||
/**
|
||||
* Build the menu bar: one button per menu plus its (hidden) dropdown.
|
||||
*
|
||||
* @param {object} ctx
|
||||
* @param {boolean} ctx.editable - .xlsx that can be written
|
||||
* @param {boolean} ctx.readOnly - .xls / .ods
|
||||
* @returns {string} the `.xlsx-menubar` markup
|
||||
*/
|
||||
export function buildMenuBar({ editable = true, readOnly = false } = {}) {
|
||||
const ctx = { editable, readOnly };
|
||||
const bars = SHEETS_MENUS.map((menu) => {
|
||||
const btnId = menu.btnId || `xlsx-menu-btn-${menu.id}`;
|
||||
const dropId = menu.dropId || `xlsx-menu-${menu.id}`;
|
||||
const label = escapeHtml(t(menu.labelKey));
|
||||
return `<span class="xlsx-menuwrap${menu.id === "gemini" ? " xlsx-menuwrap-gemini" : ""}">`
|
||||
+ `<button type="button" class="xlsx-menubtn" id="${btnId}" role="menuitem"`
|
||||
+ ` aria-haspopup="menu" aria-expanded="false" aria-controls="${dropId}"`
|
||||
+ ` data-xlsx-menu="${menu.id}">${label}</button>`
|
||||
+ `<div class="xlsx-menudrop" id="${dropId}" role="menu"`
|
||||
+ ` aria-label="${label}" style="display:none">`
|
||||
+ `${menuItemsHtml(menu, ctx)}`
|
||||
+ `</div></span>`;
|
||||
});
|
||||
return `<div class="xlsx-menubar" role="menubar" aria-label="${escapeHtml(t("xlsx.mbar_label"))}">`
|
||||
+ `${bars.join("")}</div>`;
|
||||
}
|
||||
@@ -24,9 +24,36 @@ export function columnName(col) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// #184 — cell lookup cache. `findTd` used to run a `querySelector` per call;
|
||||
// painting the grid out to A → Z × 1000 rows means a single "select column"
|
||||
// asks for 26 000 cells, which was quadratic on the DOM. The index is built
|
||||
// once per panel and rebuilt on demand whenever an entry no longer matches its
|
||||
// own `data-cell` (the signature of a renumbered grid after a structure op).
|
||||
const _cellIndex = new WeakMap();
|
||||
|
||||
function buildCellIndex(panel) {
|
||||
const map = new Map();
|
||||
panel.querySelectorAll("td[data-cell]").forEach((td) => {
|
||||
map.set(td.dataset.cell, td);
|
||||
});
|
||||
_cellIndex.set(panel, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Drop a panel's lookup cache (bulk DOM changes). */
|
||||
export function invalidateCellIndex(panel) {
|
||||
if (panel) _cellIndex.delete(panel);
|
||||
}
|
||||
|
||||
/** Find a rendered cell inside a sheet panel by (row, col). */
|
||||
export function findTd(panel, row, col) {
|
||||
return panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||||
const ref = `${columnName(col)}${row}`;
|
||||
const cached = _cellIndex.get(panel);
|
||||
const hit = cached && cached.get(ref);
|
||||
if (hit && hit.dataset.cell === ref) return hit;
|
||||
// Miss, or a stale entry (the row was renumbered): rebuild once and retry.
|
||||
const fresh = buildCellIndex(panel).get(ref);
|
||||
return fresh && fresh.dataset.cell === ref ? fresh : null;
|
||||
}
|
||||
|
||||
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
|
||||
|
||||
+261
-8
@@ -90,8 +90,6 @@
|
||||
"ai.confirm_actions": "{count} actions to approve",
|
||||
"ai.stop": "Stop the assistant",
|
||||
"ai.stopped": "Run stopped.",
|
||||
"ai.agent_mode_off": "Agent mode off (read/search + actions)",
|
||||
"ai.agent_mode_on": "Agent mode on (read/search tools + actions)",
|
||||
"ai.casual": "Casual tone",
|
||||
"ai.close": "Close assistant",
|
||||
"ai.completion_added": "AI: completion added",
|
||||
@@ -512,6 +510,13 @@
|
||||
"config.section_format-du-payload": "Format du payload",
|
||||
"config.section_gestion-des-onglets": "📑 Gestion des onglets",
|
||||
"config.section_hidden": "🗂️ Hidden files",
|
||||
"config.section_desktop": "🖥️ Vaults & folders (Desktop)",
|
||||
"config.desktop_roots_desc": "Add or remove the vaults and root folders loaded by the desktop application (local config removal only — no file is deleted).",
|
||||
"desktop.remove_root": "Remove from app",
|
||||
"desktop.remove_root_confirm": "Remove “{name}” from ObsiGate Desktop? No files will be deleted.",
|
||||
"desktop.add_vault": "📁 Add a vault",
|
||||
"desktop.add_dir": "📂 Add a folder",
|
||||
"desktop.roots_empty": "No vault or folder configured.",
|
||||
"config.section_historique-recent-redemarrage-non-requis": "📋 Recent History\n No restart required",
|
||||
"config.section_indicateurs-visuels": "Indicateurs visuels",
|
||||
"config.section_intelligence-artificielle-dans-l-editeur": "🤖 AI in the Editor",
|
||||
@@ -999,7 +1004,7 @@
|
||||
"help.desc_8f0bffe3": "JWT tokens",
|
||||
"help.desc_9186a3a3": "Explorez l'arborescence : Naviguez dans les dossiers",
|
||||
"help.desc_93902d18": "Utilisez les tags : Filtrez par tags pour affiner",
|
||||
"help.desc_9846fc07": "In the",
|
||||
"help.desc_9846fc07": ": Masks JWTs, passwords and API keys (OpenAI, Anthropic, GitHub, Google, AWS, Slack, Stripe, GitLab, Hugging Face…) in markdown previews — click a mask to copy the value to the clipboard",
|
||||
"help.desc_985d759b": "Token unique : 64 caractères hexadécimaux, impossible à deviner",
|
||||
"help.desc_991307d6": " — Generate content from the selection:",
|
||||
"help.desc_995a711f": ": Opens the CodeMirror editor",
|
||||
@@ -1358,7 +1363,7 @@
|
||||
"help.assistant_insert": "The \"Add\" button (revealed on hover of an answer) inserts the answer into the document open in the editor (Editer or Forge); each code block offers \"Add section\" to insert just that block.",
|
||||
"help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.",
|
||||
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
|
||||
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
|
||||
"help.assistant_agent": "The assistant always uses its tools (read, list, search, modify); modifying actions require confirmation with a change preview.",
|
||||
"help.assistant_agent_run": "Actions appear in the thread: the assistant groups modifications into a single approval (\"Approve all\") and refreshes the file tree and the open document as soon as they are applied. The send button becomes \"Stop\" to interrupt the run at any time.",
|
||||
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
|
||||
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
|
||||
@@ -1456,6 +1461,16 @@
|
||||
"modal.delete_permanently": "Delete permanently",
|
||||
"modal.delete_warning": "Warning!",
|
||||
"modal.dir_to_delete": "Folder to delete:",
|
||||
"modal.ext_excalidraw": "Excalidraw (.excalidraw)",
|
||||
"modal.ext_js": "JavaScript (.js)",
|
||||
"modal.ext_json": "JSON (.json)",
|
||||
"modal.ext_md": "Markdown (.md)",
|
||||
"modal.ext_ps1": "PowerShell (.ps1)",
|
||||
"modal.ext_py": "Python (.py)",
|
||||
"modal.ext_sh": "Shell (.sh)",
|
||||
"modal.ext_txt": "Plain text (.txt)",
|
||||
"modal.ext_xlsx": "Excel (.xlsx)",
|
||||
"modal.ext_yaml": "YAML (.yaml)",
|
||||
"modal.file_name": "File name",
|
||||
"modal.file_to_delete": "File to delete:",
|
||||
"modal.file_type": "File type",
|
||||
@@ -1842,8 +1857,8 @@
|
||||
"xlsx.tabs_add_sheet": "Add a sheet",
|
||||
"xlsx.readonly_badge": "Read-only",
|
||||
"xlsx.readonly_hint": "This format (.xls / .ods) cannot be edited in ObsiGate — convert it to .xlsx to edit.",
|
||||
"xlsx.formulas_note": "Formulas not recalculated",
|
||||
"xlsx.formulas_note_title": "ObsiGate shows the formula as stored: Excel recalculates it on open. Dependent cells do not refresh on screen.",
|
||||
"xlsx.formulas_note": "Formulas computed locally",
|
||||
"xlsx.formulas_note_title": "ObsiGate computes common formulas on screen (arithmetic, SUM, AVERAGE, IF…). Unsupported functions show the formula as stored: Excel recalculates it on open.",
|
||||
"xlsx.truncated_title": "Truncated sheet",
|
||||
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
|
||||
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
|
||||
@@ -1871,8 +1886,31 @@
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
|
||||
"xlsx.csv_export": "Export the sheet or the selection as CSV",
|
||||
"xlsx.colour_panel_font": "Text colour",
|
||||
"xlsx.colour_panel_fill": "Fill colour",
|
||||
"xlsx.colour_palette": "Colour palette",
|
||||
"xlsx.colour_reset": "Reset",
|
||||
"xlsx.colour_reset_font": "Reset the text colour",
|
||||
"xlsx.colour_reset_fill": "Reset the fill colour",
|
||||
"xlsx.colour_standard": "Standard",
|
||||
"xlsx.colour_custom": "Custom",
|
||||
"xlsx.colour_edit_standard": "Pick a custom colour",
|
||||
"xlsx.colour_custom_pick": "Add a custom colour",
|
||||
"xlsx.colour_conditional": "Conditional formatting",
|
||||
"xlsx.colour_banded": "Banded colours",
|
||||
"xlsx.colour_unavailable": "“{feature}” is not available in ObsiGate yet.",
|
||||
"xlsx.colour_black": "Black",
|
||||
"xlsx.colour_white": "White",
|
||||
"xlsx.colour_blue": "Blue",
|
||||
"xlsx.colour_red": "Red",
|
||||
"xlsx.colour_yellow": "Yellow",
|
||||
"xlsx.colour_green": "Green",
|
||||
"xlsx.colour_orange": "Orange",
|
||||
"xlsx.colour_cyan": "Cyan",
|
||||
"xlsx.export_btn": "Export",
|
||||
"xlsx.export_csv": "Export as CSV",
|
||||
"xlsx.export_title": "Export the selection or the sheet",
|
||||
"xlsx.file_menu": "File",
|
||||
"xlsx.export_md": "Export as Markdown",
|
||||
"xlsx.export_html": "Export as HTML",
|
||||
"xlsx.print": "Print",
|
||||
@@ -1880,6 +1918,47 @@
|
||||
"xlsx.export_scope_selection": "the selection",
|
||||
"xlsx.export_done": "Exported {scope}",
|
||||
"xlsx.format_btn": "Formatting",
|
||||
"xlsx.format_strike": "Strikethrough",
|
||||
"xlsx.format_align_justify": "Justified",
|
||||
"xlsx.tb_search": "Search in sheet",
|
||||
"xlsx.tb_paint": "Paint format",
|
||||
"xlsx.paint_pick_target": "Click the target cell or range",
|
||||
"xlsx.tb_zoom": "Zoom",
|
||||
"xlsx.tb_currency": "Currency format",
|
||||
"xlsx.tb_percent": "Percent format",
|
||||
"xlsx.tb_dec_minus": "Decrease decimals",
|
||||
"xlsx.tb_dec_plus": "Increase decimals",
|
||||
"xlsx.tb_number": "Number format",
|
||||
"xlsx.tb_numfmt": "Number format",
|
||||
"xlsx.tb_font_minus": "Decrease font size",
|
||||
"xlsx.tb_font_plus": "Increase font size",
|
||||
"xlsx.tb_font_size": "Font size",
|
||||
"xlsx.tb_borders": "Borders",
|
||||
"xlsx.border_all": "All borders",
|
||||
"xlsx.border_outer": "Outer borders",
|
||||
"xlsx.border_none": "No borders",
|
||||
"xlsx.tb_merge": "Merge cells",
|
||||
"xlsx.tb_alignh": "Horizontal alignment",
|
||||
"xlsx.tb_alignv": "Vertical alignment",
|
||||
"xlsx.valign_top": "Top",
|
||||
"xlsx.valign_middle": "Middle",
|
||||
"xlsx.valign_bottom": "Bottom",
|
||||
"xlsx.tb_wrap": "Text wrapping",
|
||||
"xlsx.tb_rotation": "Text rotation",
|
||||
"xlsx.rotation_0": "Horizontal",
|
||||
"xlsx.rotation_45": "Diagonal up",
|
||||
"xlsx.rotation_315": "Diagonal down",
|
||||
"xlsx.rotation_90": "Vertical",
|
||||
"xlsx.tb_more": "More options",
|
||||
"xlsx.comment_add": "Comment",
|
||||
"xlsx.comment_prompt": "Comment text (empty to delete):",
|
||||
"xlsx.link_add": "Insert link",
|
||||
"xlsx.link_url": "Link address (https://…):",
|
||||
"xlsx.link_label": "Label:",
|
||||
"xlsx.link_invalid": "Invalid address (https://… expected)",
|
||||
"xlsx.sum_insert": "Sum",
|
||||
"xlsx.sum_empty": "Nothing to total (numbers above or a selection)",
|
||||
"xlsx.paint_pick_source": "Select a formatted source cell first",
|
||||
"xlsx.format_bold": "Bold",
|
||||
"xlsx.format_italic": "Italic",
|
||||
"xlsx.format_underline": "Underline",
|
||||
@@ -1901,9 +1980,13 @@
|
||||
"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_col_prompt": "Width of column {col} — in characters (0 to 255, 0 = default width)",
|
||||
"xlsx.format_row_height": "Row height…",
|
||||
"xlsx.format_row_prompt": "Height of row {row} (0 to 409)",
|
||||
"xlsx.format_row_prompt": "Height of row {row} — in points (0 to 409, 0 = default height)",
|
||||
"xlsx.format_col_fit": "Fit column width to data",
|
||||
"xlsx.format_row_fit": "Fit row height to data",
|
||||
"xlsx.mi_resize": "Resize",
|
||||
"xlsx.fit_nothing": "Nothing to fit (empty content or already fitted)",
|
||||
"xlsx.format_applied": "Formatting saved",
|
||||
"xlsx.format_error": "Formatting failed",
|
||||
"xlsx.sort_asc": "Sort column A→Z",
|
||||
@@ -1964,6 +2047,166 @@
|
||||
"xlsx.feature_signature": "digital signature",
|
||||
"xlsx.feature_rich_comments": "rich comments",
|
||||
"xlsx.feature_macros": "macros",
|
||||
|
||||
"xlsx.mbar_label": "Spreadsheet menus",
|
||||
"xlsx.cmdbar_label": "Spreadsheet command bar",
|
||||
"xlsx.mbar_file": "File",
|
||||
"xlsx.mbar_edit": "Edit",
|
||||
"xlsx.mbar_view": "View",
|
||||
"xlsx.mbar_insert": "Insert",
|
||||
"xlsx.mbar_format": "Format",
|
||||
"xlsx.mbar_data": "Data",
|
||||
"xlsx.mbar_tools": "Tools",
|
||||
"xlsx.mbar_gemini": "Gemini",
|
||||
"xlsx.mbar_extensions": "Extensions",
|
||||
"xlsx.mbar_help": "Help",
|
||||
"xlsx.menu_unavailable": "“{item}” is not available in ObsiGate.",
|
||||
"xlsx.menu_unavailable_hint": "This option does not exist in ObsiGate's spreadsheet editor: it is listed to stay faithful to Google Sheets.",
|
||||
|
||||
"xlsx.mi_new": "New",
|
||||
"xlsx.mi_open": "Open",
|
||||
"xlsx.mi_import": "Import",
|
||||
"xlsx.mi_make_copy": "Make a copy",
|
||||
"xlsx.mi_share": "Share",
|
||||
"xlsx.mi_email": "Email",
|
||||
"xlsx.mi_download": "Download",
|
||||
"xlsx.mi_rename": "Rename",
|
||||
"xlsx.mi_trash": "Move to trash",
|
||||
"xlsx.mi_history": "Version history",
|
||||
"xlsx.mi_offline": "Make available offline",
|
||||
"xlsx.mi_details": "Details",
|
||||
"xlsx.mi_settings": "Settings",
|
||||
|
||||
"xlsx.mi_paste_special": "Paste special",
|
||||
"xlsx.mi_paste_all": "Paste all",
|
||||
"xlsx.mi_paste_values": "Paste values only",
|
||||
"xlsx.mi_move": "Move",
|
||||
"xlsx.mi_move_rows": "Selected rows down",
|
||||
"xlsx.mi_move_cols": "Selected columns right",
|
||||
"xlsx.mi_delete": "Delete",
|
||||
"xlsx.mi_delete_rows": "Delete selected rows",
|
||||
"xlsx.mi_delete_cols": "Delete selected columns",
|
||||
"xlsx.mi_select_all": "Select all",
|
||||
"xlsx.mi_replace": "Find and replace",
|
||||
"xlsx.mi_replace_find": "Find",
|
||||
"xlsx.mi_replace_with": "Replace with",
|
||||
"xlsx.mi_replace_one": "Replace",
|
||||
"xlsx.mi_replace_all": "Replace all",
|
||||
"xlsx.mi_replace_none": "No match found",
|
||||
"xlsx.mi_replace_done": "{n} cell(s) replaced",
|
||||
|
||||
"xlsx.mi_freeze": "Freeze",
|
||||
"xlsx.mi_freeze_here": "Freeze up to active cell",
|
||||
"xlsx.mi_freeze_row": "Freeze active row",
|
||||
"xlsx.mi_freeze_col": "Freeze active column",
|
||||
"xlsx.mi_show": "Show",
|
||||
"xlsx.mi_show_formula": "Formula bar",
|
||||
"xlsx.mi_show_grid": "Gridlines",
|
||||
"xlsx.mi_show_headers": "Headers",
|
||||
"xlsx.mi_show_dashboard": "Dashboard",
|
||||
"xlsx.mi_group": "Group",
|
||||
"xlsx.mi_group_rows": "Group selected rows",
|
||||
"xlsx.mi_group_cols": "Group selected columns",
|
||||
"xlsx.mi_ungroup_rows": "Ungroup rows",
|
||||
"xlsx.mi_ungroup_cols": "Ungroup columns",
|
||||
"xlsx.mi_hidden_sheets": "Hidden sheets",
|
||||
"xlsx.mi_hide_sheet": "Hide active sheet",
|
||||
"xlsx.mi_unhide_sheets": "Show hidden sheets",
|
||||
"xlsx.mi_zoom": "Zoom",
|
||||
"xlsx.mi_fullscreen": "Full screen",
|
||||
"xlsx.mi_exit_fullscreen": "Exit full screen",
|
||||
|
||||
"xlsx.mi_insert_rows": "Rows",
|
||||
"xlsx.mi_insert_cols": "Columns",
|
||||
"xlsx.mi_insert_cells": "Cells",
|
||||
"xlsx.mi_image": "Image",
|
||||
"xlsx.mi_chart": "Chart",
|
||||
"xlsx.mi_drawing": "Drawing",
|
||||
"xlsx.mi_function": "Function",
|
||||
"xlsx.mi_function_hint": "Inserts the function and puts the caret inside the parentheses",
|
||||
"xlsx.mi_pivot": "Pivot table",
|
||||
"xlsx.mi_smart_chips": "Smart chips",
|
||||
"xlsx.mi_form": "Form",
|
||||
|
||||
"xlsx.mi_number": "Number",
|
||||
"xlsx.mi_theme": "Theme",
|
||||
"xlsx.mi_font_size": "Font size",
|
||||
"xlsx.mi_align": "Alignment",
|
||||
"xlsx.mi_align_h": "Horizontal alignment",
|
||||
"xlsx.mi_align_v": "Vertical alignment",
|
||||
"xlsx.mi_wrap": "Text wrapping",
|
||||
"xlsx.mi_wrap_on": "On",
|
||||
"xlsx.mi_wrap_off": "Off",
|
||||
"xlsx.mi_rotation": "Text rotation",
|
||||
"xlsx.mi_conditional": "Conditional formatting",
|
||||
"xlsx.mi_banded": "Banded colours",
|
||||
"xlsx.mi_banded_hint": "Alternates the fill colour every other row in the selection",
|
||||
"xlsx.mi_banded_done": "Banded colours applied to {n} row(s)",
|
||||
"xlsx.mi_banded_default": "#eef3fb",
|
||||
|
||||
"xlsx.mi_sort": "Sort range",
|
||||
"xlsx.mi_filter": "Create filter",
|
||||
"xlsx.mi_filter_hint": "Filters the rows on the active cell's value",
|
||||
"xlsx.mi_validation": "Data validation",
|
||||
"xlsx.mi_named": "Named ranges",
|
||||
"xlsx.mi_protect": "Protect sheets and ranges",
|
||||
"xlsx.mi_cleanup": "Data cleanup",
|
||||
"xlsx.mi_cleanup_dupes": "Remove duplicates",
|
||||
"xlsx.mi_cleanup_trim": "Trim extra spaces",
|
||||
"xlsx.mi_cleanup_dupes_none": "No duplicate in the selection",
|
||||
"xlsx.mi_cleanup_dupes_done": "{n} duplicate row(s) removed",
|
||||
"xlsx.mi_cleanup_trim_done": "{n} cell(s) cleaned",
|
||||
"xlsx.mi_split": "Split text to columns",
|
||||
"xlsx.mi_split_hint": "Splits the active cell's content on a separator",
|
||||
"xlsx.mi_split_sep": "Separator",
|
||||
"xlsx.mi_split_done": "{n} column(s) filled from the active cell",
|
||||
|
||||
"xlsx.mi_form_create": "Create a form",
|
||||
"xlsx.mi_spellcheck": "Spelling",
|
||||
"xlsx.mi_autocomplete": "Autocomplete",
|
||||
"xlsx.mi_autocomplete_on": "Autocomplete on",
|
||||
"xlsx.mi_autocomplete_off": "Autocomplete off",
|
||||
"xlsx.mi_notifications": "Notification rules",
|
||||
"xlsx.mi_macros": "Macros",
|
||||
"xlsx.mi_script_editor": "Script editor",
|
||||
"xlsx.mi_a11y": "Accessibility",
|
||||
"xlsx.mi_a11y_on": "Enhanced accessibility on (bigger cells, higher-contrast grid)",
|
||||
"xlsx.mi_a11y_off": "Enhanced accessibility off",
|
||||
|
||||
"xlsx.mi_ask_gemini": "Ask Gemini",
|
||||
"xlsx.mi_gem_table": "Create tables",
|
||||
"xlsx.mi_gem_formula": "Create formulas",
|
||||
"xlsx.mi_gem_insights": "Analysis and insights",
|
||||
"xlsx.mi_gem_chart": "Create charts",
|
||||
"xlsx.mi_gem_summarize": "Summarize emails and files",
|
||||
"xlsx.mi_gem_actions": "Perform actions",
|
||||
"xlsx.mi_gem_more": "More options",
|
||||
|
||||
"xlsx.mi_addons": "Add-ons",
|
||||
"xlsx.mi_apps_script": "Apps Script",
|
||||
"xlsx.mi_appsheet": "AppSheet",
|
||||
"xlsx.mi_looker": "Looker Studio",
|
||||
|
||||
"xlsx.mi_help_sheets": "Spreadsheets help",
|
||||
"xlsx.mi_training": "Training",
|
||||
"xlsx.mi_whatsnew": "What's new",
|
||||
"xlsx.mi_shortcuts": "Keyboard shortcuts",
|
||||
"xlsx.mi_report": "Report a problem",
|
||||
"xlsx.mi_abuse": "Report abuse",
|
||||
|
||||
"xlsx.details_title": "File details",
|
||||
"xlsx.details_body": "Vault: {vault}\nFile: {path}\nSheets: {sheets}\nDisplayed cells: {cells}\nLast revision: {revision}\nUnsaved changes: {dirty}",
|
||||
"xlsx.shortcuts_title": "Keyboard shortcuts",
|
||||
"xlsx.shortcuts_body": "Ctrl/Cmd + Z — Undo\nCtrl/Cmd + Y — Redo\nCtrl/Cmd + C / X / V — Copy / Cut / Paste\nCtrl/Cmd + A — Select all\nCtrl/Cmd + F — Search in sheet\nEnter — Commit and move down\nF2 — Edit cell\nAlt + arrow — Line break inside the cell\nEscape — Leave edit mode\nDelete — Clear the selection\nDouble-click — Edit in place\nShift + click — Extend selection",
|
||||
"xlsx.version_title": "What's new",
|
||||
"xlsx.version_body": "ObsiGate {version} — {date}\n{status}",
|
||||
"xlsx.report_copied": "Diagnostic copied to the clipboard.",
|
||||
"xlsx.themes_title": "Sheet theme",
|
||||
"xlsx.function_prompt": "Function to insert",
|
||||
"xlsx.rename_prompt": "New file name",
|
||||
"xlsx.trash_confirm": "Move “{name}” to trash? The workbook will close.",
|
||||
"xlsx.rename_done": "File renamed to {name}",
|
||||
"xlsx.trash_done": "File moved to trash",
|
||||
"viewer.download_md": "Download as .md",
|
||||
"viewer.download_file": "Download file",
|
||||
"viewer.pretty": "Pretty",
|
||||
@@ -1978,6 +2221,9 @@
|
||||
"viewer.export_html": "Export as HTML",
|
||||
"viewer.export_md_bundle": "Export as Markdown bundle (.zip)",
|
||||
"viewer.export_start": "Exporting...",
|
||||
"viewer.secret_copy_hint": "Click to copy the value",
|
||||
"viewer.secret_copied": "Value copied to the clipboard",
|
||||
"viewer.secret_copy_failed": "Copy failed",
|
||||
"viewer.export_title": "Export document",
|
||||
"viewer.forge_brand": "Forge",
|
||||
"viewer.forge_title": "Forge (new editor)",
|
||||
@@ -2138,6 +2384,13 @@
|
||||
"ai.step.docx_create": "Word document proposed: {value}",
|
||||
"ai.step.csv_create": "CSV file proposed: {value}",
|
||||
"ai.step.pdf_create": "PDF document proposed: {value}",
|
||||
"ai.step.duplicates": "Searched duplicates: {value}",
|
||||
"ai.step.duplicates_merge": "Merged notes: {value}",
|
||||
"ai.step.notify": "Notification sent: {value}",
|
||||
"ai.step.schedule_create": "Scheduled task: {value}",
|
||||
"ai.step.schedule_list": "Listed scheduled tasks",
|
||||
"ai.step.schedule_delete": "Deleted task: {value}",
|
||||
"ai.step.schedule_run": "Ran task: {value}",
|
||||
"bookslm.copied": "Copied to clipboard",
|
||||
"bookslm.error": "AI service error",
|
||||
"bookslm.regenerate": "Regenerate",
|
||||
|
||||
+261
-8
@@ -90,8 +90,6 @@
|
||||
"ai.confirm_actions": "{count} actions à approuver",
|
||||
"ai.stop": "Arrêter l'assistant",
|
||||
"ai.stopped": "Exécution arrêtée.",
|
||||
"ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)",
|
||||
"ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)",
|
||||
"ai.casual": "Ton décontracté",
|
||||
"ai.close": "Fermer l'assistant",
|
||||
"ai.completion_added": "AI: complétion ajoutée",
|
||||
@@ -512,6 +510,13 @@
|
||||
"config.section_format-du-payload": "Format du payload",
|
||||
"config.section_gestion-des-onglets": "📑 Gestion des onglets",
|
||||
"config.section_hidden": "🗂️ Fichiers cachés",
|
||||
"config.section_desktop": "🖥️ Vaults & dossiers (Desktop)",
|
||||
"config.desktop_roots_desc": "Ajoutez ou retirez les vaults et dossiers racine chargés par l'application desktop (retrait local de la configuration, aucun fichier supprimé).",
|
||||
"desktop.remove_root": "Retirer de l'application",
|
||||
"desktop.remove_root_confirm": "Retirer « {name} » de l'application ObsiGate Desktop ? Les fichiers ne seront pas supprimés.",
|
||||
"desktop.add_vault": "📁 Ajouter un vault",
|
||||
"desktop.add_dir": "📂 Ajouter un dossier",
|
||||
"desktop.roots_empty": "Aucun vault ou dossier configuré.",
|
||||
"config.section_historique-recent-redemarrage-non-requis": "📋 Historique récent\n Redémarrage non requis",
|
||||
"config.section_indicateurs-visuels": "Indicateurs visuels",
|
||||
"config.section_intelligence-artificielle-dans-l-editeur": "🤖 Intelligence Artificielle dans l'Éditeur",
|
||||
@@ -999,7 +1004,7 @@
|
||||
"help.desc_8f0bffe3": ": Tokens JWT\n avec mots de passe hachés Argon2id",
|
||||
"help.desc_9186a3a3": "Explorez l'arborescence :\n Naviguez dans les dossiers",
|
||||
"help.desc_93902d18": "Utilisez les tags : Filtrez\n par tags pour affiner",
|
||||
"help.desc_9846fc07": ": Masque\n JWT, clés API, tokens GitHub dans les\n aperçus",
|
||||
"help.desc_9846fc07": ": Masque JWT, mots de passe et clés API (OpenAI, Anthropic, GitHub, Google, AWS, Slack, Stripe, GitLab, Hugging Face…) dans les aperçus markdown ; cliquez sur un masque pour copier la valeur dans le presse-papiers",
|
||||
"help.desc_985d759b": "Token unique : 64\n caractères hexadécimaux, impossible à\n deviner",
|
||||
"help.desc_991307d6": "— Génère du\n contenu à partir de la sélection :",
|
||||
"help.desc_995a711f": ": Ouvre l'éditeur\n CodeMirror",
|
||||
@@ -1358,7 +1363,7 @@
|
||||
"help.assistant_insert": "Le bouton « Ajouter » (au survol d'une réponse) insère la réponse dans le document ouvert dans l'éditeur (Editer ou Forge) ; chaque bloc de code propose « Ajouter la section » pour n'insérer que ce bloc.",
|
||||
"help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.",
|
||||
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
|
||||
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
|
||||
"help.assistant_agent": "L'assistant utilise toujours ses outils (lire, lister, chercher, modifier) ; les actions de modification demandent une confirmation avec aperçu des changements.",
|
||||
"help.assistant_agent_run": "Les actions s'affichent dans le fil : l'assistant regroupe les modifications en une seule approbation (« Tout approuver ») et met à jour l'arborescence et le document ouvert dès qu'elles sont appliquées. Le bouton d'envoi devient « Stop » pour interrompre l'exécution à tout moment.",
|
||||
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
|
||||
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
|
||||
@@ -1456,6 +1461,16 @@
|
||||
"modal.delete_permanently": "Supprimer définitivement",
|
||||
"modal.delete_warning": "Attention !",
|
||||
"modal.dir_to_delete": "Dossier à supprimer:",
|
||||
"modal.ext_excalidraw": "Excalidraw (.excalidraw)",
|
||||
"modal.ext_js": "JavaScript (.js)",
|
||||
"modal.ext_json": "JSON (.json)",
|
||||
"modal.ext_md": "Markdown (.md)",
|
||||
"modal.ext_ps1": "PowerShell (.ps1)",
|
||||
"modal.ext_py": "Python (.py)",
|
||||
"modal.ext_sh": "Shell (.sh)",
|
||||
"modal.ext_txt": "Texte (.txt)",
|
||||
"modal.ext_xlsx": "Excel (.xlsx)",
|
||||
"modal.ext_yaml": "YAML (.yaml)",
|
||||
"modal.file_name": "Nom du fichier",
|
||||
"modal.file_to_delete": "Fichier à supprimer:",
|
||||
"modal.file_type": "Type de fichier",
|
||||
@@ -1842,8 +1857,8 @@
|
||||
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
||||
"xlsx.readonly_badge": "Lecture seule",
|
||||
"xlsx.readonly_hint": "Ce format (.xls / .ods) n'est pas modifiable dans ObsiGate — convertissez-le en .xlsx pour l'éditer.",
|
||||
"xlsx.formulas_note": "Formules non recalculées",
|
||||
"xlsx.formulas_note_title": "ObsiGate affiche la formule telle qu'elle est enregistrée : Excel la recalcule à l'ouverture. Les cellules dépendantes ne se rafraîchissent pas à l'écran.",
|
||||
"xlsx.formulas_note": "Formules calculées localement",
|
||||
"xlsx.formulas_note_title": "ObsiGate calcule les formules courantes à l'écran (arithmétique, SOMME, MOYENNE, SI…). Les fonctions non prises en charge affichent la formule telle qu'enregistrée : Excel la recalcule à l'ouverture.",
|
||||
"xlsx.truncated_title": "Feuille tronquée",
|
||||
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
|
||||
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
|
||||
@@ -1871,8 +1886,31 @@
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
|
||||
"xlsx.csv_export": "Exporter la feuille ou la sélection en CSV",
|
||||
"xlsx.colour_panel_font": "Couleur du texte",
|
||||
"xlsx.colour_panel_fill": "Couleur de fond",
|
||||
"xlsx.colour_palette": "Palette de couleurs",
|
||||
"xlsx.colour_reset": "Réinitialiser",
|
||||
"xlsx.colour_reset_font": "Réinitialiser la couleur du texte",
|
||||
"xlsx.colour_reset_fill": "Réinitialiser la couleur de fond",
|
||||
"xlsx.colour_standard": "Standard",
|
||||
"xlsx.colour_custom": "Personnalisé",
|
||||
"xlsx.colour_edit_standard": "Choisir une couleur personnalisée",
|
||||
"xlsx.colour_custom_pick": "Ajouter une couleur personnalisée",
|
||||
"xlsx.colour_conditional": "Mise en forme conditionnelle",
|
||||
"xlsx.colour_banded": "Couleurs en alternance",
|
||||
"xlsx.colour_unavailable": "« {feature} » n'est pas encore disponible dans ObsiGate.",
|
||||
"xlsx.colour_black": "Noir",
|
||||
"xlsx.colour_white": "Blanc",
|
||||
"xlsx.colour_blue": "Bleu",
|
||||
"xlsx.colour_red": "Rouge",
|
||||
"xlsx.colour_yellow": "Jaune",
|
||||
"xlsx.colour_green": "Vert",
|
||||
"xlsx.colour_orange": "Orange",
|
||||
"xlsx.colour_cyan": "Cyan",
|
||||
"xlsx.export_btn": "Exporter",
|
||||
"xlsx.export_csv": "Exporter en CSV",
|
||||
"xlsx.export_title": "Exporter la sélection ou la feuille",
|
||||
"xlsx.file_menu": "Fichier",
|
||||
"xlsx.export_md": "Exporter en Markdown",
|
||||
"xlsx.export_html": "Exporter en HTML",
|
||||
"xlsx.print": "Imprimer",
|
||||
@@ -1880,6 +1918,47 @@
|
||||
"xlsx.export_scope_selection": "la sélection",
|
||||
"xlsx.export_done": "Export de {scope} terminé",
|
||||
"xlsx.format_btn": "Mise en forme",
|
||||
"xlsx.format_strike": "Barré",
|
||||
"xlsx.format_align_justify": "Justifié",
|
||||
"xlsx.tb_search": "Rechercher dans la feuille",
|
||||
"xlsx.tb_paint": "Peindre le format",
|
||||
"xlsx.paint_pick_target": "Cliquez sur la cellule ou la plage cible du format",
|
||||
"xlsx.tb_zoom": "Zoom",
|
||||
"xlsx.tb_currency": "Format monétaire",
|
||||
"xlsx.tb_percent": "Format pourcentage",
|
||||
"xlsx.tb_dec_minus": "Diminuer les décimales",
|
||||
"xlsx.tb_dec_plus": "Augmenter les décimales",
|
||||
"xlsx.tb_number": "Format nombre",
|
||||
"xlsx.tb_numfmt": "Format de nombre",
|
||||
"xlsx.tb_font_minus": "Réduire la taille de police",
|
||||
"xlsx.tb_font_plus": "Augmenter la taille de police",
|
||||
"xlsx.tb_font_size": "Taille de police",
|
||||
"xlsx.tb_borders": "Bordures",
|
||||
"xlsx.border_all": "Toutes les bordures",
|
||||
"xlsx.border_outer": "Bordures extérieures",
|
||||
"xlsx.border_none": "Aucune bordure",
|
||||
"xlsx.tb_merge": "Fusionner les cellules",
|
||||
"xlsx.tb_alignh": "Alignement horizontal",
|
||||
"xlsx.tb_alignv": "Alignement vertical",
|
||||
"xlsx.valign_top": "En haut",
|
||||
"xlsx.valign_middle": "Au milieu",
|
||||
"xlsx.valign_bottom": "En bas",
|
||||
"xlsx.tb_wrap": "Retour à la ligne automatique",
|
||||
"xlsx.tb_rotation": "Rotation du texte",
|
||||
"xlsx.rotation_0": "Horizontal",
|
||||
"xlsx.rotation_45": "Diagonale vers le haut",
|
||||
"xlsx.rotation_315": "Diagonale vers le bas",
|
||||
"xlsx.rotation_90": "Vertical",
|
||||
"xlsx.tb_more": "Plus d'options",
|
||||
"xlsx.comment_add": "Commentaire",
|
||||
"xlsx.comment_prompt": "Texte du commentaire (vide pour supprimer) :",
|
||||
"xlsx.link_add": "Insérer un lien",
|
||||
"xlsx.link_url": "Adresse du lien (https://…) :",
|
||||
"xlsx.link_label": "Libellé :",
|
||||
"xlsx.link_invalid": "Adresse invalide (https://… attendue)",
|
||||
"xlsx.sum_insert": "Somme",
|
||||
"xlsx.sum_empty": "Aucune plage à totaliser (nombres au-dessus ou sélection)",
|
||||
"xlsx.paint_pick_source": "Sélectionnez d'abord une cellule source mise en forme",
|
||||
"xlsx.format_bold": "Gras",
|
||||
"xlsx.format_italic": "Italique",
|
||||
"xlsx.format_underline": "Souligné",
|
||||
@@ -1901,9 +1980,13 @@
|
||||
"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_col_prompt": "Largeur de la colonne {col} — en caractères (0 à 255, 0 = largeur par défaut)",
|
||||
"xlsx.format_row_height": "Hauteur de ligne…",
|
||||
"xlsx.format_row_prompt": "Hauteur de la ligne {row} (0 à 409)",
|
||||
"xlsx.format_row_prompt": "Hauteur de la ligne {row} — en points (0 à 409, 0 = hauteur par défaut)",
|
||||
"xlsx.format_col_fit": "Ajuster la largeur au contenu",
|
||||
"xlsx.format_row_fit": "Ajuster la hauteur au contenu",
|
||||
"xlsx.mi_resize": "Redimensionner",
|
||||
"xlsx.fit_nothing": "Rien à ajuster (contenu vide ou déjà à la bonne taille)",
|
||||
"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",
|
||||
@@ -1964,6 +2047,166 @@
|
||||
"xlsx.feature_signature": "signature numérique",
|
||||
"xlsx.feature_rich_comments": "commentaires enrichis",
|
||||
"xlsx.feature_macros": "macros",
|
||||
|
||||
"xlsx.mbar_label": "Menus du tableur",
|
||||
"xlsx.cmdbar_label": "Barre de commandes du tableur",
|
||||
"xlsx.mbar_file": "Fichier",
|
||||
"xlsx.mbar_edit": "Modifier",
|
||||
"xlsx.mbar_view": "Affichage",
|
||||
"xlsx.mbar_insert": "Insertion",
|
||||
"xlsx.mbar_format": "Format",
|
||||
"xlsx.mbar_data": "Données",
|
||||
"xlsx.mbar_tools": "Outils",
|
||||
"xlsx.mbar_gemini": "Gemini",
|
||||
"xlsx.mbar_extensions": "Extensions",
|
||||
"xlsx.mbar_help": "Aide",
|
||||
"xlsx.menu_unavailable": "« {item} » n'est pas disponible dans ObsiGate.",
|
||||
"xlsx.menu_unavailable_hint": "Cette option n'existe pas dans l'éditeur tableur d'ObsiGate : elle est listée pour rester fidèle à Google Sheets.",
|
||||
|
||||
"xlsx.mi_new": "Nouveau",
|
||||
"xlsx.mi_open": "Ouvrir",
|
||||
"xlsx.mi_import": "Importer",
|
||||
"xlsx.mi_make_copy": "Créer une copie",
|
||||
"xlsx.mi_share": "Partager",
|
||||
"xlsx.mi_email": "Envoyer par e-mail",
|
||||
"xlsx.mi_download": "Télécharger",
|
||||
"xlsx.mi_rename": "Renommer",
|
||||
"xlsx.mi_trash": "Déplacer vers la corbeille",
|
||||
"xlsx.mi_history": "Historique des versions",
|
||||
"xlsx.mi_offline": "Rendre disponible hors connexion",
|
||||
"xlsx.mi_details": "Détails",
|
||||
"xlsx.mi_settings": "Paramètres",
|
||||
|
||||
"xlsx.mi_paste_special": "Collage spécial",
|
||||
"xlsx.mi_paste_all": "Tout coller",
|
||||
"xlsx.mi_paste_values": "Coller les valeurs uniquement",
|
||||
"xlsx.mi_move": "Déplacer",
|
||||
"xlsx.mi_move_rows": "Lignes sélectionnées vers le bas",
|
||||
"xlsx.mi_move_cols": "Colonnes sélectionnées vers la droite",
|
||||
"xlsx.mi_delete": "Supprimer",
|
||||
"xlsx.mi_delete_rows": "Supprimer les lignes sélectionnées",
|
||||
"xlsx.mi_delete_cols": "Supprimer les colonnes sélectionnées",
|
||||
"xlsx.mi_select_all": "Tout sélectionner",
|
||||
"xlsx.mi_replace": "Rechercher et remplacer",
|
||||
"xlsx.mi_replace_find": "Rechercher",
|
||||
"xlsx.mi_replace_with": "Remplacer par",
|
||||
"xlsx.mi_replace_one": "Remplacer",
|
||||
"xlsx.mi_replace_all": "Tout remplacer",
|
||||
"xlsx.mi_replace_none": "Aucune occurrence trouvée",
|
||||
"xlsx.mi_replace_done": "{n} cellule(s) remplacée(s)",
|
||||
|
||||
"xlsx.mi_freeze": "Figer",
|
||||
"xlsx.mi_freeze_here": "Figer jusqu'à la cellule active",
|
||||
"xlsx.mi_freeze_row": "Figer la ligne active",
|
||||
"xlsx.mi_freeze_col": "Figer la colonne active",
|
||||
"xlsx.mi_show": "Afficher",
|
||||
"xlsx.mi_show_formula": "Barre de formule",
|
||||
"xlsx.mi_show_grid": "Grille",
|
||||
"xlsx.mi_show_headers": "En-têtes",
|
||||
"xlsx.mi_show_dashboard": "Tableau de bord",
|
||||
"xlsx.mi_group": "Grouper",
|
||||
"xlsx.mi_group_rows": "Grouper les lignes sélectionnées",
|
||||
"xlsx.mi_group_cols": "Grouper les colonnes sélectionnées",
|
||||
"xlsx.mi_ungroup_rows": "Dégrouper les lignes",
|
||||
"xlsx.mi_ungroup_cols": "Dégrouper les colonnes",
|
||||
"xlsx.mi_hidden_sheets": "Feuilles masquées",
|
||||
"xlsx.mi_hide_sheet": "Masquer la feuille active",
|
||||
"xlsx.mi_unhide_sheets": "Afficher les feuilles masquées",
|
||||
"xlsx.mi_zoom": "Zoom",
|
||||
"xlsx.mi_fullscreen": "Plein écran",
|
||||
"xlsx.mi_exit_fullscreen": "Quitter le plein écran",
|
||||
|
||||
"xlsx.mi_insert_rows": "Lignes",
|
||||
"xlsx.mi_insert_cols": "Colonnes",
|
||||
"xlsx.mi_insert_cells": "Cellules",
|
||||
"xlsx.mi_image": "Image",
|
||||
"xlsx.mi_chart": "Graphique",
|
||||
"xlsx.mi_drawing": "Dessin",
|
||||
"xlsx.mi_function": "Fonction",
|
||||
"xlsx.mi_function_hint": "Insère la fonction et place le curseur dans les parenthèses",
|
||||
"xlsx.mi_pivot": "Tableau croisé dynamique",
|
||||
"xlsx.mi_smart_chips": "Chips intelligents",
|
||||
"xlsx.mi_form": "Formulaire",
|
||||
|
||||
"xlsx.mi_number": "Nombre",
|
||||
"xlsx.mi_theme": "Thème",
|
||||
"xlsx.mi_font_size": "Taille de police",
|
||||
"xlsx.mi_align": "Alignement",
|
||||
"xlsx.mi_align_h": "Alignement horizontal",
|
||||
"xlsx.mi_align_v": "Alignement vertical",
|
||||
"xlsx.mi_wrap": "Retour à la ligne",
|
||||
"xlsx.mi_wrap_on": "Activer",
|
||||
"xlsx.mi_wrap_off": "Désactiver",
|
||||
"xlsx.mi_rotation": "Rotation du texte",
|
||||
"xlsx.mi_conditional": "Mise en forme conditionnelle",
|
||||
"xlsx.mi_banded": "Couleurs alternées",
|
||||
"xlsx.mi_banded_hint": "Alterne la couleur de fond une ligne sur deux dans la sélection",
|
||||
"xlsx.mi_banded_done": "Couleurs alternées appliquées à {n} ligne(s)",
|
||||
"xlsx.mi_banded_default": "#eef3fb",
|
||||
|
||||
"xlsx.mi_sort": "Trier la plage",
|
||||
"xlsx.mi_filter": "Créer un filtre",
|
||||
"xlsx.mi_filter_hint": "Filtre les lignes selon la valeur de la cellule active",
|
||||
"xlsx.mi_validation": "Validation des données",
|
||||
"xlsx.mi_named": "Plages nommées",
|
||||
"xlsx.mi_protect": "Protéger les feuilles et les plages",
|
||||
"xlsx.mi_cleanup": "Nettoyage des données",
|
||||
"xlsx.mi_cleanup_dupes": "Supprimer les doublons",
|
||||
"xlsx.mi_cleanup_trim": "Supprimer les espaces superflus",
|
||||
"xlsx.mi_cleanup_dupes_none": "Aucun doublon dans la sélection",
|
||||
"xlsx.mi_cleanup_dupes_done": "{n} ligne(s) en doublon supprimée(s)",
|
||||
"xlsx.mi_cleanup_trim_done": "{n} cellule(s) nettoyée(s)",
|
||||
"xlsx.mi_split": "Diviser le texte en colonnes",
|
||||
"xlsx.mi_split_hint": "Sépare le contenu de la cellule active sur un séparateur",
|
||||
"xlsx.mi_split_sep": "Séparateur",
|
||||
"xlsx.mi_split_done": "{n} colonne(s) remplie(s) à partir de la cellule active",
|
||||
|
||||
"xlsx.mi_form_create": "Créer un formulaire",
|
||||
"xlsx.mi_spellcheck": "Vérifier l'orthographe",
|
||||
"xlsx.mi_autocomplete": "Saisie semi-automatique",
|
||||
"xlsx.mi_autocomplete_on": "Saisie semi-automatique activée",
|
||||
"xlsx.mi_autocomplete_off": "Saisie semi-automatique désactivée",
|
||||
"xlsx.mi_notifications": "Règles de notification",
|
||||
"xlsx.mi_macros": "Macros",
|
||||
"xlsx.mi_script_editor": "Éditeur de script",
|
||||
"xlsx.mi_a11y": "Accessibilité",
|
||||
"xlsx.mi_a11y_on": "Accessibilité renforcée activée (cellules plus grandes, grille plus contrastée)",
|
||||
"xlsx.mi_a11y_off": "Accessibilité renforcée désactivée",
|
||||
|
||||
"xlsx.mi_ask_gemini": "Demander à Gemini",
|
||||
"xlsx.mi_gem_table": "Créer des tableaux",
|
||||
"xlsx.mi_gem_formula": "Créer des formules",
|
||||
"xlsx.mi_gem_insights": "Analyse et insights",
|
||||
"xlsx.mi_gem_chart": "Créer des graphiques",
|
||||
"xlsx.mi_gem_summarize": "Résumer des e-mails et des fichiers",
|
||||
"xlsx.mi_gem_actions": "Effectuer des actions",
|
||||
"xlsx.mi_gem_more": "Plus d'options",
|
||||
|
||||
"xlsx.mi_addons": "Modules complémentaires",
|
||||
"xlsx.mi_apps_script": "Apps Script",
|
||||
"xlsx.mi_appsheet": "AppSheet",
|
||||
"xlsx.mi_looker": "Looker Studio",
|
||||
|
||||
"xlsx.mi_help_sheets": "Aide sur les feuilles de calcul",
|
||||
"xlsx.mi_training": "Formation",
|
||||
"xlsx.mi_whatsnew": "Mises à jour",
|
||||
"xlsx.mi_shortcuts": "Raccourcis clavier",
|
||||
"xlsx.mi_report": "Signaler un problème",
|
||||
"xlsx.mi_abuse": "Signaler un abus",
|
||||
|
||||
"xlsx.details_title": "Détails du fichier",
|
||||
"xlsx.details_body": "Vault : {vault}\nFichier : {path}\nFeuilles : {sheets}\nCellules affichées : {cells}\nDernière révision : {revision}\nModification en attente : {dirty}",
|
||||
"xlsx.shortcuts_title": "Raccourcis clavier",
|
||||
"xlsx.shortcuts_body": "Ctrl/Cmd + Z — Annuler\nCtrl/Cmd + Y — Rétablir\nCtrl/Cmd + C / X / V — Copier / Couper / Coller\nCtrl/Cmd + A — Tout sélectionner\nCtrl/Cmd + F — Rechercher dans la feuille\nEntrée — Valider et descendre d'une cellule\nF2 — Modifier la cellule\nAlt + flèche — Saut de ligne dans la cellule\nÉchap — Quitter la saisie\nSuppr — Effacer le contenu de la sélection\nDouble-clic — Modifier la cellule en place\nMaj + clic — Étendre la sélection",
|
||||
"xlsx.version_title": "Mises à jour",
|
||||
"xlsx.version_body": "ObsiGate {version} — {date}\n{status}",
|
||||
"xlsx.report_copied": "Diagnostic copié dans le presse-papiers.",
|
||||
"xlsx.themes_title": "Thème de la feuille",
|
||||
"xlsx.function_prompt": "Fonction à insérer",
|
||||
"xlsx.rename_prompt": "Nouveau nom du fichier",
|
||||
"xlsx.trash_confirm": "Déplacer « {name} » vers la corbeille ? Le classeur sera fermé.",
|
||||
"xlsx.rename_done": "Fichier renommé en {name}",
|
||||
"xlsx.trash_done": "Fichier déplacé vers la corbeille",
|
||||
"viewer.download_md": "Télécharger en .md",
|
||||
"viewer.download_file": "Télécharger le fichier",
|
||||
"viewer.pretty": "Pretty",
|
||||
@@ -1978,6 +2221,9 @@
|
||||
"viewer.export_html": "Exporter en HTML",
|
||||
"viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)",
|
||||
"viewer.export_start": "Export en cours...",
|
||||
"viewer.secret_copy_hint": "Cliquer pour copier la valeur",
|
||||
"viewer.secret_copied": "Valeur copiée dans le presse-papiers",
|
||||
"viewer.secret_copy_failed": "Échec de la copie",
|
||||
"viewer.export_title": "Exporter le document",
|
||||
"viewer.forge_brand": "Forge",
|
||||
"viewer.forge_title": "Forge (nouvel éditeur)",
|
||||
@@ -2138,6 +2384,13 @@
|
||||
"ai.step.docx_create": "Document Word proposé : {value}",
|
||||
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
||||
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
||||
"ai.step.duplicates": "Doublons recherchés : {value}",
|
||||
"ai.step.duplicates_merge": "Notes fusionnées : {value}",
|
||||
"ai.step.notify": "Notification envoyée : {value}",
|
||||
"ai.step.schedule_create": "Tâche planifiée : {value}",
|
||||
"ai.step.schedule_list": "Tâches planifiées consultées",
|
||||
"ai.step.schedule_delete": "Tâche supprimée : {value}",
|
||||
"ai.step.schedule_run": "Tâche exécutée : {value}",
|
||||
"bookslm.copied": "Réponse copiée dans le presse-papiers",
|
||||
"bookslm.error": "Erreur du service AI",
|
||||
"bookslm.regenerate": "Régénérer",
|
||||
|
||||
+873
-53
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.49.0",
|
||||
"version": "2.55.0",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
+284
-11
@@ -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.
|
||||
*
|
||||
@@ -37,6 +41,8 @@ const FIXTURE = 'sample-xlsx-lossy.xlsx';
|
||||
// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
|
||||
// committed next to the other fixture; nothing in the suite writes to it.
|
||||
const LARGE = 'sample-xlsx-large.xlsx';
|
||||
const LARGE_PATH = path.resolve(process.cwd(), 'test_vault', LARGE);
|
||||
let originalLargeBytes = null;
|
||||
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
|
||||
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
||||
|
||||
@@ -81,19 +87,17 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
|
||||
// the viewer rewrites the workbook and an openpyxl round-trip drops the cached
|
||||
// formula results (BUG-085), so the shadow line only exists on a pristine
|
||||
// fixture.
|
||||
test('affiche la valeur calculée en cache sous la formule (#153 A12)', async ({ page }) => {
|
||||
test('calcule les formules à l’écran, garde le texte en barre (#153 A12)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>).
|
||||
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>): the
|
||||
// local engine computes it, so the cell shows the value (not the shadow).
|
||||
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await expect(formulaCell).toContainText('=B1*2');
|
||||
|
||||
const cached = formulaCell.locator('.xlsx-cached');
|
||||
await expect(cached).toHaveCount(1);
|
||||
await expect(cached).toHaveText('200');
|
||||
// The tooltip is translated client-side, never hardcoded by the backend.
|
||||
await expect(cached).toHaveAttribute('title', /Excel/);
|
||||
await expect(formulaCell).toHaveText('200');
|
||||
await expect(formulaCell.locator('.xlsx-cached')).toHaveCount(0);
|
||||
await formulaCell.click();
|
||||
await expect(page.locator('#xlsx-formula-input')).toHaveValue('=B1*2');
|
||||
|
||||
// A plain value cell must not be duplicated with a shadow line.
|
||||
await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0);
|
||||
@@ -282,11 +286,12 @@ test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)',
|
||||
// — 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');
|
||||
// #184 — la feuille rendue va jusqu'à Z (la grille est posée A → Z).
|
||||
await expect(nameBox).toHaveValue('A1:Z500');
|
||||
await page.keyboard.press('Control+Home');
|
||||
await expect(nameBox).toHaveValue('A1');
|
||||
await page.keyboard.press('Control+End');
|
||||
await expect(nameBox).toHaveValue('C500');
|
||||
await expect(nameBox).toHaveValue('Z500');
|
||||
|
||||
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
|
||||
// utilise le presse-papiers interne de la grille : aucune permission
|
||||
@@ -314,3 +319,271 @@ test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)',
|
||||
expect(await textOf('C10')).toBe(src.c10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #179 — icônes des menus, fermeture au focus, polish mobile ──────────────
|
||||
|
||||
test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)', () => {
|
||||
test('chaque entrée du menu contextuel porte une icône', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
|
||||
const menu = page.locator('.xlsx-context-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
// Icône rendue par Lucide (svg) ou à rendre (<i data-lucide>) si le CDN
|
||||
// est injoignable — dans les deux cas l'entrée est iconisée.
|
||||
for (const label of ['Couper', 'Copier', 'Coller']) {
|
||||
const entry = menu.locator('.xlsx-context-item', { hasText: label });
|
||||
await expect(entry.locator('i[data-lucide], svg')).toHaveCount(1);
|
||||
}
|
||||
await expect(menu.locator('.xlsx-context-item i[data-lucide="trash-2"], .xlsx-context-item svg.lucide-trash-2')).not.toHaveCount(0);
|
||||
});
|
||||
|
||||
test('le menu contextuel se ferme au clic extérieur et à Échap', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const cell = page.locator('#content-area td[data-cell="A1"]');
|
||||
await cell.click({ button: 'right' });
|
||||
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
|
||||
await page.locator('#content-area .xlsx-table').click({ position: { x: 5, y: 5 } });
|
||||
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
|
||||
|
||||
await cell.click({ button: 'right' });
|
||||
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('le menu Structure se ferme quand le focus le quitte', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const toggle = page.locator('#xlsx-structure-btn');
|
||||
await toggle.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();
|
||||
// The menu opens right under its button, inside the toolbar width.
|
||||
const [btnBox, menuBox, toolbarBox] = await Promise.all([
|
||||
toggle.boundingBox(), menu.boundingBox(),
|
||||
page.locator('#content-area .xlsx-toolbar').boundingBox(),
|
||||
]);
|
||||
expect(menuBox.y).toBeGreaterThanOrEqual(btnBox.y + btnBox.height - 2);
|
||||
expect(menuBox.y).toBeLessThanOrEqual(btnBox.y + btnBox.height + 24);
|
||||
// Le menu reste DANS la barre : près du bord droit il est décalé vers la
|
||||
// gauche (clamp de anchorUnder) au lieu de dépasser.
|
||||
expect(menuBox.x).toBeGreaterThanOrEqual(toolbarBox.x - 2);
|
||||
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 2);
|
||||
// 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 Fichier (Télécharger/CSV/Export) se ferme au clic extérieur', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
await page.locator('#xlsx-file-btn').click();
|
||||
const menu = page.locator('#xlsx-file-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
// Les 5 sorties sont réunies dans ce seul menu (#185 : il est passé dans
|
||||
// la barre de menus, au-dessus du ruban), repliées dans la section
|
||||
// « Télécharger » — les sections du menu sont des accordéons.
|
||||
await menu.locator('.xlsx-menu-item', { hasText: 'Télécharger' }).first().click();
|
||||
await expect(menu.locator('#xlsx-download-btn')).toBeVisible();
|
||||
await expect(menu.locator('#xlsx-csv-btn')).toBeVisible();
|
||||
await expect(page.locator('#content-area .xlsx-sheetbar-end #xlsx-save-btn')).toBeVisible();
|
||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('#185 : les 10 menus Google Sheets au-dessus du ruban, et leurs sections', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const bar = page.locator('#content-area .xlsx-menubar');
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(bar.locator('.xlsx-menubtn')).toHaveText([
|
||||
'Fichier', 'Modifier', 'Affichage', 'Insertion', 'Format',
|
||||
'Données', 'Outils', 'Gemini', 'Extensions', 'Aide',
|
||||
]);
|
||||
// Le ruban est bien SOUS les menus.
|
||||
const [barBox, ribbonBox] = await Promise.all([
|
||||
bar.boundingBox(),
|
||||
page.locator('#content-area .xlsx-cmdbar').boundingBox(),
|
||||
]);
|
||||
expect(ribbonBox.y).toBeGreaterThan(barBox.y);
|
||||
|
||||
// Une section s'ouvre en accordéon, comme dans Sheets.
|
||||
await page.locator('#content-area [data-xlsx-menu="format"]').click();
|
||||
const format = page.locator('#xlsx-menu-format');
|
||||
await expect(format).toBeVisible();
|
||||
const number = format.locator('.xlsx-menu-item', { hasText: 'Nombre' }).first();
|
||||
await number.click();
|
||||
await expect(number).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(format.locator('[data-xlsx-action="numfmt"]').first()).toBeVisible();
|
||||
|
||||
// Une option non implémentée le dit au lieu de ne rien faire.
|
||||
const ghost = format.locator('[data-xlsx-unavailable]').first();
|
||||
await ghost.click();
|
||||
await expect(page.locator('.toast').first()).toBeVisible();
|
||||
await expect(format).toBeHidden();
|
||||
});
|
||||
|
||||
test('#185 : la grille est unie (1px sur les quatre côtés) et se masque', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
const sides = await cell.evaluate((td) => {
|
||||
const cs = getComputedStyle(td);
|
||||
return [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth];
|
||||
});
|
||||
expect(new Set(sides).size).toBe(1);
|
||||
expect(sides[0]).toBe('1px');
|
||||
|
||||
await page.locator('#content-area [data-xlsx-menu="view"]').click();
|
||||
const view = page.locator('#xlsx-menu-view');
|
||||
await expect(view).toBeVisible();
|
||||
// Les entrées d'affichage vivent dans la section « Afficher » (accordéon).
|
||||
await view.locator('.xlsx-menu-item', { hasText: 'Afficher' }).first().click();
|
||||
await page.locator('#xlsx-menu-view [data-xlsx-action="toggle-grid"]').click();
|
||||
await expect(page.locator('#content-area .xlsx-viewer')).toHaveClass(/xlsx-no-grid/);
|
||||
const hidden = await cell.evaluate((td) => getComputedStyle(td).borderTopWidth);
|
||||
expect(hidden).toBe('0px');
|
||||
});
|
||||
|
||||
// Mon seul test mutant : restaure la fixture comme le bloc lossy.
|
||||
test.beforeAll(() => {
|
||||
if (existsSync(LARGE_PATH)) originalLargeBytes = readFileSync(LARGE_PATH);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
if (originalLargeBytes) writeFileSync(LARGE_PATH, originalLargeBytes);
|
||||
});
|
||||
|
||||
test('insère une colonne à droite et une ligne en fin de feuille', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
// #184 — la grille posée va jusqu'à Z : le compte d'en-têtes ne peut plus
|
||||
// témoigner de l'insertion (un re-rendu re-complète à 26 colonnes). Le
|
||||
// toast est le signal de l'opération, la fixture restaurée sa preuve.
|
||||
const headers = () => page.locator('#content-area .xlsx-table thead th:not(.xlsx-corner)');
|
||||
await expect(headers()).toHaveCount(26);
|
||||
await headers().nth(2).click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'droite' }).click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
// Insérée localement (pas de re-rendu complet) : la colonne s'ajoute à
|
||||
// la grille posée, 26 → 27.
|
||||
await expect(headers()).toHaveCount(27);
|
||||
|
||||
// La ligne 501 dépasse le plafond de rendu : l'insertion réussit
|
||||
// (toast) même si la ligne reste hors champ.
|
||||
await page.locator('#content-area th.xlsx-rownum').nth(499).click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'dessous' }).click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('la barre façon Sheets expose ses contrôles et agit', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
for (const id of ['#xs-search', '#xs-print', '#xs-paint', '#xs-zoom', '#xs-cur', '#xs-pct',
|
||||
'#xs-numfmt', '#xs-b', '#xs-i', '#xs-s', '#xs-u', '#xs-borders', '#xs-merge',
|
||||
'#xs-alignh', '#xs-alignv', '#xs-wrap', '#xs-rot', '#xs-more', '#xlsx-save-btn']) {
|
||||
await expect(page.locator(id)).toBeVisible();
|
||||
}
|
||||
// Zoom : la grille suit le sélecteur.
|
||||
await page.locator('#xs-zoom').selectOption('150');
|
||||
await expect.poll(async () => page.locator('#content-area .xlsx-panels').evaluate(
|
||||
(el) => el.style.zoom || '100%')).toBe('150%');
|
||||
await page.locator('#xs-zoom').selectOption('100');
|
||||
|
||||
// Gras via la barre : PUT + toast, sans dialogue.
|
||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
||||
await page.locator('#xs-b').click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Mini-barre contextuelle sur une plage.
|
||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
||||
await page.locator('#content-area td[data-cell="B2"]').click({ modifiers: ['Shift'] });
|
||||
await expect(page.locator('#xlsx-minibar')).toBeVisible();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #186 — création d'un classeur depuis la modale « Créer un fichier » ──────
|
||||
|
||||
test.describe('Excel viewer — création depuis l\'UI (#186)', () => {
|
||||
// Le nom saisi dans la modale ne porte que le nom court : le dossier parent
|
||||
// (« IT ») est déjà choisi par le menu contextuel.
|
||||
const stem = `e2e-cree-${Date.now()}`;
|
||||
const CREATED = `IT/${stem}.xlsx`;
|
||||
|
||||
async function cleanup(request) {
|
||||
await request.fetch(
|
||||
`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(CREATED)}`,
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
test('la liste des types propose Excel et le classeur créé s\'ouvre dans la visionneuse', async ({ page, request }) => {
|
||||
await cleanup(request);
|
||||
await login(page);
|
||||
|
||||
// Menu contextuel du dossier « IT » → « Nouveau fichier ici ».
|
||||
await page.locator('.tree-item.vault-item[data-vault="TestVault"]').first().click();
|
||||
await page.locator('.tree-item[data-vault="TestVault"][data-path="IT"]').first()
|
||||
.click({ button: 'right' });
|
||||
const menu = page.locator('#context-menu.active');
|
||||
await expect(menu).toBeVisible({ timeout: 5000 });
|
||||
await menu.locator('.context-menu-item', { hasText: 'Nouveau fichier ici' }).click();
|
||||
|
||||
await page.locator('.obsigate-modal-overlay.active').waitFor({ state: 'visible', timeout: 5000 });
|
||||
const extSelect = page.locator('#file-ext-select');
|
||||
// L'option Excel existe, et son libellé vient des locales (jamais du dur).
|
||||
await expect(extSelect.locator('option[value=".xlsx"]')).toHaveText('Excel (.xlsx)');
|
||||
|
||||
await extSelect.selectOption('.xlsx');
|
||||
await page.fill('#file-name-input', stem);
|
||||
await page.click('#file-create-btn');
|
||||
|
||||
// Le classeur s'ouvre dans la visionneuse : une grille, pas une erreur.
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.locator('#content-area .xlsx-tab', { hasText: 'Feuille1' })).toBeVisible();
|
||||
|
||||
// Et c'est un vrai classeur : l'API le sert en tableur, avec sa feuille.
|
||||
const body = await request
|
||||
.get(`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(CREATED)}`)
|
||||
.then((r) => r.json());
|
||||
expect(body.is_xlsx).toBe(true);
|
||||
expect(body.xlsx_sheets.map((s) => s.name)).toEqual(['Feuille1']);
|
||||
|
||||
await cleanup(request);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,10 +114,9 @@ await test("the 4 scenarios of the design table map to the expected actions", ()
|
||||
assert.deepEqual(suggestionsFor("selection").map((a) => a.id), ["concise", "fix_style", "explain_selection"]);
|
||||
});
|
||||
|
||||
await test("frontmatter actions are agent-mode (they mutate the document)", () => {
|
||||
assert.equal(getAction("frontmatter").agent, true);
|
||||
assert.equal(getAction("frontmatter_update").agent, true);
|
||||
assert.ok(!getAction("summarize_3").agent);
|
||||
await test("#187: frontmatter actions need no agent-mode flag (always agent)", () => {
|
||||
assert.equal(getAction("frontmatter").agent, undefined);
|
||||
assert.equal(getAction("frontmatter_update").agent, undefined);
|
||||
});
|
||||
|
||||
// ── i18n completeness (FR + EN) ────────────────────────────────────────────
|
||||
|
||||
+15
-26
@@ -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;
|
||||
}
|
||||
|
||||
@@ -307,6 +307,22 @@ function testSyntaxHighlightTheme() {
|
||||
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
|
||||
}
|
||||
|
||||
// ── Test mobile : le sidebar passe par-dessus la barre d'outils du bas (BUG-103) ──
|
||||
function testMobileSidebarOverToolbar() {
|
||||
const css = readFileSync(join(JS_DIR, '..', 'style.css'), 'utf-8');
|
||||
const zOf = (selector) => {
|
||||
const blocks = css.match(new RegExp('\\' + selector + ' \\{[^}]*\\}', 'g')) || [];
|
||||
return Math.max(0, ...blocks.map((b) => Number((b.match(/z-index: (\d+)/) || [])[1] || 0)));
|
||||
};
|
||||
const sidebarZ = zOf('.sidebar');
|
||||
const toolbarZ = zOf('.mobile-toolbar');
|
||||
assert.ok(
|
||||
sidebarZ > toolbarZ,
|
||||
`.sidebar z-index (${sidebarZ}) doit être supérieur à .mobile-toolbar (${toolbarZ})`,
|
||||
);
|
||||
console.log(' ✓ mobile sidebar over toolbar');
|
||||
}
|
||||
|
||||
// ── Run all tests ──────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
let passed = 0, failed = 0;
|
||||
@@ -324,6 +340,7 @@ async function main() {
|
||||
['config TOC icons', testConfigTocIcons],
|
||||
['sidebar user section', testSidebarUserSection],
|
||||
['syntax highlight theme', testSyntaxHighlightTheme],
|
||||
['mobile sidebar over toolbar', testMobileSidebarOverToolbar],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — unit tests for the spreadsheet formula engine
|
||||
* (frontend/js/xlsx/formula.js). Pure logic, no DOM needed.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-formula.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
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, "..", "..");
|
||||
|
||||
const modUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "formula.js")
|
||||
).href;
|
||||
const {
|
||||
isFormulaText, parseFormula, evaluate, createScope, displayValue,
|
||||
colToIndex, indexToCol, parseA1,
|
||||
} = await import(modUrl);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (e) {
|
||||
console.error(` ✗ ${name}`);
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate `text` against a fake sheet {ref: raw}. */
|
||||
function run(text, cells = {}, locale = "en") {
|
||||
const scope = createScope({
|
||||
readRaw: (_sheet, ref) => {
|
||||
const hit = Object.entries(cells).find(
|
||||
([k]) => k.toUpperCase() === String(ref).toUpperCase()
|
||||
);
|
||||
return hit ? hit[1] : null;
|
||||
},
|
||||
sheet: "F",
|
||||
locale,
|
||||
});
|
||||
return displayValue(evaluate(parseFormula(text), scope), locale);
|
||||
}
|
||||
|
||||
await test("detects formula text", () => {
|
||||
assert.ok(isFormulaText("=1+1"));
|
||||
assert.ok(isFormulaText(" @A1 "));
|
||||
assert.ok(!isFormulaText("hello"));
|
||||
assert.ok(!isFormulaText("="));
|
||||
});
|
||||
|
||||
await test("refs helpers", () => {
|
||||
assert.equal(colToIndex("A"), 1);
|
||||
assert.equal(colToIndex("Z"), 26);
|
||||
assert.equal(colToIndex("AA"), 27);
|
||||
assert.equal(indexToCol(28), "AB");
|
||||
assert.deepEqual(parseA1("$B$12"), { col: 2, row: 12 });
|
||||
assert.equal(parseA1("ZZZ"), null);
|
||||
});
|
||||
|
||||
await test("arithmetic and Excel precedence (-2^2 = 4)", () => {
|
||||
assert.equal(run("=1+2*3"), "7");
|
||||
assert.equal(run("=(1+2)*3"), "9");
|
||||
assert.equal(run("=2^3"), "8");
|
||||
assert.equal(run("=-2^2"), "4");
|
||||
assert.equal(run("=10/4"), "2.5");
|
||||
assert.equal(run("=50%"), "0.5");
|
||||
assert.equal(run("=5*10%"), "0.5");
|
||||
});
|
||||
|
||||
await test("comparisons and concatenation", () => {
|
||||
assert.equal(run("=1+1=2"), "TRUE");
|
||||
assert.equal(run('="a"&"b"'), "ab");
|
||||
assert.equal(run("=3>2"), "TRUE");
|
||||
assert.equal(run("=1<>1"), "FALSE");
|
||||
});
|
||||
|
||||
await test("cell refs and ranges", () => {
|
||||
const cells = { A1: 2, A2: 4, B1: "x" };
|
||||
assert.equal(run("=A1*3", cells), "6");
|
||||
assert.equal(run("=SOMME(A1:A2)", cells), "6");
|
||||
assert.equal(run("=SUM(A1:A2)", cells), "6");
|
||||
assert.equal(run("=MOYENNE(A1:A2)", cells), "3");
|
||||
assert.equal(run("=A1&B1", cells), "2x");
|
||||
});
|
||||
|
||||
await test("French decimal comma with ; separator", () => {
|
||||
assert.equal(run("=1,5+1", {}, "fr"), "2.5");
|
||||
assert.equal(run("=SOMME(1;2;3)"), "6");
|
||||
assert.equal(run("=SI(1>2;10;20)"), "20");
|
||||
});
|
||||
|
||||
await test("functions FR/EN", () => {
|
||||
assert.equal(run("=MAX(1;5;3)"), "5");
|
||||
assert.equal(run("=MIN(1;5;3)"), "1");
|
||||
assert.equal(run("=ABS(-4)"), "4");
|
||||
assert.equal(run("=ARRONDI(2.456;2)"), "2.46");
|
||||
assert.equal(run("=RACINE(9)"), "3");
|
||||
assert.equal(run("=PUISSANCE(2;10)"), "1024");
|
||||
assert.equal(run("=NB(1;\"x\";3)"), "2");
|
||||
assert.equal(run("=NBVAL(1;\"x\";3)"), "3");
|
||||
assert.equal(run("=NBCAR(\"héllo\")"), "5");
|
||||
assert.equal(run("=MAJUSCULES(\"abc\")"), "ABC");
|
||||
assert.equal(run("=GAUCHE(\"hello\";2)"), "he");
|
||||
assert.equal(run("=DROITE(\"hello\";2)"), "lo");
|
||||
assert.equal(run("=STXT(\"hello\";2;3)"), "ell");
|
||||
assert.equal(run("=ET(1;1)"), "TRUE");
|
||||
assert.equal(run("=OU(0;1)"), "TRUE");
|
||||
assert.equal(run("=NON(1)"), "FALSE");
|
||||
assert.equal(run('=SIERREUR(1/0;"raté")'), "raté");
|
||||
assert.equal(run("=MEDIANE(3;1;2)"), "2");
|
||||
assert.equal(run("=PRODUIT(2;3;4)"), "24");
|
||||
assert.equal(run("=MOD(7;3)"), "1");
|
||||
assert.equal(run("=ENT(2.9)"), "2");
|
||||
});
|
||||
|
||||
await test("nested formulas and chained refs", () => {
|
||||
const cells = { A1: "=B1*2", B1: 21 };
|
||||
assert.equal(run("=A1+1", cells), "43");
|
||||
});
|
||||
|
||||
await test("errors surface as localized codes", () => {
|
||||
assert.equal(run("=1/0"), "#DIV/0!");
|
||||
assert.equal(run("=1/0", {}, "fr"), "#DIV/0!");
|
||||
assert.equal(run("=NOSUCHFN(1)"), "#NAME?");
|
||||
assert.equal(run("=NOSUCHFN(1)", {}, "fr"), "#NOM?");
|
||||
assert.equal(run('="a"+1'), "#VALUE!");
|
||||
assert.equal(run('="a"+1', {}, "fr"), "#VALEUR!");
|
||||
assert.equal(run("=A99999999"), "#REF!");
|
||||
});
|
||||
|
||||
await test("cycles are detected", () => {
|
||||
const cells = { A1: "=B1", B1: "=A1" };
|
||||
assert.equal(run("=A1", cells), "#CIRC!");
|
||||
assert.equal(run("=A1", cells, "fr"), "#CYCLE!");
|
||||
});
|
||||
|
||||
await test("booleans localize", () => {
|
||||
assert.equal(run("=VRAI"), "TRUE");
|
||||
assert.equal(run("=VRAI", {}, "fr"), "VRAI");
|
||||
assert.equal(run("=TRUE"), "TRUE");
|
||||
assert.equal(run("=1=1", {}, "fr"), "VRAI");
|
||||
});
|
||||
|
||||
await test("HYPERLINK yields a link value", async () => {
|
||||
const scope = createScope({ readRaw: () => null, sheet: "F", locale: "en" });
|
||||
const v = evaluate(parseFormula('=LIEN("https://x.test";"ici")'), scope);
|
||||
assert.equal(v.t, "link");
|
||||
assert.equal(v.url, "https://x.test");
|
||||
assert.equal(v.label, "ici");
|
||||
assert.equal(displayValue(v), "ici");
|
||||
});
|
||||
|
||||
await test("garbage is rejected by the parser", () => {
|
||||
assert.throws(() => parseFormula("=1+"), Error);
|
||||
assert.throws(() => parseFormula("=SUM("), Error);
|
||||
assert.throws(() => parseFormula("=(1"), Error);
|
||||
});
|
||||
|
||||
await test("quoted and absolute refs", () => {
|
||||
const cells = { A1: 5 };
|
||||
assert.equal(run("=$A$1+1", cells), "6");
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} passed`);
|
||||
process.exit(process.exitCode || 0);
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM tests for the spreadsheet menus (ROADMAP #179, #185).
|
||||
*
|
||||
* Covers frontend/js/xlsx/context-menu.js, command-bar.js and menus.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 its icons (save, tab "+", gridlines);
|
||||
* - #185: the menu bar lists the ten Google Sheets menus, every entry is
|
||||
* actionable or honestly flagged unavailable, labels come from the catalog,
|
||||
* editing entries disappear on a read-only workbook, and the historical
|
||||
* Fichier ids (#xlsx-file-btn / -menu / #xlsx-download-btn / #xlsx-csv-btn)
|
||||
* survive the move from the sheet strip.
|
||||
*
|
||||
* 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);
|
||||
|
||||
// #185 — the menu bar renders localized labels, so the real FR catalog has to
|
||||
// be reachable: serve /static/locales/*.json from disk and nothing else.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/static/locales/")) {
|
||||
const file = path.join(REPO_ROOT, "frontend", "locales", "fr.json");
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(readFileSync(file, "utf8")) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}), text: async () => "" };
|
||||
};
|
||||
|
||||
// ── 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 menusUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "menus.js")
|
||||
).href;
|
||||
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
|
||||
await import(ctxMenuUrl);
|
||||
const { buildCommandBar } = await import(cmdBarUrl);
|
||||
const { buildMenuBar, SHEETS_MENUS } = await import(menusUrl);
|
||||
const { initI18n } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
|
||||
);
|
||||
await initI18n();
|
||||
|
||||
// ── 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("double close never throws and a late click still runs its action", () => {
|
||||
// Regression: in a real browser, remove() fires focusout synchronously,
|
||||
// which re-entered closeContextMenu — the second remove() threw
|
||||
// NotFoundError and aborted the item's onClick before it ran.
|
||||
let runs = 0;
|
||||
const menu = openContextMenu(10, 10, [{ icon: "copy", label: "Copy", onClick: () => { runs++; } }]);
|
||||
const btn = menu.querySelector(".xlsx-context-item");
|
||||
closeContextMenu();
|
||||
closeContextMenu(); // idempotent: no-op, no throw
|
||||
assert.ok(!isContextMenuOpen());
|
||||
btn.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(runs, 1, "click on the detached entry still runs its action");
|
||||
});
|
||||
|
||||
await test("scroll and resize close the menu", async () => {
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
window.dispatchEvent(new w.Event("resize"));
|
||||
assert.ok(!isContextMenuOpen(), "resize closes");
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
// The smooth-scroll tail of the opening gesture is forgiven…
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(isContextMenuOpen(), "immediate scroll does not close");
|
||||
// …but a later scroll detaches the menu.
|
||||
await sleep(450);
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(!isContextMenuOpen(), "later scroll 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 (#185: the File menu moved to the menu bar) ────────────────
|
||||
await test("the ribbon carries save, tab-plus and gridline icons", () => {
|
||||
const { actionsHtml, tabs, fileHtml } = buildCommandBar({
|
||||
sheets: [{ name: "F1" }],
|
||||
isCsv: false,
|
||||
readOnly: false,
|
||||
editable: true,
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = tabs + actionsHtml + fileHtml;
|
||||
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 gridIcon = host.querySelector("#xs-grid i[data-lucide]");
|
||||
assert.ok(gridIcon, "gridline switch has an icon");
|
||||
assert.equal(gridIcon.getAttribute("data-lucide"), "grid-3x3");
|
||||
// #185 — the sheet strip keeps Save only; the file outputs live in the menu bar.
|
||||
assert.equal(host.querySelector("#xlsx-file-btn"), null, "no file dropdown left on the strip");
|
||||
});
|
||||
|
||||
// ── #185 — the menu bar itself ──────────────────────────────────────────────
|
||||
await test("buildMenuBar renders the ten Sheets menus with their labels", () => {
|
||||
assert.deepEqual(
|
||||
SHEETS_MENUS.map((m) => m.id),
|
||||
["file", "edit", "view", "insert", "format", "data", "tools", "gemini", "extensions", "help"],
|
||||
);
|
||||
const html = buildMenuBar({ editable: true, readOnly: false });
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = html;
|
||||
const menus = [...host.querySelectorAll(".xlsx-menubar > .xlsx-menuwrap")];
|
||||
assert.deepEqual(
|
||||
menus.map((w) => w.querySelector(".xlsx-menubtn").textContent.trim()),
|
||||
["Fichier", "Modifier", "Affichage", "Insertion", "Format", "Données", "Outils", "Gemini", "Extensions", "Aide"],
|
||||
);
|
||||
// Every button controls its own dropdown, hidden until opened.
|
||||
menus.forEach((w) => {
|
||||
const btn = w.querySelector(".xlsx-menubtn");
|
||||
const drop = host.querySelector(`#${btn.getAttribute("aria-controls")}`);
|
||||
assert.ok(drop, `${btn.dataset.xlsxMenu} has a dropdown`);
|
||||
assert.equal(drop.getAttribute("role"), "menu");
|
||||
assert.equal(drop.style.display, "none", "dropdown starts closed");
|
||||
assert.equal(btn.getAttribute("aria-expanded"), "false");
|
||||
});
|
||||
});
|
||||
|
||||
await test("the Fichier menu keeps the historical ids and export markers", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
assert.ok(host.querySelector("#xlsx-file-btn"), "file button id kept");
|
||||
assert.ok(host.querySelector("#xlsx-file-menu"), "file menu id kept");
|
||||
assert.ok(host.querySelector("#xlsx-download-btn"), "download entry kept its id");
|
||||
assert.ok(host.querySelector("#xlsx-csv-btn"), "csv entry kept its id");
|
||||
assert.deepEqual(
|
||||
[...host.querySelectorAll("#xlsx-file-menu [data-xlsx-export]")].map((n) => n.dataset.xlsxExport),
|
||||
["md", "html", "print"],
|
||||
);
|
||||
});
|
||||
|
||||
await test("every menu entry is either actionable or honestly flagged", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const rows = [...host.querySelectorAll(".xlsx-menu-item")];
|
||||
assert.ok(rows.length > 120, `all Sheets entries are listed (${rows.length})`);
|
||||
rows.forEach((row) => {
|
||||
const actionable = row.hasAttribute("data-xlsx-action") || row.hasAttribute("data-xlsx-export");
|
||||
const flagged = row.hasAttribute("data-xlsx-unavailable");
|
||||
assert.ok(actionable || flagged || row.hasAttribute("aria-haspopup"),
|
||||
`"${row.textContent.trim()}" is actionable, a section, or flagged unavailable`);
|
||||
});
|
||||
// A declared action always resolves against the viewer's registry (#185).
|
||||
const ACTIONS = [
|
||||
"share", "download", "export-csv", "rename", "trash", "history", "details", "settings", "print",
|
||||
"undo", "redo", "cut", "copy", "paste", "paste-values", "delete-rows", "delete-cols",
|
||||
"clear-contents", "select-all", "find-replace", "freeze", "freeze-row", "freeze-col",
|
||||
"unfreeze", "toggle-formula-bar", "toggle-grid", "toggle-headers", "dashboard", "zoom",
|
||||
"fullscreen", "insert-rows-above", "insert-rows-below", "insert-cols-left",
|
||||
"insert-cols-right", "insert-cells", "link", "comment", "function", "numfmt", "theme-menu",
|
||||
"bold", "italic", "underline", "strike", "fontsize", "align", "valign",
|
||||
"wrap-on", "wrap-off", "rotate", "clear-format", "banded", "sort-asc", "sort-desc",
|
||||
"sort-reset", "filter-column", "named-ranges", "cleanup-dupes", "cleanup-trim",
|
||||
"split-text", "autocomplete", "a11y", "gemini", "help", "whatsnew", "shortcuts", "report",
|
||||
];
|
||||
const declared = new Set(
|
||||
[...host.querySelectorAll("[data-xlsx-action]")].map((n) => n.dataset.xlsxAction),
|
||||
);
|
||||
ACTIONS.forEach((name) => assert.ok(declared.has(name), `menu declares "${name}"`));
|
||||
// "apply-theme" is deliberately absent: the theme list is built on demand.
|
||||
assert.equal(declared.has("apply-theme"), false, "themes are not hard-coded in the markup");
|
||||
});
|
||||
|
||||
await test("Format ▸ Thème is an empty section the viewer fills on demand (#185)", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const row = host.querySelector('[data-xlsx-menu="format"]')
|
||||
.closest(".xlsx-menuwrap")
|
||||
.querySelector('[data-xlsx-action="theme-menu"]');
|
||||
assert.ok(row, "the Thème section exists");
|
||||
const sub = row.nextElementSibling;
|
||||
assert.equal(sub.className, "xlsx-menu-sub", "it is a section, not a leaf");
|
||||
assert.equal(sub.hidden, true, "collapsed");
|
||||
assert.equal(sub.children.length, 0, "empty until the viewer fills it");
|
||||
assert.equal(row.getAttribute("aria-expanded"), "false");
|
||||
// The next entry is untouched by the empty section.
|
||||
assert.ok(host.querySelector('[data-xlsx-action="bold"]'), "Bold still listed");
|
||||
});
|
||||
|
||||
await test("editing entries disappear on a read-only workbook, view entries stay", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: false, readOnly: true });
|
||||
assert.equal(host.querySelector('[data-xlsx-action="bold"]'), null, "no Bold");
|
||||
assert.equal(host.querySelector('[data-xlsx-action="insert-cells"]'), null, "no Insert cells");
|
||||
assert.equal(host.querySelector('[data-xlsx-action="undo"]'), null, "no Undo");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="copy"]'), "Copy stays available");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="zoom"]'), "Zoom stays available");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay available");
|
||||
});
|
||||
|
||||
await test("menu labels come from the i18n catalog, never from raw text", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const labels = [...host.querySelectorAll(".xlsx-menu-label")].map((n) => n.textContent);
|
||||
assert.ok(labels.length > 100);
|
||||
assert.equal(labels.some((l) => /xlsx\./.test(l)), false, "no unresolved key is rendered");
|
||||
assert.ok(labels.includes("Nouveau"), "Google Sheets' own wording is kept");
|
||||
assert.ok(labels.includes("Couleurs alternées"));
|
||||
assert.ok(labels.includes("Diviser le texte en colonnes"));
|
||||
});
|
||||
|
||||
await test("a sheet name or cell value cannot leak HTML into a menu label", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
host.querySelectorAll(".xlsx-menu-label").forEach((n) => {
|
||||
assert.equal(n.querySelector("img, script"), null, "labels stay text nodes");
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} passed`);
|
||||
process.exit(process.exitCode || 0);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
# tests/test_api_main.py — Integration tests for main.py API endpoints
|
||||
# Uses the existing app_with_vault / client fixtures from conftest.py
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Health & Metadata
|
||||
@@ -200,6 +203,45 @@ class TestFileCRUD:
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
|
||||
def test_create_xlsx_empty_workbook(self, client, test_vault_dir):
|
||||
"""#186 - "Create a file" > Excel must produce a readable workbook.
|
||||
|
||||
An .xlsx is a ZIP archive, not a text file: writing the empty payload
|
||||
of the modal as text would leave a file the viewer cannot open.
|
||||
"""
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
resp = client.post("/api/file/TestVault", json={
|
||||
"path": "nouveau.xlsx",
|
||||
"content": "",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
# The archive is a real workbook: one empty sheet, loadable by openpyxl.
|
||||
wb = openpyxl.load_workbook(Path(test_vault_dir) / "nouveau.xlsx")
|
||||
assert wb.sheetnames == ["Feuille1"]
|
||||
|
||||
# And the viewer serves it as a spreadsheet.
|
||||
resp2 = client.get("/api/file/TestVault", params={"path": "nouveau.xlsx"})
|
||||
assert resp2.status_code == 200
|
||||
body = resp2.json()
|
||||
assert body["is_xlsx"] is True
|
||||
assert [s["name"] for s in body["xlsx_sheets"]] == ["Feuille1"]
|
||||
|
||||
def test_create_xlsx_in_new_subdir(self, client):
|
||||
"""#186 - same in a subdirectory that does not exist yet."""
|
||||
pytest.importorskip("openpyxl")
|
||||
|
||||
resp = client.post("/api/file/TestVault", json={
|
||||
"path": "Tableaux/annee.xlsx",
|
||||
"content": "",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
resp2 = client.get("/api/file/TestVault", params={"path": "Tableaux/annee.xlsx"})
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json()["is_xlsx"] is True
|
||||
|
||||
def test_save_file(self, client):
|
||||
"""Save (update) an existing file"""
|
||||
resp = client.put("/api/file/TestVault/save?path=note1.md", json={
|
||||
@@ -803,6 +845,95 @@ class TestSecretRedactor:
|
||||
text = f"value {blob} end"
|
||||
assert redact_file_content(text) == text
|
||||
|
||||
# ── Feature #188 : couverture universelle + clic → copie ──────────
|
||||
|
||||
@pytest.mark.parametrize("secret", [
|
||||
"sk-" + "a" * 30, # OpenAI / OpenRouter
|
||||
"sk_live_" + "b" * 24, # Stripe
|
||||
"whsec_" + "c" * 24, # Stripe / Svix
|
||||
"glpat-" + "d" * 24, # GitLab
|
||||
"github_pat_" + "e" * 30, # GitHub fine-grained
|
||||
"npm_" + "f" * 36, # npm
|
||||
"dckr_pat_" + "g" * 24, # Docker Hub
|
||||
"hf_" + "h" * 32, # Hugging Face
|
||||
"AIza" + "I" * 35, # Google API key
|
||||
"ya29." + "J" * 25, # Google OAuth
|
||||
"xoxb-" + "K" * 24, # Slack
|
||||
"SG." + "L" * 24, # SendGrid
|
||||
"re_" + "M" * 40, # Resend
|
||||
"sq0atp-" + "N" * 24, # Square
|
||||
"ATATT" + "O" * 24, # Atlassian
|
||||
"AKIA" + "P" * 16, # AWS
|
||||
"ASIA" + "Q" * 16, # AWS STS
|
||||
"ghp_" + "R" * 36, # GitHub legacy
|
||||
"123456789:AA" + "S" * 33, # Telegram bot (35 chars after ":")
|
||||
"Bearer " + "T" * 32, # Authorization header
|
||||
])
|
||||
def test_universal_provider_keys_masked(self, secret):
|
||||
"""#188 — the large majority of token formats end up masked."""
|
||||
from backend.secret_redactor import redact_file_content
|
||||
result = redact_file_content(f"here: {secret}")
|
||||
assert secret not in result
|
||||
assert "MASQUÉ" in result
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"password=hunter2",
|
||||
"PASSWORD=short",
|
||||
"db_password=abc12345",
|
||||
'password: "quoted1"',
|
||||
'{"passwd": "jsonpass"}',
|
||||
"passphrase=longenough",
|
||||
"mot de passe=chezmoi",
|
||||
"user_pwd=s3cret",
|
||||
"token=abcdef12", # 8+ char generic values are masked too
|
||||
])
|
||||
def test_passwords_masked_any_length(self, text):
|
||||
"""#188 — passwords are masked whatever their length / quoting."""
|
||||
from backend.secret_redactor import redact_file_content
|
||||
result = redact_file_content(text)
|
||||
assert "MASQUÉ" in result
|
||||
# No raw value left behind
|
||||
assert text.split("=", 1)[-1].split(":", 1)[-1].strip('"\' ') not in result
|
||||
|
||||
def test_pwd_env_var_not_masked(self):
|
||||
"""``PWD=`` is a shell working directory, not a password."""
|
||||
from backend.secret_redactor import redact_file_content
|
||||
text = "PWD=/home/bruno/notes"
|
||||
assert redact_file_content(text) == text
|
||||
|
||||
def test_placeholders_restore_clickable_mask(self):
|
||||
"""#188 — placeholders become spans carrying the real value."""
|
||||
from backend.secret_redactor import redact_with_placeholders, restore_masks
|
||||
secret = "sk-" + "a" * 30
|
||||
text, entries = redact_with_placeholders(f"key: {secret}")
|
||||
assert secret not in text and entries
|
||||
html = restore_masks(text, entries, click_to_copy=True)
|
||||
assert f'data-secret="{secret}"' in html
|
||||
assert 'class="secret-mask"' in html
|
||||
# Plain restore never leaks the value
|
||||
plain = restore_masks(text, entries)
|
||||
assert secret not in plain and "data-secret" not in plain
|
||||
|
||||
def test_render_markdown_masks_clickable_in_app(self):
|
||||
"""#188 — the authenticated preview serves clickable masks."""
|
||||
from backend.render import _render_markdown
|
||||
secret = "sk-" + "a" * 30
|
||||
md = f"```bash\nexport KEY={secret}\n```\n"
|
||||
html = _render_markdown(md, "test_vault", None, click_to_copy=True)
|
||||
# The value only ever appears inside the data-secret attribute
|
||||
assert 'class="secret-mask"' in html
|
||||
assert f'data-secret="{secret}"' in html
|
||||
assert f">{secret}" not in html # never as visible text
|
||||
|
||||
def test_render_markdown_plain_without_click(self):
|
||||
"""Public shares / PDF exports get the plain label only."""
|
||||
from backend.render import _render_markdown
|
||||
secret = "sk-" + "a" * 30
|
||||
html = _render_markdown(f"Cle: {secret}\n", "test_vault", None)
|
||||
assert secret not in html
|
||||
assert "[CLÉ API MASQUÉE]" in html
|
||||
assert "data-secret" not in html
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Static / PWA caching (Cloudflare / mobile freshness)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -255,6 +255,7 @@ class TestDependencySecurityFloors:
|
||||
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
|
||||
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
|
||||
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
|
||||
"multidict": (6, 9, 1), # CVE-2026-104874 (fix 6.9.1 ; 6.7.x toolcache runner)
|
||||
}
|
||||
|
||||
def test_security_floors_are_declared(self):
|
||||
|
||||
@@ -80,3 +80,35 @@ def test_nonce_matches_injected_html(client):
|
||||
nonce = _nonce_of(resp.headers.get("content-security-policy"))
|
||||
assert nonce, path
|
||||
assert f'nonce="{nonce}"' in resp.text, path
|
||||
|
||||
|
||||
def test_docs_inline_script_nonced(client):
|
||||
"""BUG-106 : /docs (script inline d'init Swagger) porte le nonce de son
|
||||
en-tête — sinon script-src sans 'unsafe-inline' rend la page blanche."""
|
||||
resp = client.get("/docs")
|
||||
assert resp.status_code == 200
|
||||
nonce = _nonce_of(resp.headers.get("content-security-policy"))
|
||||
assert nonce
|
||||
assert f'nonce="{nonce}"' in resp.text, "/docs : script inline sans nonce"
|
||||
# Le bundle externe (jsdelivr) reste tel quel, couvert par script-src.
|
||||
assert "<script src=" in resp.text
|
||||
|
||||
|
||||
def test_redoc_served_with_csp(client):
|
||||
"""/redoc n'a aucun script inline (bundle externe seul) : il est servi
|
||||
avec sa CSP sans aucune injection nécessaire."""
|
||||
resp = client.get("/redoc")
|
||||
assert resp.status_code == 200
|
||||
assert _nonce_of(resp.headers.get("content-security-policy"))
|
||||
assert "<script src=" in resp.text
|
||||
assert "<script nonce=" not in resp.text
|
||||
|
||||
|
||||
def test_docs_external_bundle_not_nonced(client):
|
||||
"""L'injection ne touche que les balises <script> exécutables sans src."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
html = '<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/x.js"></script><script>init()</script>'
|
||||
out = inject_csp_nonce(html, NONCE)
|
||||
assert out.count(f'nonce="{NONCE}"') == 1
|
||||
assert '<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/x.js">' in out
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# tests/test_duplicates.py — Duplicate detection & merge (#166)
|
||||
"""Tests for backend.services.duplicates, its REST routes and AI tools.
|
||||
|
||||
Fusion is destructive: the service takes backups first and the tool/route
|
||||
require an explicit confirmation (DANGEROUS + ``confirm: true``).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services import duplicates as _duplicates
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.tools.api import ToolContext, call_tool
|
||||
from backend.tools.context import ToolConfirmationRequired
|
||||
|
||||
|
||||
def _ctx(**kwargs) -> ToolContext:
|
||||
user = {"username": "tester", "role": "admin", "vaults": ["*"]}
|
||||
kwargs.setdefault("audit_enabled", False)
|
||||
return ToolContext(user=user, **kwargs)
|
||||
|
||||
|
||||
class TestSimilarity:
|
||||
def test_identical_texts_score_one(self):
|
||||
text = "# Titre\nContenu identique avec plusieurs mots significatifs."
|
||||
assert _duplicates.similarity_score(text, text) == 1.0
|
||||
|
||||
def test_different_texts_score_low(self):
|
||||
a = "# Recette pizza\nFarine, tomate, mozzarella, four à bois."
|
||||
b = "# Config réseau\nAdresse IP, masque, passerelle, DNS du datacenter."
|
||||
assert _duplicates.similarity_score(a, b) < 0.5
|
||||
|
||||
def test_empty_text_scores_zero(self):
|
||||
assert _duplicates.similarity_score("", "# Titre\ncontenu") == 0.0
|
||||
|
||||
def test_frontmatter_ignored(self):
|
||||
a = "---\ntitle: A\ntags: [x]\n---\n# Note\nContenu commun significatif ici."
|
||||
b = "---\ntitle: B\ntags: [y]\n---\n# Note\nContenu commun significatif ici."
|
||||
assert _duplicates.similarity_score(a, b) > 0.8
|
||||
|
||||
|
||||
class TestFindPairs:
|
||||
def test_finds_planted_duplicates(self, client, test_vault_dir):
|
||||
vault = Path(test_vault_dir)
|
||||
body = "# Rapport mensuel\nVentes en hausse de vingt pour cent ce trimestre."
|
||||
(vault / "rapport-a.md").write_text(body, encoding="utf-8")
|
||||
(vault / "rapport-b.md").write_text(body + "\nLigne complémentaire mineure.", encoding="utf-8")
|
||||
result = _duplicates.find_duplicate_pairs("TestVault", threshold=0.5, limit=20)
|
||||
assert result["vault"] == "TestVault"
|
||||
assert result["files_scanned"] >= 2
|
||||
pair_files = {(p["file_a"], p["file_b"]) for p in result["pairs"]}
|
||||
assert ("rapport-a.md", "rapport-b.md") in pair_files
|
||||
|
||||
def test_threshold_filters(self, client, test_vault_dir):
|
||||
result = _duplicates.find_duplicate_pairs("TestVault", threshold=1.0, limit=20)
|
||||
assert result["pairs"] == []
|
||||
|
||||
def test_invalid_threshold_rejected(self, client):
|
||||
with pytest.raises(ServiceError):
|
||||
_duplicates.find_duplicate_pairs("TestVault", threshold=0.1)
|
||||
|
||||
def test_unknown_vault_rejected(self, client):
|
||||
with pytest.raises(ServiceError):
|
||||
_duplicates.find_duplicate_pairs("NoSuchVault")
|
||||
|
||||
|
||||
class TestMerge:
|
||||
def _plant(self, test_vault_dir: str, name: str, content: str) -> None:
|
||||
Path(test_vault_dir, name).write_text(content, encoding="utf-8")
|
||||
|
||||
def test_merge_append(self, client, test_vault_dir):
|
||||
self._plant(test_vault_dir, "src.md", "# Source\nContenu source unique.")
|
||||
self._plant(test_vault_dir, "dst.md", "# Cible\nContenu cible unique.")
|
||||
result = _duplicates.merge_duplicates("TestVault", "src.md", "dst.md", strategy="append")
|
||||
assert result["strategy"] == "append"
|
||||
assert result["deleted"] == "src.md"
|
||||
assert not Path(test_vault_dir, "src.md").exists()
|
||||
merged = Path(test_vault_dir, "dst.md").read_text(encoding="utf-8")
|
||||
assert "Contenu cible unique" in merged
|
||||
assert "Contenu source unique" in merged
|
||||
|
||||
def test_merge_prefer_target(self, client, test_vault_dir):
|
||||
self._plant(test_vault_dir, "old.md", "# Vieux\nAncien contenu.")
|
||||
self._plant(test_vault_dir, "new.md", "# Neuf\nNouveau contenu.")
|
||||
result = _duplicates.merge_duplicates("TestVault", "old.md", "new.md", strategy="prefer_target")
|
||||
assert result["strategy"] == "prefer_target"
|
||||
assert Path(test_vault_dir, "new.md").read_text(encoding="utf-8") == "# Neuf\nNouveau contenu."
|
||||
assert not Path(test_vault_dir, "old.md").exists()
|
||||
|
||||
def test_merge_same_path_rejected(self, client, test_vault_dir):
|
||||
self._plant(test_vault_dir, "same.md", "# Same\nContenu.")
|
||||
with pytest.raises(ServiceError):
|
||||
_duplicates.merge_duplicates("TestVault", "same.md", "same.md")
|
||||
|
||||
def test_merge_missing_source_rejected(self, client, test_vault_dir):
|
||||
self._plant(test_vault_dir, "exists.md", "# Exists\nContenu.")
|
||||
with pytest.raises(ServiceError):
|
||||
_duplicates.merge_duplicates("TestVault", "missing.md", "exists.md")
|
||||
|
||||
|
||||
class TestDuplicatesApi:
|
||||
def test_list_endpoint(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "dup1.md").write_text("# Doublon\nTexte commun significatif.", encoding="utf-8")
|
||||
(Path(test_vault_dir) / "dup2.md").write_text("# Doublon\nTexte commun significatif.", encoding="utf-8")
|
||||
resp = client.get("/api/duplicates", params={"vault": "TestVault", "threshold": 0.5})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["vault"] == "TestVault"
|
||||
|
||||
def test_merge_requires_confirm(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "m1.md").write_text("# M1\nContenu.", encoding="utf-8")
|
||||
(Path(test_vault_dir) / "m2.md").write_text("# M2\nContenu.", encoding="utf-8")
|
||||
resp = client.post(
|
||||
"/api/duplicates/merge",
|
||||
json={"vault": "TestVault", "source_path": "m1.md", "target_path": "m2.md"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_merge_with_confirm(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "c1.md").write_text("# C1\nContenu un.", encoding="utf-8")
|
||||
(Path(test_vault_dir) / "c2.md").write_text("# C2\nContenu deux.", encoding="utf-8")
|
||||
resp = client.post(
|
||||
"/api/duplicates/merge",
|
||||
json={
|
||||
"vault": "TestVault",
|
||||
"source_path": "c1.md",
|
||||
"target_path": "c2.md",
|
||||
"strategy": "append",
|
||||
"confirm": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["deleted"] == "c1.md"
|
||||
|
||||
|
||||
class TestDuplicateTools:
|
||||
def test_find_tool(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "t1.md").write_text("# Outil\nRecherche de doublons.", encoding="utf-8")
|
||||
result = call_tool(
|
||||
"find_duplicates",
|
||||
_ctx(),
|
||||
{"vault": "TestVault", "threshold": 0.5, "limit": 10},
|
||||
)
|
||||
assert result.ok
|
||||
assert result.data["vault"] == "TestVault"
|
||||
|
||||
def test_merge_tool_requires_confirmation(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "s1.md").write_text("# S1\nContenu.", encoding="utf-8")
|
||||
(Path(test_vault_dir) / "s2.md").write_text("# S2\nContenu.", encoding="utf-8")
|
||||
with pytest.raises(ToolConfirmationRequired):
|
||||
call_tool(
|
||||
"merge_duplicate_notes",
|
||||
_ctx(),
|
||||
{"vault": "TestVault", "source_path": "s1.md", "target_path": "s2.md"},
|
||||
)
|
||||
|
||||
def test_merge_tool_confirmed(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "k1.md").write_text("# K1\nContenu k.", encoding="utf-8")
|
||||
(Path(test_vault_dir) / "k2.md").write_text("# K2\nContenu l.", encoding="utf-8")
|
||||
result = call_tool(
|
||||
"merge_duplicate_notes",
|
||||
_ctx(confirmed=True),
|
||||
{"vault": "TestVault", "source_path": "k1.md", "target_path": "k2.md", "strategy": "append"},
|
||||
)
|
||||
assert result.ok
|
||||
assert result.data["deleted"] == "k1.md"
|
||||
@@ -0,0 +1,202 @@
|
||||
# tests/test_notify_channels.py — External notifications (#168)
|
||||
"""Tests for backend.notify (Discord, Telegram, SMTP, webhook), its REST
|
||||
routes and the ``notify_external`` AI tool.
|
||||
|
||||
Network calls are mocked: no test ever hits the real Internet (hermetic CI).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import notify as _notify
|
||||
from backend.tools.api import ToolContext, call_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_store(tmp_path, monkeypatch):
|
||||
"""Redirect the channel + secret stores to a tmp dir."""
|
||||
channels = tmp_path / "notify_channels.json"
|
||||
secrets = tmp_path / "notify_secrets.json"
|
||||
monkeypatch.setattr(_notify, "CHANNELS_FILE", channels)
|
||||
monkeypatch.setattr(_notify, "SECRETS_FILE", secrets)
|
||||
monkeypatch.setenv("OBSIGATE_WEBHOOK_ALLOW_PRIVATE", "true")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _ctx(**kwargs) -> ToolContext:
|
||||
user = {"username": "tester", "role": "admin", "vaults": ["*"]}
|
||||
kwargs.setdefault("audit_enabled", False)
|
||||
return ToolContext(user=user, **kwargs)
|
||||
|
||||
|
||||
class TestValidation:
|
||||
def test_unknown_type_rejected(self, isolated_store):
|
||||
with pytest.raises(ValueError):
|
||||
_notify.create_channel("x", "slack", {})
|
||||
|
||||
def test_discord_requires_url(self, isolated_store):
|
||||
with pytest.raises(ValueError):
|
||||
_notify.create_channel("d", "discord", {"webhook_url": "not-a-url"})
|
||||
|
||||
def test_telegram_requires_chat(self, isolated_store):
|
||||
with pytest.raises(ValueError):
|
||||
_notify.create_channel("t", "telegram", {})
|
||||
|
||||
def test_smtp_requires_fields(self, isolated_store):
|
||||
with pytest.raises(ValueError):
|
||||
_notify.create_channel("m", "smtp", {"host": "smtp.example.com"})
|
||||
|
||||
def test_bad_trigger_falls_back_to_manual(self, isolated_store):
|
||||
channel = _notify.create_channel(
|
||||
"w",
|
||||
"webhook",
|
||||
{"url": "https://example.com/hook", "triggers": ["nope"]},
|
||||
)
|
||||
assert channel["config"]["triggers"] == ["manual"]
|
||||
|
||||
|
||||
class TestCrud:
|
||||
def test_create_masks_secret(self, isolated_store):
|
||||
channel = _notify.create_channel(
|
||||
"disc",
|
||||
"discord",
|
||||
{
|
||||
"webhook_url": "https://discord.com/api/webhooks/123/abcdef-secret-token",
|
||||
"triggers": ["manual", "schedule_failure"],
|
||||
},
|
||||
)
|
||||
assert channel["has_secret"] is True
|
||||
assert channel["config"]["webhook_url"] == "***"
|
||||
# Le secret vit dans le store dédié, pas dans le fichier public.
|
||||
raw = isolated_store.joinpath("notify_channels.json").read_text(encoding="utf-8")
|
||||
assert "abcdef-secret-token" not in raw
|
||||
|
||||
def test_update_and_delete(self, isolated_store):
|
||||
channel = _notify.create_channel("w", "webhook", {"url": "https://example.com/a"})
|
||||
updated = _notify.update_channel(channel["id"], {"enabled": False})
|
||||
assert updated is not None and updated["enabled"] is False
|
||||
assert _notify.delete_channel(channel["id"]) is True
|
||||
assert _notify.delete_channel(channel["id"]) is False
|
||||
|
||||
def test_update_unknown_returns_none(self, isolated_store):
|
||||
assert _notify.update_channel("nope", {"enabled": True}) is None
|
||||
|
||||
|
||||
class TestDispatch:
|
||||
def test_broadcast_skips_unsubscribed_trigger(self, isolated_store, monkeypatch):
|
||||
_notify.create_channel("w", "webhook", {"url": "https://example.com/a", "triggers": ["manual"]})
|
||||
calls: list = []
|
||||
monkeypatch.setattr(_notify, "send_via_channel", lambda ch, t, m, trig="manual": calls.append(ch["id"]))
|
||||
results = _notify.broadcast("schedule_failure", "T", "M")
|
||||
assert results == []
|
||||
assert calls == []
|
||||
|
||||
def test_broadcast_delivers_and_records_failure(self, isolated_store, monkeypatch):
|
||||
channel = _notify.create_channel(
|
||||
"w", "webhook", {"url": "https://example.com/a", "triggers": ["manual"]}
|
||||
)
|
||||
monkeypatch.setattr(_notify, "send_via_channel", lambda ch, t, m, trig="manual": None)
|
||||
results = _notify.broadcast("manual", "T", "M")
|
||||
assert results == [{"channel_id": channel["id"], "ok": True}]
|
||||
|
||||
def _boom(ch, t, m, trig="manual"):
|
||||
raise RuntimeError("down")
|
||||
|
||||
monkeypatch.setattr(_notify, "send_via_channel", _boom)
|
||||
results = _notify.broadcast("manual", "T", "M")
|
||||
assert results[0]["ok"] is False
|
||||
assert "down" in results[0]["error"]
|
||||
|
||||
def test_smtp_send_uses_smtplib(self, isolated_store, monkeypatch):
|
||||
_notify.create_channel(
|
||||
"mail",
|
||||
"smtp",
|
||||
{
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"from_addr": "[email protected]",
|
||||
"to_addr": "[email protected]",
|
||||
"username": "a",
|
||||
"password": "s3cret-pwd",
|
||||
"triggers": ["manual"],
|
||||
},
|
||||
)
|
||||
sent: dict = {}
|
||||
|
||||
class _FakeSMTP:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
sent["tls"] = True
|
||||
|
||||
def login(self, user, pwd):
|
||||
sent["login"] = (user, pwd)
|
||||
|
||||
def send_message(self, msg):
|
||||
sent["subject"] = msg["Subject"]
|
||||
|
||||
monkeypatch.setattr(_notify.smtplib, "SMTP", _FakeSMTP)
|
||||
results = _notify.broadcast("manual", "Sujet", "Corps")
|
||||
assert results[0]["ok"] is True
|
||||
assert sent["tls"] is True
|
||||
assert sent["login"] == ("a", "s3cret-pwd")
|
||||
assert "Sujet" in sent["subject"]
|
||||
|
||||
|
||||
class TestNotifyTool:
|
||||
def test_tool_broadcasts(self, isolated_store, monkeypatch):
|
||||
monkeypatch.setattr(_notify, "broadcast", lambda trig, t, m: [{"channel_id": "c", "ok": True}])
|
||||
result = call_tool(
|
||||
"notify_external",
|
||||
_ctx(confirmed=True),
|
||||
{"title": "Hello", "message": "World", "trigger": "manual"},
|
||||
)
|
||||
assert result.ok
|
||||
assert result.data["deliveries"] == [{"channel_id": "c", "ok": True}]
|
||||
|
||||
def test_tool_unknown_channel(self, isolated_store):
|
||||
from backend.tools.context import ToolError
|
||||
|
||||
with pytest.raises(ToolError):
|
||||
call_tool(
|
||||
"notify_external",
|
||||
_ctx(confirmed=True),
|
||||
{"title": "H", "message": "M", "channel_id": "unknown"},
|
||||
)
|
||||
|
||||
|
||||
class TestNotifyApi:
|
||||
def test_channels_crud(self, client, isolated_store):
|
||||
created = client.post(
|
||||
"/api/notify/channels",
|
||||
json={"name": "w", "type": "webhook", "config": {"url": "https://example.com/a"}},
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
channel_id = created.json()["id"]
|
||||
assert created.json()["has_secret"] is False
|
||||
|
||||
listed = client.get("/api/notify/channels")
|
||||
assert listed.status_code == 200
|
||||
assert any(c["id"] == channel_id for c in listed.json())
|
||||
|
||||
patched = client.patch(f"/api/notify/channels/{channel_id}", json={"enabled": False})
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["enabled"] is False
|
||||
|
||||
deleted = client.delete(f"/api/notify/channels/{channel_id}")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
def test_create_rejects_bad_type(self, client, isolated_store):
|
||||
resp = client.post("/api/notify/channels", json={"name": "x", "type": "slack", "config": {}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_test_endpoint_no_channel(self, client, isolated_store):
|
||||
resp = client.post("/api/notify/test", json={"title": "T", "message": "M"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["deliveries"] == []
|
||||
@@ -0,0 +1,198 @@
|
||||
# tests/test_scheduler.py — Scheduled tasks, type cron (#170)
|
||||
"""Tests for backend.scheduler, its REST routes and AI tools.
|
||||
|
||||
The file store is redirected to tmp; task actions run against the TestVault
|
||||
fixture vault (create/append reuse the real mutation services).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import scheduler as _scheduler
|
||||
from backend.tools.api import ToolContext, call_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_tasks(tmp_path, monkeypatch):
|
||||
"""Redirect the task store to a tmp file."""
|
||||
tasks_file = tmp_path / "scheduled_tasks.json"
|
||||
monkeypatch.setattr(_scheduler, "TASKS_FILE", tasks_file)
|
||||
monkeypatch.setattr("backend.notify.CHANNELS_FILE", tmp_path / "notify_channels.json")
|
||||
monkeypatch.setattr("backend.notify.SECRETS_FILE", tmp_path / "notify_secrets.json")
|
||||
return tasks_file
|
||||
|
||||
|
||||
def _ctx(**kwargs) -> ToolContext:
|
||||
user = {"username": "tester", "role": "admin", "vaults": ["*"]}
|
||||
kwargs.setdefault("audit_enabled", False)
|
||||
return ToolContext(user=user, **kwargs)
|
||||
|
||||
|
||||
class TestValidation:
|
||||
def test_unknown_action_rejected(self, isolated_tasks):
|
||||
with pytest.raises(ValueError):
|
||||
_scheduler.create_task(
|
||||
"x",
|
||||
{"kind": "launch_rocket", "params": {}},
|
||||
{"kind": "interval_hours", "hours": 1},
|
||||
)
|
||||
|
||||
def test_interval_too_short_rejected(self, isolated_tasks):
|
||||
with pytest.raises(ValueError):
|
||||
_scheduler.create_task(
|
||||
"x",
|
||||
{"kind": "notify", "params": {"title": "T", "message": "M"}},
|
||||
{"kind": "interval_hours", "hours": 0.1},
|
||||
)
|
||||
|
||||
def test_bad_daily_time_rejected(self, isolated_tasks):
|
||||
with pytest.raises(ValueError):
|
||||
_scheduler.create_task(
|
||||
"x",
|
||||
{"kind": "notify", "params": {"title": "T", "message": "M"}},
|
||||
{"kind": "daily_time", "at": "25:00"},
|
||||
)
|
||||
|
||||
|
||||
class TestNextRun:
|
||||
def test_interval_from_now(self, isolated_tasks):
|
||||
now = datetime(2026, 10, 4, 12, 0, tzinfo=timezone.utc)
|
||||
nxt = _scheduler.compute_next_run(
|
||||
{"schedule": {"kind": "interval_hours", "hours": 2}, "last_run_at": None}, now
|
||||
)
|
||||
assert nxt == now + timedelta(hours=2)
|
||||
|
||||
def test_daily_tomorrow_when_passed(self, isolated_tasks):
|
||||
now = datetime(2026, 10, 4, 12, 0, tzinfo=timezone.utc)
|
||||
nxt = _scheduler.compute_next_run({"schedule": {"kind": "daily_time", "at": "08:00"}}, now)
|
||||
assert (nxt - now).total_seconds() == pytest.approx(20 * 3600)
|
||||
|
||||
def test_daily_today_when_upcoming(self, isolated_tasks):
|
||||
now = datetime(2026, 10, 4, 7, 0, tzinfo=timezone.utc)
|
||||
nxt = _scheduler.compute_next_run({"schedule": {"kind": "daily_time", "at": "08:00"}}, now)
|
||||
assert (nxt - now).total_seconds() == pytest.approx(3600)
|
||||
|
||||
|
||||
class TestExecution:
|
||||
def test_create_and_run_append(self, client, test_vault_dir, isolated_tasks):
|
||||
(Path(test_vault_dir) / "journal.md").write_text("# Journal\n", encoding="utf-8")
|
||||
task = _scheduler.create_task(
|
||||
"append",
|
||||
{"kind": "append_to_file", "params": {"vault": "TestVault", "path": "journal.md", "content": "Ligne auto."}},
|
||||
{"kind": "once_at", "at": "2020-01-01T00:00:00"},
|
||||
)
|
||||
outcome = _scheduler.run_task(task["id"], manual=True)
|
||||
assert outcome["ok"] is True
|
||||
assert "Ligne auto." in Path(test_vault_dir, "journal.md").read_text(encoding="utf-8")
|
||||
stored = _scheduler.get_task(task["id"])
|
||||
assert stored is not None and stored["last_status"] == "ok"
|
||||
assert stored["run_count"] == 1
|
||||
assert stored["enabled"] is False # one-shot consommé
|
||||
|
||||
def test_tick_runs_due_task(self, client, test_vault_dir, isolated_tasks):
|
||||
task = _scheduler.create_task(
|
||||
"once",
|
||||
{"kind": "create_file", "params": {"vault": "TestVault", "path": "auto/tache.md", "content": "auto"}},
|
||||
{"kind": "once_at", "at": "2020-01-01T00:00:00"},
|
||||
)
|
||||
outcomes = _scheduler.tick()
|
||||
assert any(o.get("task_id") == task["id"] and o.get("ok") for o in outcomes)
|
||||
assert Path(test_vault_dir, "auto", "tache.md").exists()
|
||||
|
||||
def test_failure_recorded(self, client, isolated_tasks):
|
||||
task = _scheduler.create_task(
|
||||
"fail",
|
||||
{"kind": "append_to_file", "params": {"vault": "TestVault", "path": "missing/nope.md", "content": "x"}},
|
||||
{"kind": "once_at", "at": "2020-01-01T00:00:00"},
|
||||
)
|
||||
outcome = _scheduler.run_task(task["id"], manual=True)
|
||||
assert outcome["ok"] is False
|
||||
stored = _scheduler.get_task(task["id"])
|
||||
assert stored is not None and stored["last_status"] == "error"
|
||||
assert stored["last_error"]
|
||||
|
||||
def test_run_unknown_raises(self, isolated_tasks):
|
||||
with pytest.raises(KeyError):
|
||||
_scheduler.run_task("nope", manual=True)
|
||||
|
||||
def test_delete(self, isolated_tasks):
|
||||
task = _scheduler.create_task(
|
||||
"del",
|
||||
{"kind": "notify", "params": {"title": "T", "message": "M"}},
|
||||
{"kind": "interval_hours", "hours": 24},
|
||||
)
|
||||
assert _scheduler.delete_task(task["id"]) is True
|
||||
assert _scheduler.delete_task(task["id"]) is False
|
||||
|
||||
|
||||
class TestSchedulerApi:
|
||||
def test_crud_and_run(self, client, test_vault_dir, isolated_tasks):
|
||||
created = client.post(
|
||||
"/api/scheduler/tasks",
|
||||
json={
|
||||
"name": "t",
|
||||
"action": {
|
||||
"kind": "create_file",
|
||||
"params": {"vault": "TestVault", "path": "sched/api.md", "content": "hello"},
|
||||
},
|
||||
"schedule": {"kind": "interval_hours", "hours": 24},
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
task_id = created.json()["id"]
|
||||
|
||||
listed = client.get("/api/scheduler/tasks")
|
||||
assert listed.status_code == 200
|
||||
assert any(t["id"] == task_id for t in listed.json())
|
||||
|
||||
run = client.post(f"/api/scheduler/tasks/{task_id}/run")
|
||||
assert run.status_code == 200, run.text
|
||||
assert run.json()["ok"] is True
|
||||
assert Path(test_vault_dir, "sched", "api.md").exists()
|
||||
|
||||
deleted = client.delete(f"/api/scheduler/tasks/{task_id}")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
def test_create_rejects_unknown_vault(self, client, isolated_tasks):
|
||||
resp = client.post(
|
||||
"/api/scheduler/tasks",
|
||||
json={
|
||||
"name": "t",
|
||||
"action": {"kind": "create_file", "params": {"vault": "NoVault", "path": "x.md"}},
|
||||
"schedule": {"kind": "interval_hours", "hours": 24},
|
||||
},
|
||||
)
|
||||
assert resp.status_code in (400, 403, 404)
|
||||
|
||||
def test_run_unknown_404(self, client, isolated_tasks):
|
||||
assert client.post("/api/scheduler/tasks/nope/run").status_code == 404
|
||||
|
||||
|
||||
class TestSchedulerTools:
|
||||
def test_create_list_delete_run(self, client, test_vault_dir, isolated_tasks):
|
||||
created = call_tool(
|
||||
"create_scheduled_task",
|
||||
_ctx(confirmed=True),
|
||||
{
|
||||
"name": "tool-task",
|
||||
"action": {
|
||||
"kind": "create_file",
|
||||
"params": {"vault": "TestVault", "path": "sched/tool.md", "content": "hi"},
|
||||
},
|
||||
"schedule": {"kind": "interval_hours", "hours": 24},
|
||||
},
|
||||
)
|
||||
assert created.ok
|
||||
task_id = created.data["id"]
|
||||
|
||||
listed = call_tool("list_scheduled_tasks", _ctx(), {})
|
||||
assert listed.ok
|
||||
assert any(t["id"] == task_id for t in listed.data)
|
||||
|
||||
ran = call_tool("run_scheduled_task_now", _ctx(confirmed=True), {"task_id": task_id})
|
||||
assert ran.ok and ran.data["ok"] is True
|
||||
|
||||
deleted = call_tool("delete_scheduled_task", _ctx(confirmed=True), {"task_id": task_id})
|
||||
assert deleted.ok
|
||||
@@ -93,6 +93,16 @@ class TestRegistry:
|
||||
props = read["function"]["parameters"]["properties"]
|
||||
assert "vault" in props and "path" in props
|
||||
|
||||
def test_schemas_cached_but_caller_safe(self):
|
||||
"""#187: repeated calls hit the cache and a caller mutating its copy
|
||||
(e.g. stripping keys) must never corrupt the next request."""
|
||||
a = get_tool_schemas()
|
||||
b = get_tool_schemas()
|
||||
assert a == b
|
||||
assert all(x is not y for x, y in zip(a, b)), "top-level dicts must be fresh copies"
|
||||
a[0]["poisoned"] = True
|
||||
assert "poisoned" not in get_tool_schemas()[0]
|
||||
|
||||
def test_duplicate_tool_name_raises(self):
|
||||
from backend.tools.registry import tool
|
||||
|
||||
|
||||
@@ -129,6 +129,36 @@ class TestRowColOps:
|
||||
assert wb["Data"]["B1"].value == "Valeur"
|
||||
wb.close()
|
||||
|
||||
def test_insert_past_the_last_row_and_column_takes_effect(self, client, book):
|
||||
# openpyxl's sparse model silently ignores inserts past the used
|
||||
# extent: the service materialises the missing rows/columns first so
|
||||
# « below the last row » / « right of the last column » behave.
|
||||
wb = openpyxl.load_workbook(book)
|
||||
last_row, last_col = wb["Data"].max_row, wb["Data"].max_column
|
||||
wb.close()
|
||||
assert _put(client, actions=[
|
||||
{"op": "col_insert", "sheet": "Data", "at": last_col + 1},
|
||||
]).status_code == 200
|
||||
assert _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": last_row + 1},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
ws = wb["Data"]
|
||||
assert ws.max_column == last_col + 1, "one blank column appended"
|
||||
assert ws.max_row == last_row + 1, "one blank row appended"
|
||||
wb.close()
|
||||
|
||||
def test_insert_far_past_the_extent_extends_the_sheet(self, client, book):
|
||||
wb = openpyxl.load_workbook(book)
|
||||
last_row = wb["Data"].max_row
|
||||
wb.close()
|
||||
assert _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": last_row + 3},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data"].max_row >= last_row + 3
|
||||
wb.close()
|
||||
|
||||
|
||||
class TestGuards:
|
||||
def test_unknown_sheet_is_400(self, client, book):
|
||||
|
||||
@@ -250,6 +250,115 @@ class TestStyleWriting:
|
||||
assert backups, "the rewrite is backed up first"
|
||||
|
||||
|
||||
class TestExtendedStyles:
|
||||
"""Nouvelles clés de style (barre façon Sheets) : taille, barré,
|
||||
alignements verticaux, retour ligne, rotation, bordures — et commentaires."""
|
||||
|
||||
def _style(self, client, path, ops, **extra):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/style",
|
||||
params={"path": path},
|
||||
json={"ops": ops, **extra},
|
||||
)
|
||||
|
||||
def test_font_size_strike_valign_wrap_rotation(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{
|
||||
"op": "cell", "sheet": "Data", "range": "A1",
|
||||
"style": {
|
||||
"font_size": 14, "strike": True, "valign": "middle",
|
||||
"wrap": True, "rotation": -45,
|
||||
},
|
||||
},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
cell = wb["Data"]["A1"]
|
||||
assert cell.font.size == 14
|
||||
assert cell.font.strike is True
|
||||
assert cell.alignment.vertical == "center"
|
||||
assert cell.alignment.wrap_text is True
|
||||
assert cell.alignment.textRotation == 135
|
||||
wb.close()
|
||||
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert "font-size:14pt" in sheet["styles"]["A1"]
|
||||
assert "line-through" in sheet["styles"]["A1"]
|
||||
assert "vertical-align:middle" in sheet["styles"]["A1"]
|
||||
assert "white-space:normal" in sheet["styles"]["A1"]
|
||||
# La diagonale (-45° → 135 OOXML) est relue en `rotate()` CSS.
|
||||
assert "transform:rotate(45deg)" in sheet["styles"]["A1"]
|
||||
|
||||
def test_borders_all_outer_none(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1:B2",
|
||||
"style": {"border": "all", "border_color": "#112233"}},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
side = wb["Data"]["A1"].border.left
|
||||
assert side.style == "thin" and side.color.rgb == "FF112233"
|
||||
wb.close()
|
||||
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1:B2", "style": {"border": "none"}},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
cleared = wb["Data"]["A1"].border.left
|
||||
assert cleared is None or cleared.style is None
|
||||
wb.close()
|
||||
|
||||
def test_comment_set_clear_and_readback(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": "À vérifier"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
assert wb["Data"]["B2"].comment.text == "À vérifier"
|
||||
wb.close()
|
||||
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
assert resp.json()["xlsx_sheets"][0]["comments"]["B2"] == "À vérifier"
|
||||
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_clear", "sheet": "Data", "ref": "B2"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
assert wb["Data"]["B2"].comment is None
|
||||
wb.close()
|
||||
|
||||
def test_formats_map_lists_number_formats(self, client, plain_book):
|
||||
self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "B2", "style": {"number_format": "0.00"}},
|
||||
])
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
assert resp.json()["xlsx_sheets"][0]["formats"]["B2"] == "0.00"
|
||||
|
||||
def test_extended_invalid_inputs_are_refused(self, client, plain_book):
|
||||
style = lambda s: self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": s},
|
||||
]).status_code
|
||||
assert style({"font_size": 200}) == 400
|
||||
assert style({"font_size": "grand"}) == 400
|
||||
assert style({"valign": "diagonal"}) == 400
|
||||
# 180° (texte vertical retourné, relu tel quel) est accepté — seules
|
||||
# les valeurs vraiment hors bornes sont refusées.
|
||||
assert style({"rotation": 180}) == 200
|
||||
assert style({"rotation": 181}) == 400
|
||||
assert style({"rotation": -91}) == 400
|
||||
assert style({"border": "dotted-circle"}) == 400
|
||||
assert style({"border_style": "wavy"}) == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": ""},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_set", "sheet": "Data", "ref": "B2:C3", "text": "x"},
|
||||
]).status_code == 400
|
||||
|
||||
|
||||
class TestEndpointWiring:
|
||||
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
|
||||
|
||||
Reference in New Issue
Block a user