Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2813d546a7 | ||
|
|
19e94dfa51 | ||
|
|
49f97fc26e | ||
|
|
562290d922 | ||
|
|
66a5505965 | ||
|
|
c5c225a68e | ||
|
|
1bacfd69d9 | ||
|
|
383ffa6a65 | ||
|
|
69927176df | ||
|
|
5c2ae26a74 | ||
|
|
3f52b56251 |
+13
-8
@@ -58,6 +58,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -78,6 +79,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -168,17 +170,20 @@ jobs:
|
||||
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
|
||||
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
|
||||
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
|
||||
# s'exécutent jamais. PYSEC-2026-178 (pyjwt) est, lui, corrigé par le
|
||||
# plancher pyjwt>=2.13.0 de backend/requirements.txt (BUG-091).
|
||||
# s'exécutent jamais. Les advisories pyjwt (PYSEC-2026-178 puis
|
||||
# CVE-2026-102274) sont corrigées par le plancher pyjwt>=2.14.0 de
|
||||
# backend/requirements.txt (BUG-091, BUG-095).
|
||||
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
||||
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
||||
# via backend/pdf_reader.py) sont corrigés par le plancher
|
||||
# pypdf>=6.16.1 (BUG-093). Ces planchers doivent rester *au-dessus*
|
||||
# des versions préinstallées dans la toolcache de l'image du runner :
|
||||
# en dessous, pip répond « already satisfied » et n'aligne jamais
|
||||
# (c'est exactement ce qui a fait échouer ce job). Le garde-fou
|
||||
# tests/test_ci_workflow.py::TestDependencySecurityFloors verrouille
|
||||
# ces planchers.
|
||||
# pypdf>=6.16.1 (BUG-093).
|
||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||
# corrigés par le plancher urllib3>=2.8.0.
|
||||
# Ces planchers doivent rester *au-dessus* des versions préinstallées
|
||||
# dans la toolcache de l'image du runner : en dessous, pip répond
|
||||
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
|
||||
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
|
||||
# TestDependencySecurityFloors verrouille ces planchers.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
|
||||
|
||||
+215
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.43.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.46.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,220 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.46.0] — 2026-10-01
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#157 — facette « Extensions » dans les facettes des résultats de recherche.** Le panneau de
|
||||
facettes de la page de résultats (Vaults · Tags) gagne un troisième groupe qui liste les
|
||||
extensions de fichiers présents dans les résultats (`.md`, `.xlsx`, `.pdf`, …) avec leurs
|
||||
compteurs ; cliquer une extension ajoute l'opérateur `ext:` à la requête (en remplaçant un
|
||||
`ext:` déjà actif). Le backend calcule `facets.extensions` dans `backend/search.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.45.2] — 2026-10-01
|
||||
|
||||
---
|
||||
|
||||
## [2.45.1] — 2026-10-01
|
||||
|
||||
---
|
||||
|
||||
## [2.45.0] — 2026-09-30
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#156-A5 — presse-papiers de plage dans l'éditeur Excel.** Copier, couper et coller un
|
||||
**bloc** de cellules (TSV), au clavier (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`) comme par le menu
|
||||
contextuel ; le collage remplit la plage à partir de la cellule active et la laisse
|
||||
sélectionnée. La grille tient son propre presse-papiers (le presse-papiers système est
|
||||
synchronisé quand le navigateur l'autorise), le collage est toujours du **texte** — jamais de
|
||||
HTML — et il est **annulable**. Un bloc plus large que la grille affichée est tronqué et signalé.
|
||||
- **#156-A6 — raccourcis clavier complets.** `Ctrl+S` (enregistrer), `Ctrl+A` (sélectionner la
|
||||
feuille rendue), `Suppr` (vider la sélection), `F2` (édition), `Ctrl+Home`/`Ctrl+End` (coins de
|
||||
la feuille), `Home`/`End` (bords de la ligne), `PgUp`/`PgDn` (un écran), `Ctrl+flèches` (bout de
|
||||
la plage de données) et `Maj+Entrée` (saut de ligne dans la cellule). Une cellule reste en mode
|
||||
« navigation » jusqu'à la saisie (ou `F2`) : `Suppr` et `Home`/`End` ne volent donc jamais une
|
||||
touche du curseur de texte.
|
||||
- **#156-A7 — zone Nom éditable et aide à la saisie.** La zone Nom devient un champ
|
||||
(« Atteindre ») : saisir `B12`, `A1:B3` ou `Feuille!A1` puis `Entrée` sélectionne la cellule ou
|
||||
la plage (avec changement d'onglet si la feuille est nommée) ; une référence inconnue est
|
||||
refusée avec un message et l'adresse précédente restaurée. La barre de formule propose les noms
|
||||
de fonctions courants (liste localisée FR/EN).
|
||||
- **#156-A8 — mise en forme en écriture.** Un bouton **Mise en forme** ouvre un menu qui
|
||||
agit sur la **sélection courante** (une cellule ou une plage) : gras, italique, souligné,
|
||||
effacement de la mise en forme, alignement gauche/centré/droite, couleur de police et de fond,
|
||||
format de nombre (général, nombre, pourcentage, devise, date, texte), fusion/défusion, volets
|
||||
figés/libérés et largeur de colonne / hauteur de ligne. L'écriture passe par une nouvelle route
|
||||
`PUT /api/file/{vault}/xlsx/style`, sur le même modèle que `xlsx/save` et `xlsx/structure` :
|
||||
verrou par fichier, écriture atomique (`.tmp` + `os.replace`), backup automatique, garde de perte
|
||||
(une opération qui effacerait des éléments non préservables est refusée sans `force`) et
|
||||
**concurrence optimiste** (`If-Match`, cf. A12). Les mises en forme ne sont pas proposées sur un
|
||||
`.csv` (le format n'en porte pas) ni sur une grille en lecture seule. Les couleurs viennent du
|
||||
sélecteur natif, jamais d'une palette codée en dur. Tests : `tests/test_xlsx_styles.py` (17) et
|
||||
JSDOM (application d'un format, refus d'un CSV, garde de perte).
|
||||
- **#156-A9 — le calcul est explicitement hors périmètre, et annoncé.** L'éditeur n'embarque
|
||||
aucun moteur de formule : ObsiGate affiche et écrit les formules telles qu'Excel les a
|
||||
enregistrées, les cellules dépendantes ne se recalculent pas à l'écran. Le choix est désormais
|
||||
**dit à l'utilisateur** : un enregistrement contenant des valeurs commençant par `=` ou `@`
|
||||
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé (la garde
|
||||
anti-DDE de BUG-088 reste en place). La pastille « Formules non recalculées » et la fiche #156
|
||||
portent la décision. Tests JSDOM : l'avertissement apparaît, et disparaît une fois `f(x)`
|
||||
activé.
|
||||
- **#156-A10 — historique annuler/rétablir unifié, conservé au re-rendu.** La pile ne couvrait
|
||||
que les éditions de cellules : « Effacer le contenu », le tri, le filtre et les actions de
|
||||
**structure** y entrent désormais, avec des entrées typées (`cell`, `order`, `hidden`,
|
||||
`structure`). Les piles sont **conservées par fichier** (8 fichiers, `_xlsxHistory`) au lieu
|
||||
d'être vidées par un re-rendu ; `Suppr` passe par le même chemin (donc annulable) ; le filtre
|
||||
est **coalescé** en une seule entrée par session de frappe ; un rejeu de structure appelle
|
||||
l'endpoint en mode silencieux (pas de doublon d'historique ni de bandeau parasite) et une
|
||||
action destructrice (suppression de ligne/colonne/feuille) n'est **pas** annulable, faute
|
||||
d'inverse. Tests JSDOM : édition → effacement → tri annulés successivement, insertion de ligne
|
||||
rejouée par l'endpoint, action destructive non annulable, filtre annulé par un seul `Ctrl+Z`.
|
||||
- **#156-A11 — sortie (CSV, Markdown, HTML, impression) et recherche sur tout le classeur.**
|
||||
L'export ne se limite plus à la feuille entière : le bouton **CSV** exporte la **sélection**
|
||||
quand une plage de plusieurs cellules est active (nom de fichier `Feuille-A1B2.csv`), sinon la
|
||||
feuille ; un menu **Exporter** ajoute le **Markdown** (tableau), le **HTML** (document
|
||||
autonome) et l'**impression** (la feuille seule, sans le ruban, via un cadre hors écran). Les
|
||||
quatre formats suivent le **contenu affiché** (la valeur calculée en cache reste une
|
||||
infobulle). L'écart doc/code de #153-A13 (« export de la sélection ») est tranché : la
|
||||
sélection est le défaut quand elle existe. La recherche (`Ctrl+F`) parcourt désormais **toutes
|
||||
les feuilles** : compteur « n/m · k feuilles » et activation automatique de l'onglet de la
|
||||
correspondance atteinte.
|
||||
- **#156-A14 — l'assistant IA couvre les trois formats édités et gagne trois outils.** Les
|
||||
outils existants acceptent `.xlsm` (écriture avec macros préservées) et `.csv` (lecture et
|
||||
écriture, délimiteur conservé) au lieu du seul `.xlsx` ; `update_xlsx_cells` écrit un `.csv`
|
||||
via `save_csv_cells`. Nouveaux outils : `search_workbook` (recherche dans toutes les feuilles,
|
||||
comptée par feuille), `analyze_range` (nombre, somme, moyenne, min, max d'une plage A1, avec
|
||||
échantillon borné) et `edit_xlsx_structure` (ajouter/renommer/dupliquer/supprimer une feuille,
|
||||
insérer/supprimer lignes et colonnes) — les deux premiers en risque READ, le dernier conservant
|
||||
la confirmation obligatoire des outils mutants. Tests `tests/test_spreadsheet_tools.py` (34).
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#156 — Éditeur Excel : audit de complétude, roadmap et 4 défauts enregistrés (documentation
|
||||
seule).** Nouvelle fiche `docs/features/xlsx-editor-completeness.md` (audit par couche, manques
|
||||
fonctionnels, risques, critères d'acceptation) et item **#156** dans `docs/ROADMAP.md`
|
||||
(P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3
|
||||
sortie/robustesse/perf). **BUG-096 → BUG-099** ouverts dans `docs/ISSUES_TODOLIST.md` :
|
||||
enregistrer un `.csv` depuis la visionneuse lève un `TypeError` (reproduit en JSDOM, aucun
|
||||
`PUT …/csv/save` émis) ; « Charger la suite » d'une feuille `.xlsm` tronquée répond **415** ;
|
||||
le délimiteur CSV est figé sur `,` alors que l'export écrit `;` ; la sonde de perte plafonnée à
|
||||
8 Mo peut laisser passer une écriture qui détruit les valeurs calculées en cache. L'audit
|
||||
lui-même ne changeait aucun code ; les correctifs correspondants suivent ci-dessous.
|
||||
- **#156-A12 — concurrence optimiste entre processus (`If-Match`, 409 réparable).** Chaque
|
||||
lecture renvoie la **version** du fichier (`xlsx_revision` : `mtime_ns` + taille) et les trois
|
||||
routes d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête `If-Match` ou
|
||||
le champ `if_match` : si le fichier a changé depuis, l'écriture est refusée en **409**
|
||||
`conflict` (`details.reason = "stale_revision"`, versions attendue et courante) au lieu
|
||||
d'écraser l'autre écrivain. Le bandeau « Réessayer » **relit** le fichier puis rejoue la
|
||||
sauvegarde avec la version courante ; les réponses de succès renvoient la nouvelle version, que
|
||||
le client enchaîne sur les écritures suivantes. Tests : `TestOptimisticConcurrency` (6) et
|
||||
JSDOM (version lue, version rafraîchie par la réponse, relecture avant le retry).
|
||||
- **#156-A13 — cache des métadonnées par `mtime`.** `read_workbook_meta()` rechargeait le
|
||||
classeur entier à chaque fenêtre servie ; les métadonnées (styles, fusions, volets, largeurs)
|
||||
sont désormais mémoïsées par `(chemin, mtime_ns, taille)` (LRU de 8) et invalidées à chaque
|
||||
écriture (cellules, structure, CSV). Charger une fenêtre ne rescanne plus le classeur. L'axe
|
||||
des colonnes au-delà de `MAX_COLS` reste hors périmètre (voir la fiche #156). Tests :
|
||||
`TestMetaCache` (4).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-096 — enregistrer un `.csv` depuis la visionneuse échoue (`TypeError`, aucun
|
||||
`PUT …/csv/save` émis).** Le job de sauvegarde lisait le nom de la feuille dans
|
||||
`xlsx_sheets` — absent du payload d'un CSV — donc `sheets[0].name` levait un `TypeError`
|
||||
**avant** toute requête : aucune modification de CSV ne pouvait être enregistrée. Le nom est
|
||||
désormais résolu avec repli (feuille → nom du fichier) ; l'endpoint `csv/save` est inchangé.
|
||||
Tests : JSDOM (un CSV monté **sans** `xlsx_sheets` émet un unique `PUT …/csv/save`).
|
||||
- **BUG-097 — « Charger la suite » d'une feuille `.xlsm` renvoyait **415**.** Un `.xlsm`
|
||||
s'édite dans la même visionneuse que le `.xlsx`, mais `GET …/xlsx/sheet` refusait tout autre
|
||||
format : les lignes au-delà du plafond de 500 étaient inaccessibles. L'endpoint accepte
|
||||
désormais `.xlsx` **et** `.xlsm` (les autres formats restent en **415**). Tests :
|
||||
`test_xlsx_formats.py` (fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`).
|
||||
- **BUG-098 — un CSV français (`;`) s'affichait en une seule colonne.** Le délimiteur était
|
||||
figé à la virgule, en lecture comme en réécriture. `sniff_csv_delimiter()` détecte `;`, `,`
|
||||
ou tabulation (repli : décompte sur la première ligne, puis `,`) et `save_csv_cells()`
|
||||
**réutilise le délimiteur détecté** — un CSV point-virgule s'affiche en colonnes distinctes et
|
||||
le reste après édition. Tests : `test_xlsx_formats.py` (lecture, écriture, champs entre
|
||||
guillemets contenant le séparateur).
|
||||
- **BUG-099 — perte silencieuse possible des valeurs calculées en cache (sonde plafonnée).**
|
||||
La détection des `<f>…</f><v>…</v>` disposait d'un budget **unique** de 8 Mo partagé entre les
|
||||
feuilles : au-delà, un résultat négatif était pris pour une preuve et l'écriture détruisait
|
||||
les valeurs mises en cache sans avertissement. Le budget est désormais **par feuille** (4 Mo)
|
||||
avec un plafond global (32 Mo), et un budget épuisé est **signalé** (`cached_values_unverified`,
|
||||
libellé FR/EN) : la garde de sauvegarde demande une confirmation au lieu de laisser passer.
|
||||
Tests : `TestXlsxCachedValueProbe` (budget épuisé → signalé, feuille volumineuse → la suivante
|
||||
reste scannée, scan complet propre → aucun faux positif).
|
||||
|
||||
---
|
||||
|
||||
## [2.44.1] — 2026-09-29
|
||||
|
||||
### Sécurité
|
||||
|
||||
- **BUG-095 — le job CI `security` repasse au vert : `pyjwt` monté au-dessus du dernier
|
||||
correctif.** `pip-audit` bloquait sur **CVE-2026-102274** dans `pyjwt` 2.13.0 (correctif
|
||||
**2.14.0**) : le plancher introduit par BUG-091 (`>=2.13.0`, qui corrigeait PYSEC-2026-178)
|
||||
était devenu insuffisant. Le plancher passe à `pyjwt[crypto]>=2.14.0` — volontairement au-dessus
|
||||
de la version préinstallée dans la toolcache du runner (sinon `pip` répond « already satisfied »
|
||||
et n'aligne jamais la version). Le garde-fou
|
||||
`tests/test_ci_workflow.py::TestDependencySecurityFloors` verrouille ce nouveau plancher
|
||||
(contre-preuve : plancher remis à `2.13.0` → test rouge).
|
||||
|
||||
---
|
||||
|
||||
## [2.44.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#155 — Ergonomie du tableur : menu contextuel (clic droit / appui long) et sélection type
|
||||
Excel.** Un **menu contextuel** thémé (nouveau module `frontend/js/xlsx/context-menu.js`)
|
||||
s'ouvre au **clic droit** sur une cellule, un numéro de ligne ou un en-tête de colonne, et par
|
||||
**appui long** sur mobile : insérer une ligne au-dessus/en dessous, une colonne à gauche/à
|
||||
droite (avec `count` pour une plage), supprimer la/les ligne(s) ou colonne(s) après confirmation,
|
||||
trier A→Z / Z→A et effacer le contenu. La grille adopte la **sélection d'Excel** : plage par
|
||||
**glisser**, extension par **`Maj+clic`** et **`Maj+flèches`**, sélection de la ligne/colonne
|
||||
entière au clic sur son en-tête, **curseur croix** sur les cellules et zone **Nom** affichant la
|
||||
plage (`A1:B3`). Le tri par simple clic sur l'en-tête laisse place à la sélection (tri déplacé
|
||||
dans le menu contextuel). `SW_VERSION` passe à `v29`. Tests JSDOM
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (60, dont 8 nouveaux) et E2E (9) verts.
|
||||
|
||||
---
|
||||
|
||||
## [2.43.2] — 2026-09-29
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-094 — une feuille `.xlsx` vide ou nouvellement ajoutée est désormais éditable et
|
||||
manipulable.** Elle s'affichait comme un simple « Feuille vide » sans aucune cellule : il était
|
||||
donc impossible d'y saisir une valeur ou d'y insérer une ligne/colonne (aucune cellule active →
|
||||
aucune action de structure). `render_sheets()` substitue maintenant un quadrillage vierge
|
||||
**20×8** aux vraies coordonnées A1 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`), et la
|
||||
visionneuse retombe sur `{row:1,col:1}` quand aucune cellule n'est active, de sorte que le menu
|
||||
Structure propose toujours insérer/supprimer ligne et colonne. Test de non-régression :
|
||||
`TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid`.
|
||||
|
||||
---
|
||||
|
||||
## [2.43.1] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 5, clôture) : extraction modulaire et
|
||||
finitions.**
|
||||
Les unités **sans état** de la visionneuse sont extraites dans `frontend/js/xlsx/` :
|
||||
`refs.js` (références A1), `command-bar.js` (onglets + ruban + pastilles d'état) et
|
||||
`dashboard.js` (rendu du tableau de bord) — le noyau avec état reste dans `viewer.js` à
|
||||
comportement constant. Le tableau de bord devient interactif : **cliquer une plage nommée
|
||||
sélectionne et révèle sa première cellule** dans la grille (changement d'onglet si nécessaire).
|
||||
L'inspecteur est **redimensionnable** (largeur mémorisée par session). `SW_VERSION` passe à
|
||||
`v28` et les nouveaux modules entrent dans le pré-cache. Tests JSDOM
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (51) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
|
||||
Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [2.43.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
+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/)
|
||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation clavier, barre de formule, tri/filtre/recherche, export CSV, édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`. L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, modifier des cellules et ajouter des lignes
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.43.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.46.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.43.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.46.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/)
|
||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation, a formula bar, sort/filter/find, CSV export, sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled. The AI assistant can list sheets, dump a bounded table to its context, update cells and append rows
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.43.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.46.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.43.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.46.0 | Last updated: September 2026*
|
||||
|
||||
@@ -185,6 +185,17 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/style"): {
|
||||
"request": {
|
||||
"ops": [
|
||||
{"op": "cell", "sheet": "Budget", "range": "A1:B1", "style": {"bold": True, "fill_color": "#ffe08a"}},
|
||||
{"op": "col_width", "sheet": "Budget", "col": "A", "width": 24},
|
||||
],
|
||||
"force": False,
|
||||
"if_match": "18f2c0ab-1f4",
|
||||
},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 2, "revision": "18f2c0ab-1f6"},
|
||||
},
|
||||
# GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI
|
||||
# invalide) — les paramètres sont documentés par leurs Query().
|
||||
("get", "/api/file/{vault_name}/xlsx/sheet"): {
|
||||
|
||||
@@ -26,9 +26,10 @@ webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
mcp==1.28.1
|
||||
# Plancher de sécurité (BUG-091) : pyjwt est une dépendance transitive (mcp) ;
|
||||
# 2.12.x est vulnérable (PYSEC-2026-178, fix 2.13.0) et pip-audit bloque sinon.
|
||||
pyjwt[crypto]>=2.13.0
|
||||
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
|
||||
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
|
||||
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
|
||||
pyjwt[crypto]>=2.14.0
|
||||
sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
xlrd==2.0.2
|
||||
@@ -36,3 +37,5 @@ odfpy==1.4.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||
urllib3>=2.8.0
|
||||
|
||||
@@ -42,6 +42,7 @@ from backend.schemas import (
|
||||
XlsxSheetWindowResponse,
|
||||
)
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
|
||||
@@ -226,7 +227,7 @@ def api_file_xlsx_dashboard(
|
||||
)
|
||||
def api_file_xlsx_sheet(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
|
||||
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
|
||||
limit: int = Query(
|
||||
@@ -234,7 +235,7 @@ def api_file_xlsx_sheet(
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a window of rows of one sheet of an .xlsx workbook (#153 A9).
|
||||
"""Return a window of rows of one sheet of an .xlsx/.xlsm workbook (#153 A9).
|
||||
|
||||
Backs the viewer's lazy loading: instead of every sheet in a single JSON
|
||||
payload, the client asks for the block it is about to display. The row
|
||||
@@ -258,7 +259,7 @@ def api_file_xlsx_sheet(
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
|
||||
415 (not an .xlsx file), 500 (unreadable workbook).
|
||||
415 (not an .xlsx/.xlsm file), 500 (unreadable workbook).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -269,8 +270,12 @@ def api_file_xlsx_sheet(
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".xlsx":
|
||||
raise HTTPException(status_code=415, detail="Le fichier n'est pas un classeur .xlsx")
|
||||
# BUG-097 — a .xlsm rides the same editable viewer (and its lazy loading),
|
||||
# so its row windows must be servable too; other formats stay refused.
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
|
||||
from backend.xlsx_reader import read_sheet_window
|
||||
@@ -368,6 +373,10 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
# #156-A12 — optimistic-concurrency token: the viewer sends it
|
||||
# back as `if_match` so another writer cannot be overwritten in
|
||||
# silence (409 `conflict` instead).
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# #153 A1 — parts a save would drop; the viewer warns and asks
|
||||
# for an explicit confirmation before forcing the write.
|
||||
"xlsx_lossy_features": inspect_workbook(file_path),
|
||||
@@ -507,6 +516,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# Macros are NOT lossy for .xlsm: keep_vba re-serializes them
|
||||
# (an empty LOSSY probe is what makes the save gate pass).
|
||||
"xlsx_lossy_features": [],
|
||||
@@ -554,6 +564,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
"html": html, "raw_length": len(raw), "extension": ext,
|
||||
"is_markdown": False, "is_csv": True,
|
||||
# #156-A12 — same stale-write guard as the workbooks.
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
# === JSON: syntax-highlighted display ===
|
||||
|
||||
+109
-10
@@ -67,6 +67,9 @@ from backend.services.mutations import (
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_structure as service_mutate_xlsx_structure,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_style as service_mutate_xlsx_style,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
@@ -127,7 +130,7 @@ def api_file_xlsx_save(
|
||||
...,
|
||||
description=(
|
||||
'{"sheet": str, "cells": {"A1": value}, '
|
||||
'"allow_formula": false, "force": false}'
|
||||
'"allow_formula": false, "force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -144,6 +147,10 @@ def api_file_xlsx_save(
|
||||
(slicers, form controls, connections, custom XML, signature, cached formula
|
||||
results). Without it the call fails **409** ``xlsx_lossy_content`` and the
|
||||
client asks the user to confirm (#153 A1).
|
||||
* ``if_match`` — revision token returned by the read (#156-A12). When it no
|
||||
longer matches the file on disk the write is refused with **409**
|
||||
``conflict`` (``reason=stale_revision``) instead of overwriting a change
|
||||
made by another writer. Omitted: last writer wins (curl, AI tools).
|
||||
|
||||
A backup is created before the workbook is rewritten, and the new archive
|
||||
swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl
|
||||
@@ -168,16 +175,22 @@ def api_file_xlsx_save(
|
||||
if not isinstance(raw, bool):
|
||||
raise HTTPException(status_code=400, detail=f"Flag invalide: {name}")
|
||||
flags[name] = raw
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_edit_xlsx_cells(
|
||||
vault_name, path, sheet, cells, **flags
|
||||
vault_name, path, sheet, cells, expected_revision=if_match, **flags
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
|
||||
@@ -186,7 +199,11 @@ def api_file_csv_save(
|
||||
path: str = Query(..., description="Relative path to the .csv file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description='{"cells": {"A1": value}} — A1-addressed text edits (#153 A16)',
|
||||
description=(
|
||||
'{"cells": {"A1": value}, "if_match": str} — A1-addressed text '
|
||||
'edits (#153 A16). `if_match` is the revision token of the read '
|
||||
'(#156-A12): a stale token fails with 409 `conflict`.'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
@@ -195,6 +212,9 @@ def api_file_csv_save(
|
||||
The grid is re-parsed with :mod:`csv`, patched and re-serialized
|
||||
(RFC 4180 quoting). References beyond the extent grow the grid. Values
|
||||
are stored verbatim as text — a CSV has no formula engine.
|
||||
|
||||
``if_match`` (optional, #156-A12) is the revision the client read: when the
|
||||
file changed in the meantime the write is refused with **409** ``conflict``.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -206,13 +226,20 @@ def api_file_csv_save(
|
||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
||||
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
||||
|
||||
result = service_save_csv_cells(vault_name, path, cells)
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_save_csv_cells(vault_name, path, cells, expected_revision=if_match)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
|
||||
@@ -224,7 +251,7 @@ def api_file_xlsx_structure(
|
||||
description=(
|
||||
'{"actions": [{"op": "sheet_add", "name": "X"}, '
|
||||
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
|
||||
'"force": false}'
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -239,7 +266,9 @@ def api_file_xlsx_structure(
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced.
|
||||
edits). A backup is created before the archive is replaced. The optional
|
||||
``if_match`` revision (#156-A12) refuses a structural rewrite on a file that
|
||||
changed since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
@@ -258,16 +287,86 @@ def api_file_xlsx_structure(
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_structure(
|
||||
vault_name, path, actions, force=raw_force
|
||||
vault_name, path, actions, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(actions),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"])}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/style", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"ops": [{"op": "cell", "sheet": "X", "range": "A1:B2", '
|
||||
'"style": {"bold": true, "fill_color": "#ffe08a"}}], '
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Write formatting on an .xlsx/.xlsm workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
|
||||
``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``,
|
||||
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
|
||||
and ``freeze`` (``cell``, empty to release).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced; the optional
|
||||
``if_match`` revision (#156-A12) refuses a rewrite on a file that changed
|
||||
since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx``/``.xlsm`` file.
|
||||
body: JSON body with ``ops`` (1 to 50) and optional ``force``.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
ops = body.get("ops")
|
||||
if not isinstance(ops, list) or not ops or len(ops) > 50:
|
||||
raise HTTPException(status_code=400, detail="Ops invalides (1 à 50 par requête)")
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_style(
|
||||
vault_name, path, ops, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(ops),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
|
||||
@@ -301,6 +301,14 @@ class FileContentResponse(BaseModel):
|
||||
"when the sheet exceeds the 500x40 render caps (#153 A8)"
|
||||
),
|
||||
)
|
||||
xlsx_revision: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optimistic-concurrency token of the spreadsheet (#156-A12): the "
|
||||
"client sends it back as the `if_match` of a write so a change made "
|
||||
"elsewhere is refused (409 `conflict`) instead of overwritten"
|
||||
),
|
||||
)
|
||||
xlsx_lossy_features: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
@@ -401,6 +409,12 @@ class FileSaveResponse(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
size: int = Field(description="Size of saved content in characters")
|
||||
# #156-A12 — optimistic-concurrency token of the file AFTER the write, so a
|
||||
# client can chain writes without re-reading (absent on non-spreadsheets).
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
description="Opaque revision of the saved spreadsheet (send it back as `if_match`)",
|
||||
)
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
@@ -652,6 +666,7 @@ class TitleSuggestion(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(default_factory=list, description="File tags")
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
|
||||
+16
-3
@@ -373,6 +373,7 @@ class InvertedIndex:
|
||||
self.doc_vault: dict[str, str] = {}
|
||||
self.vault_docs: dict[str, set] = defaultdict(set)
|
||||
self.tag_docs: dict[str, set] = defaultdict(set)
|
||||
self.doc_tags: dict[str, set] = defaultdict(set)
|
||||
self._sorted_tokens: SortedList = SortedList()
|
||||
self._ready: bool = False # True after initial build
|
||||
|
||||
@@ -403,6 +404,7 @@ class InvertedIndex:
|
||||
self.doc_vault = {}
|
||||
self.vault_docs = defaultdict(set)
|
||||
self.tag_docs = defaultdict(set)
|
||||
self.doc_tags = defaultdict(set)
|
||||
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
for file_info in vault_data.get("files", []):
|
||||
@@ -417,6 +419,7 @@ class InvertedIndex:
|
||||
# --- Per-document tag index ---
|
||||
for tag in file_info.get("tags", []):
|
||||
self.tag_docs[tag.lower()].add(doc_key)
|
||||
self.doc_tags[file_info['path']].add(tag.lower())
|
||||
|
||||
# --- Title tokens ---
|
||||
title_tokens = tokenize(file_info.get("title", ""))
|
||||
@@ -1324,6 +1327,7 @@ def advanced_search(
|
||||
scored_results: list[tuple[float, dict[str, Any]]] = []
|
||||
facet_tags: dict[str, int] = defaultdict(int)
|
||||
facet_vaults: dict[str, int] = defaultdict(int)
|
||||
facet_extensions: dict[str, int] = defaultdict(int)
|
||||
|
||||
# Pre-compute prefix expansions once per term (avoid repeated binary search)
|
||||
prefix_expansions: dict[str, list[str]] = {}
|
||||
@@ -1423,6 +1427,11 @@ def advanced_search(
|
||||
facet_vaults[result["vault"]] = facet_vaults.get(result["vault"], 0) + 1
|
||||
for tag in result.get("tags", []):
|
||||
facet_tags[tag] = facet_tags.get(tag, 0) + 1
|
||||
# Extension normalised without leading dot ("md", not ".md") so it can be
|
||||
# fed back directly as the `ext:` query operator.
|
||||
ext = str(result.get("extension") or "").lower().lstrip(".")
|
||||
if ext:
|
||||
facet_extensions[ext] = facet_extensions.get(ext, 0) + 1
|
||||
|
||||
total = len(scored_results)
|
||||
page = scored_results[offset: offset + limit]
|
||||
@@ -1436,6 +1445,7 @@ def advanced_search(
|
||||
"facets": {
|
||||
"tags": dict(sorted(facet_tags.items(), key=lambda x: -x[1])[:20]),
|
||||
"vaults": dict(sorted(facet_vaults.items(), key=lambda x: -x[1])),
|
||||
"extensions": dict(sorted(facet_extensions.items(), key=lambda x: -x[1])),
|
||||
},
|
||||
"query_time_ms": elapsed_ms,
|
||||
"semantic_available": semantic_available,
|
||||
@@ -1538,7 +1548,7 @@ def suggest_titles(
|
||||
prefix: str,
|
||||
vault_filter: str = "all",
|
||||
limit: int = SUGGEST_LIMIT,
|
||||
) -> list[dict[str, str]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Args:
|
||||
@@ -1547,7 +1557,7 @@ def suggest_titles(
|
||||
limit: Maximum suggestions.
|
||||
|
||||
Returns:
|
||||
List of ``{"vault", "path", "title"}`` dicts.
|
||||
List of ``{"vault", "path", "title", "tags"}`` dicts.
|
||||
"""
|
||||
if not prefix or len(prefix) < MIN_PREFIX_LENGTH:
|
||||
return []
|
||||
@@ -1565,7 +1575,10 @@ def suggest_titles(
|
||||
key = f"{entry['vault']}::{entry['path']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(entry)
|
||||
# Add tags from the index
|
||||
entry_with_tags: dict[str, Any] = dict(entry)
|
||||
entry_with_tags["tags"] = list(inv.doc_tags.get(entry["path"], set()))
|
||||
results.append(entry_with_tags)
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
|
||||
|
||||
@@ -278,6 +278,67 @@ def _xlsx_write_lock(key: str) -> Iterator[None]:
|
||||
lock.release()
|
||||
|
||||
|
||||
def _invalidate_meta(file_path: Path) -> None:
|
||||
"""Drop the cached workbook metadata after a write (#156-A13).
|
||||
|
||||
Imported lazily so the module stays importable when the spreadsheet reader
|
||||
is not needed (it pulls openpyxl in).
|
||||
"""
|
||||
try:
|
||||
from backend.xlsx_reader import invalidate_workbook_meta
|
||||
|
||||
invalidate_workbook_meta(file_path)
|
||||
except Exception: # pragma: no cover - cache invalidation is best-effort
|
||||
logger.debug("workbook meta invalidation skipped", exc_info=True)
|
||||
|
||||
|
||||
def file_revision(file_path: Path) -> str:
|
||||
"""Opaque revision token of a file — ``mtime_ns:size`` in hex (#156-A12).
|
||||
|
||||
Cheap by design (one ``stat``) and enough for optimistic concurrency: any
|
||||
writer that replaces the file changes at least one of the two fields. The
|
||||
viewer reads it with the file and sends it back as the ``if_match`` of a
|
||||
write, so an external editor (Excel, the watcher, another worker) can no
|
||||
longer be silently overwritten.
|
||||
|
||||
Returns:
|
||||
The token, or ``""`` when the file cannot be stat'ed.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
return f"{st.st_mtime_ns:x}-{st.st_size:x}"
|
||||
|
||||
|
||||
def _check_revision(file_path: Path, expected: str | None) -> None:
|
||||
"""Refuse a write on a file that changed since it was read (#156-A12).
|
||||
|
||||
``expected`` comes from the read payload (``if_match``): when it is absent
|
||||
the write keeps its pre-A12 behaviour (last writer wins), so curl, the AI
|
||||
tools and the batch uploader are unaffected.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``conflict`` (409, ``reason=stale_revision``) when the
|
||||
file on disk is not the revision the caller read.
|
||||
"""
|
||||
if not expected:
|
||||
return
|
||||
current = file_revision(file_path)
|
||||
if current and current != expected:
|
||||
raise ServiceError(
|
||||
"The file changed on disk since it was read; reload before saving",
|
||||
code="conflict",
|
||||
status=409,
|
||||
details={
|
||||
"reason": "stale_revision",
|
||||
"path": str(file_path),
|
||||
"expected_revision": expected,
|
||||
"current_revision": current,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _coerce_xlsx_value(value: Any) -> Any:
|
||||
"""Turn the string sent by the cell editor back into a scalar (#153 A10).
|
||||
|
||||
@@ -354,6 +415,7 @@ def edit_xlsx_cells(
|
||||
backup: bool = True,
|
||||
allow_formula: bool = False,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
||||
|
||||
@@ -416,6 +478,9 @@ def edit_xlsx_cells(
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — inside the lock: no writer can slip in between the check
|
||||
# and the load.
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
|
||||
@@ -451,6 +516,9 @@ def edit_xlsx_cells(
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
# #156-A13 — the metadata cache is keyed on (mtime, size); drop it too
|
||||
# so a rewrite that lands on the same tick can never serve stale maps.
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||
return {
|
||||
@@ -458,6 +526,7 @@ def edit_xlsx_cells(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
@@ -468,6 +537,7 @@ def mutate_xlsx_structure(
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
|
||||
|
||||
@@ -521,6 +591,8 @@ def mutate_xlsx_structure(
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — stale-write guard (see edit_xlsx_cells).
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.worksheet.copier import WorksheetCopy
|
||||
|
||||
@@ -636,6 +708,7 @@ def mutate_xlsx_structure(
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(
|
||||
f"XLSX structure: {vault_name}/{rel_path} {applied}"
|
||||
@@ -645,6 +718,306 @@ def mutate_xlsx_structure(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
# #156-A8 — write-side formatting. Every value is data-driven (the colours
|
||||
# come from the caller, never from a hardcoded palette) and the whole batch
|
||||
# rides the same lock, backup and atomic swap as the cell edits.
|
||||
_STYLE_KEYS = {
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"font_color",
|
||||
"fill_color",
|
||||
"align",
|
||||
"number_format",
|
||||
}
|
||||
_STYLE_ALIGNS = {"left", "center", "right"}
|
||||
MAX_STYLE_CELLS = 10_000
|
||||
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def _style_color(value: Any) -> str | None:
|
||||
"""Normalise ``#rrggbb`` to openpyxl's ARGB, or ``None`` to clear."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str) or not _HEX_COLOR_RE.match(value.strip()):
|
||||
raise ServiceError(
|
||||
f"Couleur invalide: {value!r}", code="invalid", status=400
|
||||
)
|
||||
return "FF" + value.strip().lstrip("#").upper()
|
||||
|
||||
|
||||
def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
"""Apply the data-driven style fragment of one ``cell`` operation.
|
||||
|
||||
Only the keys listed in :data:`_STYLE_KEYS` are accepted — an unknown one
|
||||
is a client bug, not something to ignore silently. Font attributes are
|
||||
copied before mutation so the shared style of the other cells is left
|
||||
untouched.
|
||||
"""
|
||||
import copy as copy_mod
|
||||
|
||||
from openpyxl.styles import Alignment, Color, PatternFill
|
||||
|
||||
unknown = set(style) - _STYLE_KEYS
|
||||
if unknown:
|
||||
raise ServiceError(
|
||||
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
|
||||
)
|
||||
if {"bold", "italic", "underline", "font_color"} & set(style):
|
||||
font = copy_mod.copy(cell.font)
|
||||
if "bold" in style:
|
||||
font.bold = bool(style["bold"])
|
||||
if "italic" in style:
|
||||
font.italic = bool(style["italic"])
|
||||
if "underline" in style:
|
||||
font.underline = "single" if style["underline"] else None
|
||||
if "font_color" in style:
|
||||
rgb = _style_color(style["font_color"])
|
||||
font.color = Color(rgb=rgb) if rgb else None
|
||||
cell.font = font
|
||||
if "fill_color" in style:
|
||||
rgb = _style_color(style["fill_color"])
|
||||
cell.fill = (
|
||||
PatternFill(start_color=rgb, end_color=rgb, fill_type="solid")
|
||||
if rgb
|
||||
else PatternFill(fill_type=None)
|
||||
)
|
||||
if "align" in style:
|
||||
align = style["align"]
|
||||
if align not in _STYLE_ALIGNS:
|
||||
raise ServiceError(
|
||||
f"Alignement invalide: {align!r}", code="invalid", status=400
|
||||
)
|
||||
current = cell.alignment
|
||||
cell.alignment = Alignment(
|
||||
horizontal=align,
|
||||
vertical=getattr(current, "vertical", None),
|
||||
wrap_text=getattr(current, "wrap_text", None),
|
||||
)
|
||||
if "number_format" in style:
|
||||
fmt = style["number_format"] or "General"
|
||||
if not isinstance(fmt, str) or len(fmt) > 120:
|
||||
raise ServiceError(
|
||||
"Format de nombre invalide", code="invalid", status=400
|
||||
)
|
||||
cell.number_format = fmt
|
||||
|
||||
|
||||
def mutate_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
ops: list[dict[str, Any]],
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write formatting on an ``.xlsx``/``.xlsm`` workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
|
||||
|
||||
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
|
||||
``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color``
|
||||
(``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and
|
||||
``number_format`` ;
|
||||
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
|
||||
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
|
||||
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
|
||||
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
|
||||
|
||||
Same guards as the cell edits: per-file lock, ``.bak`` backup,
|
||||
``.tmp`` + ``os.replace`` atomic swap, lossy-write 409 and the optional
|
||||
``expected_revision`` concurrency check (#156-A12).
|
||||
"""
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise ServiceError(
|
||||
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
|
||||
)
|
||||
if not ops or len(ops) > 50:
|
||||
raise ServiceError(
|
||||
"Invalid ops (1 to 50 per request)", code="invalid", status=400
|
||||
)
|
||||
|
||||
if not force and file_path.suffix.lower() != ".xlsm":
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Restyling this workbook would drop features ObsiGate cannot "
|
||||
"preserve; retry with force=true after confirmation",
|
||||
code="xlsx_lossy_content",
|
||||
status=409,
|
||||
details={"path": path, "features": lossy},
|
||||
)
|
||||
|
||||
def _range(ref: str) -> tuple[int, int, int, int]:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(str(ref).upper())
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Plage invalide: {ref!r}", code="invalid", status=400
|
||||
) from exc
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_STYLE_CELLS:
|
||||
raise ServiceError(
|
||||
f"Plage trop grande (max {MAX_STYLE_CELLS} cellules)",
|
||||
code="invalid",
|
||||
status=400,
|
||||
)
|
||||
return min_col, min_row, max_col, max_row
|
||||
|
||||
def _sheet(wb: Any, action: dict[str, Any]) -> Any:
|
||||
name = str(action.get("sheet", ""))
|
||||
if name not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {name}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
details={"sheets": wb.sheetnames},
|
||||
)
|
||||
return wb[name]
|
||||
|
||||
applied: list[str] = []
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
try:
|
||||
wb = load_workbook(
|
||||
file_path, keep_vba=file_path.suffix.lower() == ".xlsm"
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
rel_path = _rel(root, file_path)
|
||||
try:
|
||||
for i, action in enumerate(ops):
|
||||
if not isinstance(action, dict):
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} invalide", code="invalid", status=400
|
||||
)
|
||||
op = action.get("op")
|
||||
try:
|
||||
if op == "cell":
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range") or action.get("cell") or "")
|
||||
min_col, min_row, max_col, max_row = _range(ref)
|
||||
style = action.get("style") or {}
|
||||
if not isinstance(style, dict) or not style:
|
||||
raise ServiceError(
|
||||
"Style vide", code="invalid", status=400
|
||||
)
|
||||
for row in ws.iter_rows(
|
||||
min_row=min_row,
|
||||
max_row=max_row,
|
||||
min_col=min_col,
|
||||
max_col=max_col,
|
||||
):
|
||||
for cell in row:
|
||||
_apply_cell_style(cell, style)
|
||||
applied.append(
|
||||
f"cell:{ws.title}!{ref}:{','.join(sorted(style))}"
|
||||
)
|
||||
elif op in ("merge", "unmerge"):
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range", ""))
|
||||
_range(ref)
|
||||
if op == "merge":
|
||||
ws.merge_cells(ref)
|
||||
else:
|
||||
ws.unmerge_cells(ref)
|
||||
applied.append(f"{op}:{ws.title}!{ref}")
|
||||
elif op == "col_width":
|
||||
ws = _sheet(wb, action)
|
||||
col = action.get("col")
|
||||
width = action.get("width")
|
||||
if isinstance(col, int):
|
||||
col = get_column_letter(col)
|
||||
if not isinstance(col, str) or not col.strip():
|
||||
raise ServiceError(
|
||||
"Colonne invalide", code="invalid", status=400
|
||||
)
|
||||
if not isinstance(width, (int, float)) or not 0 <= float(width) <= 255:
|
||||
raise ServiceError(
|
||||
"Largeur invalide (0 à 255)", code="invalid", status=400
|
||||
)
|
||||
ws.column_dimensions[col.strip().upper()].width = float(width)
|
||||
applied.append(f"col_width:{ws.title}!{col}={width}")
|
||||
elif op == "row_height":
|
||||
ws = _sheet(wb, action)
|
||||
row = action.get("row")
|
||||
height = action.get("height")
|
||||
if not isinstance(row, int) or row < 1:
|
||||
raise ServiceError("Ligne invalide", code="invalid", status=400)
|
||||
if not isinstance(height, (int, float)) or not 0 <= float(height) <= 409:
|
||||
raise ServiceError(
|
||||
"Hauteur invalide (0 à 409)", code="invalid", status=400
|
||||
)
|
||||
ws.row_dimensions[row].height = float(height)
|
||||
applied.append(f"row_height:{ws.title}!{row}={height}")
|
||||
elif op == "freeze":
|
||||
ws = _sheet(wb, action)
|
||||
cell_ref = str(action.get("cell", ""))
|
||||
if cell_ref:
|
||||
_range(cell_ref)
|
||||
ws.freeze_panes = cell_ref.upper() or None
|
||||
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
|
||||
else:
|
||||
raise ServiceError(
|
||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
||||
)
|
||||
except ServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} ({op}) a échoué: {exc}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
) from exc
|
||||
except ServiceError:
|
||||
wb.close()
|
||||
raise
|
||||
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
wb.save(tmp_path)
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX style: {vault_name}/{rel_path} {applied}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
@@ -654,14 +1027,18 @@ def save_csv_cells(
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
|
||||
|
||||
The file is re-parsed, patched and re-serialized with :mod:`csv` so
|
||||
quoting follows RFC 4180. References beyond the current extent grow the
|
||||
grid (missing rows/cells are filled with empty strings). Values are
|
||||
stored as text: a CSV has no formula engine, so any string — including
|
||||
ones starting with ``=`` — is written verbatim (the render escapes it).
|
||||
quoting follows RFC 4180. BUG-098 — the delimiter is **sniffed** and the
|
||||
same one is reused on write-back: a `;`-separated French CSV used to be
|
||||
parsed as a single column and rewritten with `,`. References beyond the
|
||||
current extent grow the grid (missing rows/cells are filled with empty
|
||||
strings). Values are stored as text: a CSV has no formula engine, so any
|
||||
string — including ones starting with ``=`` — is written verbatim (the
|
||||
render escapes it).
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
|
||||
@@ -670,6 +1047,8 @@ def save_csv_cells(
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
@@ -691,9 +1070,13 @@ def save_csv_cells(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
# #156-A12 — same stale-write guard as the workbook path.
|
||||
_check_revision(file_path, expected_revision)
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
# BUG-098 — parse AND rewrite with the file's own delimiter.
|
||||
delimiter = sniff_csv_delimiter(raw)
|
||||
try:
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw)))
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter))
|
||||
except csv_mod.Error:
|
||||
rows = [[line] for line in raw.splitlines()]
|
||||
|
||||
@@ -721,7 +1104,7 @@ def save_csv_cells(
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
buf = io_mod.StringIO()
|
||||
csv_mod.writer(buf, lineterminator="\n").writerows(rows)
|
||||
csv_mod.writer(buf, delimiter=delimiter, lineterminator="\n").writerows(rows)
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
tmp_path.write_text(buf.getvalue(), encoding="utf-8")
|
||||
@@ -736,6 +1119,7 @@ def save_csv_cells(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
||||
"xlsx_to_markdown": ("xlsx_read", "path"),
|
||||
"update_xlsx_cells": ("xlsx_update", "path"),
|
||||
"append_xlsx_rows": ("xlsx_append", "path"),
|
||||
"search_workbook": ("xlsx_search", "query"),
|
||||
"analyze_range": ("xlsx_analyze", "path"),
|
||||
"edit_xlsx_structure": ("xlsx_structure", "path"),
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
|
||||
@@ -332,12 +332,42 @@ class XlsxToMarkdownInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SearchWorkbookInput(BaseModel):
|
||||
"""Find a text across the sheets of a spreadsheet (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
query: str = Field(..., description="Text to look for")
|
||||
sheet: str = Field("", description="Restrict to one sheet (empty = all sheets)")
|
||||
case_sensitive: bool = Field(False, description="Match case")
|
||||
|
||||
|
||||
class AnalyzeRangeInput(BaseModel):
|
||||
"""Aggregate the values of an A1 range (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
sheet: str = Field(
|
||||
"", description="Sheet name (empty = the first/active sheet)"
|
||||
)
|
||||
range: str = Field(
|
||||
"",
|
||||
description="A1 range to analyse (e.g. 'B2:B50'); empty = the whole sheet",
|
||||
)
|
||||
|
||||
|
||||
class UpdateXlsxCellsInput(BaseModel):
|
||||
"""Batch-edit cells of an existing .xlsx workbook (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(..., description="Worksheet title to edit")
|
||||
sheet: str = Field(
|
||||
"", description="Worksheet title to edit (ignored for a .csv)"
|
||||
)
|
||||
cells: dict[str, str | int | float | bool | None] = Field(
|
||||
..., description="A1 reference -> new value (max 500 per call)"
|
||||
)
|
||||
@@ -370,6 +400,25 @@ class AppendXlsxRowsInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class EditXlsxStructureInput(BaseModel):
|
||||
"""Structural CRUD on an existing workbook (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx/.xlsm file")
|
||||
actions: list[dict[str, Any]] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Ordered structural actions (1-50): sheet_add/sheet_rename/"
|
||||
"sheet_duplicate/sheet_delete, row_insert/row_delete/"
|
||||
"col_insert/col_delete"
|
||||
),
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class CsvInput(BaseModel):
|
||||
"""Create a .csv file in a vault from rows of cells."""
|
||||
|
||||
|
||||
+392
-105
@@ -1,21 +1,30 @@
|
||||
"""Spreadsheet tools (#153 A6) — read and mutate existing ``.xlsx`` workbooks.
|
||||
"""Spreadsheet tools (#153 A6, #156 A14) — read and mutate existing workbooks.
|
||||
|
||||
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
|
||||
file; here the assistant can read and edit one that already exists):
|
||||
file; here the assistant can read and edit one that already exists). The same
|
||||
three formats the viewer edits are supported — ``.xlsx``, ``.xlsm`` and
|
||||
``.csv`` (#156-A14 used to be ``.xlsx`` only, which made a workbook the UI
|
||||
edits invisible to the assistant):
|
||||
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (wraps the guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet.
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``search_workbook`` — READ, find text across every sheet (#156-A14);
|
||||
* ``analyze_range`` — READ, aggregate stats over an A1 range (#156-A14);
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet;
|
||||
* ``edit_xlsx_structure`` — WRITE, structural CRUD (sheets/rows/columns).
|
||||
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`,
|
||||
which already carries the #153 P0 guards: per-file lock, atomic replace,
|
||||
formula neutralisation (``allow_formula`` opt-in) and the lossy-write 409.
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`
|
||||
(or ``save_csv_cells`` / ``mutate_xlsx_structure``), which already carry the
|
||||
#153 P0 guards: per-file lock, atomic replace, formula neutralisation
|
||||
(``allow_formula`` opt-in) and the lossy-write 409. Every write is a WRITE-risk
|
||||
tool, so the registry keeps asking for an explicit confirmation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -25,8 +34,11 @@ from backend.services.vaults import get_vault_root
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
AnalyzeRangeInput,
|
||||
AppendXlsxRowsInput,
|
||||
EditXlsxStructureInput,
|
||||
ListXlsxSheetsInput,
|
||||
SearchWorkbookInput,
|
||||
UpdateXlsxCellsInput,
|
||||
XlsxToMarkdownInput,
|
||||
)
|
||||
@@ -39,12 +51,28 @@ MAX_MD_ROWS = 100
|
||||
MAX_MD_COLS = 20
|
||||
MAX_MD_CHARS = 20_000
|
||||
|
||||
# #156-A14 — the newer read tools scan more than the markdown table (a search
|
||||
# or an aggregate must not stop at row 100) but stay bounded all the same:
|
||||
# a runaway scan would load a whole ledger into the model's context.
|
||||
MAX_SCAN_ROWS = 5_000
|
||||
MAX_SCAN_COLS = 100
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
MAX_RANGE_CELLS = 10_000
|
||||
MAX_RANGE_VALUES = 200
|
||||
|
||||
def _workbook_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx`` path."""
|
||||
# The formats the spreadsheet editor (and now the assistant) can handle.
|
||||
SPREADSHEET_EXTENSIONS = (".xlsx", ".xlsm", ".csv")
|
||||
|
||||
_NUMBER_RE = re.compile(r"^-?\d+(?:[.,]\d+)?$")
|
||||
|
||||
|
||||
def _spreadsheet_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx``/``.xlsm``/``.csv`` path."""
|
||||
path = (path or "").strip()
|
||||
if not path.lower().endswith(".xlsx"):
|
||||
raise ToolError("Extension attendue : .xlsx", code="invalid_arguments")
|
||||
if not path.lower().endswith(SPREADSHEET_EXTENSIONS):
|
||||
raise ToolError(
|
||||
"Extension attendue : .xlsx, .xlsm ou .csv", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
root = get_vault_root(vault)
|
||||
except ServiceError as e:
|
||||
@@ -52,57 +80,175 @@ def _workbook_path(vault: str, path: str) -> Path:
|
||||
return resolve_safe_path(root, path)
|
||||
|
||||
|
||||
def _is_csv(file_path: Path) -> bool:
|
||||
return file_path.suffix.lower() == ".csv"
|
||||
|
||||
|
||||
def _map_service_error(e: ServiceError) -> ToolError:
|
||||
return ToolError(e.message, code=e.code, details=e.details)
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of an .xlsx workbook with their dimensions "
|
||||
"(rows x columns) and whether the display caps truncate them. "
|
||||
"Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS, _sheet_extent
|
||||
def _sheet_titles(file_path: Path) -> list[str]:
|
||||
"""Sheet names of a workbook; a CSV has a single, unnamed “sheet”."""
|
||||
if _is_csv(file_path):
|
||||
return [""]
|
||||
from openpyxl import load_workbook
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
return list(wb.sheetnames)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _read_grid(
|
||||
file_path: Path, sheet: str, max_rows: int, max_cols: int
|
||||
) -> tuple[str, list[list[str]], bool]:
|
||||
"""Read one sheet (or a CSV) as bounded, formatted, trimmed rows.
|
||||
|
||||
Returns ``(title, rows, truncated)``; ``truncated`` is True when real data
|
||||
sits just beyond the row cap (probed one row further) so the caller can say
|
||||
so instead of silently dropping it.
|
||||
"""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
if _is_csv(file_path):
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
stem = file_path.stem
|
||||
if sheet and sheet != stem:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
raw = file_path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=sniff_csv_delimiter(raw))
|
||||
grid: list[list[str]] = []
|
||||
truncated = False
|
||||
for i, row in enumerate(reader):
|
||||
if i >= max_rows:
|
||||
truncated = any(str(c).strip() for c in row)
|
||||
break
|
||||
grid.append([str(c) for c in row][:max_cols])
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return stem, grid, truncated
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
total_rows, total_cols = _sheet_extent(ws)
|
||||
sheets.append(
|
||||
{
|
||||
"name": ws.title,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
if sheet:
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
ws = wb[sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
grid = []
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=max_rows, max_col=max_cols, values_only=True
|
||||
):
|
||||
grid.append([_fmt(v) for v in row])
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=max_rows + 1,
|
||||
max_row=max_rows + 1,
|
||||
max_col=max_cols,
|
||||
values_only=True,
|
||||
)
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
)
|
||||
truncated = any(any(str(v or "").strip() for v in r) for r in probe)
|
||||
finally:
|
||||
wb.close()
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return title, grid, truncated
|
||||
|
||||
|
||||
def _to_number(text: str) -> float | None:
|
||||
"""Coerce a displayed cell to a float, or ``None`` when it is not one."""
|
||||
t = text.strip().replace("\u00a0", "").replace(" ", "")
|
||||
if not _NUMBER_RE.match(t):
|
||||
return None
|
||||
try:
|
||||
return float(t.replace(",", "."))
|
||||
except ValueError: # pragma: no cover - regex already guarantees the shape
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of a spreadsheet (.xlsx, .xlsm or .csv) with their "
|
||||
"dimensions (rows x columns) and whether the display caps truncate "
|
||||
"them. Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook (or of the CSV)."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ToolError(f"Fichier introuvable: {params.path}", code="not_found")
|
||||
|
||||
extents: list[tuple[str, int, int]] = []
|
||||
if _is_csv(file_path):
|
||||
_, rows, _ = _read_grid(file_path, "", MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
extents.append(
|
||||
(
|
||||
file_path.stem,
|
||||
len(rows),
|
||||
max((len(r) for r in rows), default=0),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Declared dimensions are enough here (and far cheaper than scanning
|
||||
# every row): the caller just needs a size to decide what to read.
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _sheet_extent
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
extents = [
|
||||
(ws.title, *_sheet_extent(ws)) for ws in wb.worksheets
|
||||
]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
sheets = [
|
||||
{
|
||||
"name": name,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
for name, total_rows, total_cols in extents
|
||||
]
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
|
||||
|
||||
@tool(
|
||||
name="xlsx_to_markdown",
|
||||
description=(
|
||||
"Read a sheet of an .xlsx workbook as a bounded markdown table "
|
||||
"(up to 100 rows x 20 columns). Use to inspect spreadsheet data "
|
||||
"before answering or editing."
|
||||
"Read a sheet of a spreadsheet (.xlsx, .xlsm or .csv) as a bounded "
|
||||
"markdown table (up to 100 rows x 20 columns). Use to inspect "
|
||||
"spreadsheet data before answering or editing."
|
||||
),
|
||||
input_model=XlsxToMarkdownInput,
|
||||
risk=ToolRisk.READ,
|
||||
@@ -110,49 +256,8 @@ def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str,
|
||||
)
|
||||
def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str, Any]:
|
||||
"""Render one sheet as a markdown table for the LLM context."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
if params.sheet:
|
||||
if params.sheet not in wb.sheetnames:
|
||||
raise ToolError(
|
||||
f"Feuille introuvable: {params.sheet}", code="not_found"
|
||||
)
|
||||
ws = wb[params.sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
rows: list[list[str]] = []
|
||||
truncated = False
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=MAX_MD_ROWS, max_col=MAX_MD_COLS, values_only=True
|
||||
):
|
||||
cells = [_fmt(v) for v in row]
|
||||
if not any(c.strip() for c in cells):
|
||||
continue
|
||||
rows.append(cells)
|
||||
# Real tail beyond the caps? Probe one row further.
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=MAX_MD_ROWS + 1,
|
||||
max_row=MAX_MD_ROWS + 1,
|
||||
max_col=MAX_MD_COLS,
|
||||
values_only=True,
|
||||
)
|
||||
)
|
||||
if any(any(str(v or "").strip() for v in r) for r in probe):
|
||||
truncated = True
|
||||
finally:
|
||||
wb.close()
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, truncated = _read_grid(file_path, params.sheet, MAX_MD_ROWS, MAX_MD_COLS)
|
||||
|
||||
lines: list[str] = []
|
||||
if rows:
|
||||
@@ -174,34 +279,166 @@ def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_workbook",
|
||||
description=(
|
||||
"Search a text across every sheet of a spreadsheet (.xlsx, .xlsm or "
|
||||
".csv) and return the matching cells with their sheet and A1 "
|
||||
"reference (max 100 matches). Use it to find where a value lives "
|
||||
"without dumping whole sheets into the context."
|
||||
),
|
||||
input_model=SearchWorkbookInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def search_workbook(ctx: ToolContext, params: SearchWorkbookInput) -> dict[str, Any]:
|
||||
"""Find a needle across all sheets, bounded and counted per sheet."""
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
needle = (params.query or "").strip()
|
||||
if not needle:
|
||||
raise ToolError("Requête vide", code="invalid_arguments")
|
||||
|
||||
titles = _sheet_titles(file_path)
|
||||
if params.sheet:
|
||||
if params.sheet not in titles:
|
||||
raise ToolError(f"Feuille introuvable: {params.sheet}", code="not_found")
|
||||
titles = [params.sheet]
|
||||
|
||||
hay = needle if params.case_sensitive else needle.lower()
|
||||
matches: list[dict[str, Any]] = []
|
||||
by_sheet: dict[str, int] = {}
|
||||
total = 0
|
||||
for title in titles:
|
||||
sheet_title, rows, _ = _read_grid(
|
||||
file_path, title, MAX_SCAN_ROWS, MAX_SCAN_COLS
|
||||
)
|
||||
label = sheet_title or file_path.stem
|
||||
for r_i, row in enumerate(rows, start=1):
|
||||
for c_i, value in enumerate(row, start=1):
|
||||
if not value:
|
||||
continue
|
||||
haystack = value if params.case_sensitive else value.lower()
|
||||
if hay not in haystack:
|
||||
continue
|
||||
total += 1
|
||||
by_sheet[label] = by_sheet.get(label, 0) + 1
|
||||
if len(matches) < MAX_SEARCH_RESULTS:
|
||||
matches.append(
|
||||
{
|
||||
"sheet": label,
|
||||
"cell": f"{get_column_letter(c_i)}{r_i}",
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"query": needle,
|
||||
"total": total,
|
||||
"truncated": total > MAX_SEARCH_RESULTS,
|
||||
"by_sheet": by_sheet,
|
||||
"matches": matches,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="analyze_range",
|
||||
description=(
|
||||
"Aggregate an A1 range of a sheet (.xlsx, .xlsm or .csv): count, sum, "
|
||||
"mean, min and max of the numeric cells, plus a bounded sample of the "
|
||||
"values. Use it to answer a question about a column without reading "
|
||||
"the whole sheet."
|
||||
),
|
||||
input_model=AnalyzeRangeInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def analyze_range(ctx: ToolContext, params: AnalyzeRangeInput) -> dict[str, Any]:
|
||||
"""Numeric aggregates + value sample over an A1 range of the sheet."""
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, _ = _read_grid(file_path, params.sheet, MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
|
||||
label = (params.range or "").strip()
|
||||
if label:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(label.upper())
|
||||
except Exception as e:
|
||||
raise ToolError(f"Plage invalide: {label}", code="invalid_arguments") from e
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_RANGE_CELLS:
|
||||
raise ToolError(
|
||||
f"Plage trop grande (max {MAX_RANGE_CELLS} cellules)",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
selected = [row[min_col - 1 : max_col] for row in rows[min_row - 1 : max_row]]
|
||||
else:
|
||||
selected = rows
|
||||
|
||||
values = [v for row in selected for v in row if isinstance(v, str) and v.strip()]
|
||||
numbers = [n for n in (_to_number(v) for v in values) if n is not None]
|
||||
|
||||
stats: dict[str, Any] = {"count": len(numbers)}
|
||||
if numbers:
|
||||
stats.update(
|
||||
{
|
||||
"sum": round(sum(numbers), 6),
|
||||
"mean": round(sum(numbers) / len(numbers), 6),
|
||||
"min": min(numbers),
|
||||
"max": max(numbers),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"sheet": title,
|
||||
"range": params.range or "",
|
||||
"rows": len(selected),
|
||||
"cols": max((len(r) for r in selected), default=0),
|
||||
"cells": len(values),
|
||||
"numeric": stats,
|
||||
"values": values[:MAX_RANGE_VALUES],
|
||||
"truncated": len(values) > MAX_RANGE_VALUES,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="update_xlsx_cells",
|
||||
description=(
|
||||
"Edit cells of an existing .xlsx workbook. ``cells`` maps A1 "
|
||||
"references to new values (max 500). A value starting with '=' or "
|
||||
"'@' is stored as TEXT unless allow_formula is set (DDE guard). "
|
||||
"Editing a workbook carrying features openpyxl cannot rewrite "
|
||||
"requires force=true (cached formula results, slicers…)."
|
||||
"Edit cells of an existing spreadsheet (.xlsx, .xlsm or .csv). "
|
||||
"``cells`` maps A1 references to new values (max 500). A value "
|
||||
"starting with '=' or '@' is stored as TEXT unless allow_formula is "
|
||||
"set (DDE guard). Editing a workbook carrying features openpyxl "
|
||||
"cannot rewrite requires force=true (cached formula results, "
|
||||
"slicers…). A .csv has no sheet: any ``sheet`` value is ignored."
|
||||
),
|
||||
input_model=UpdateXlsxCellsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[str, Any]:
|
||||
"""Wrap the guarded cell-edit service."""
|
||||
from backend.services.mutations import edit_xlsx_cells
|
||||
"""Wrap the guarded cell-edit service (``.xlsx``/``.xlsm`` or ``.csv``)."""
|
||||
from backend.services.mutations import edit_xlsx_cells, save_csv_cells
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not params.cells:
|
||||
raise ToolError("Aucune cellule fournie", code="invalid_arguments")
|
||||
if not _is_csv(file_path) and not params.sheet:
|
||||
raise ToolError("Feuille requise pour un classeur", code="invalid_arguments")
|
||||
try:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
if _is_csv(file_path):
|
||||
result = save_csv_cells(params.vault, params.path, dict(params.cells))
|
||||
else:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
@@ -216,9 +453,10 @@ def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[st
|
||||
@tool(
|
||||
name="append_xlsx_rows",
|
||||
description=(
|
||||
"Append rows at the end of a sheet of an existing .xlsx workbook. "
|
||||
"Values are typed like in the viewer (numbers, TRUE/FALSE, FR dates "
|
||||
"JJ/MM/AAAA). The workbook is rewritten atomically with a backup."
|
||||
"Append rows at the end of a sheet of an existing spreadsheet "
|
||||
"(.xlsx or .xlsm). Values are typed like in the viewer (numbers, "
|
||||
"TRUE/FALSE, FR dates JJ/MM/AAAA). The workbook is rewritten "
|
||||
"atomically with a backup. Not available for .csv."
|
||||
),
|
||||
input_model=AppendXlsxRowsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
@@ -236,7 +474,13 @@ def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str,
|
||||
if len(params.rows) > 500:
|
||||
raise ToolError("Trop de lignes (max 500)", code="invalid_arguments")
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if _is_csv(file_path):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de notion de fin de feuille : utilisez "
|
||||
"update_xlsx_cells avec des références A1",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
@@ -284,3 +528,46 @@ def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str,
|
||||
"rows": len(params.rows),
|
||||
"first_row": first_free,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="edit_xlsx_structure",
|
||||
description=(
|
||||
"Change the structure of an existing .xlsx/.xlsm workbook: add, "
|
||||
"rename, duplicate or delete a sheet, or insert/delete rows and "
|
||||
"columns. ``actions`` is an ordered list of "
|
||||
'{"op": "sheet_add"|"sheet_rename"|"sheet_duplicate"|"sheet_delete"|'
|
||||
'"row_insert"|"row_delete"|"col_insert"|"col_delete", …} '
|
||||
"(1 to 50). Deletions drop data and cannot be undone from the "
|
||||
"assistant — confirm with the user first."
|
||||
),
|
||||
input_model=EditXlsxStructureInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def edit_xlsx_structure(
|
||||
ctx: ToolContext, params: EditXlsxStructureInput
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of structural changes through the guarded service."""
|
||||
from backend.services.mutations import mutate_xlsx_structure
|
||||
|
||||
if not params.actions:
|
||||
raise ToolError("Aucune action fournie", code="invalid_arguments")
|
||||
if len(params.actions) > 50:
|
||||
raise ToolError("Trop d'actions (max 50)", code="invalid_arguments")
|
||||
if str(params.path or "").lower().endswith(".csv"):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de structure modifiable", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
result = mutate_xlsx_structure(
|
||||
params.vault, params.path, [dict(a) for a in params.actions], force=params.force
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"actions": len(params.actions),
|
||||
}
|
||||
|
||||
+157
-14
@@ -14,9 +14,11 @@ read-only; :func:`render_csv_table` turns a CSV into the same table shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import zipfile
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
@@ -32,6 +34,13 @@ logger = logging.getLogger("obsigate.xlsx_reader")
|
||||
MAX_ROWS = 500
|
||||
MAX_COLS = 40
|
||||
|
||||
# BUG-094 — an empty sheet used to render as a bare "Feuille vide" paragraph
|
||||
# with no cell at all, so a freshly added sheet had nothing to click and no way
|
||||
# to insert a row/column. Render a small blank grid instead (Excel-like), with
|
||||
# real A1 coordinates, so the cells are editable and the structure actions work.
|
||||
EMPTY_SHEET_ROWS = 20
|
||||
EMPTY_SHEET_COLS = 8
|
||||
|
||||
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
|
||||
# loading). The endpoint is bounded so a single request can never ask for the
|
||||
# whole workbook back in one JSON payload; the UI pages through the rest.
|
||||
@@ -58,8 +67,22 @@ LOSSY_PARTS: dict[str, tuple[str, ...]] = {
|
||||
# Excel recalculates.
|
||||
_CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
|
||||
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||
_MAX_PROBE_BYTES = 8_000_000
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like
|
||||
# MAX_REPLACE_FILE_BYTES). BUG-099 — the allowance is PER SHEET: a single huge
|
||||
# first sheet used to eat the whole budget and hide a cached formula sitting in
|
||||
# the next one. The total ceiling still bounds the work on a many-sheet archive.
|
||||
# Running out of budget is reported as *unverified* (never as "nothing to lose")
|
||||
# so the write guard stays cautious instead of silently dropping the values.
|
||||
_MAX_PROBE_BYTES_PER_SHEET = 4_000_000
|
||||
_MAX_PROBE_BYTES_TOTAL = 32_000_000
|
||||
|
||||
# #156-A13 — metadata cache. `read_sheet_window()` serves one window at a time
|
||||
# and used to reload the whole workbook (normal mode, data_only=False) for every
|
||||
# window, just to read the style/merge/freeze maps of one sheet. The result is
|
||||
# keyed by (path, mtime_ns, size): any write replaces the file, hence the key.
|
||||
_META_CACHE_MAX = 8
|
||||
_meta_cache: dict[str, tuple[tuple[int, int], dict[str, dict[str, Any]]]] = {}
|
||||
_meta_cache_lock = threading.Lock()
|
||||
|
||||
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
|
||||
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
|
||||
@@ -104,6 +127,10 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
fragments.append("font-weight:600")
|
||||
if font and font.italic:
|
||||
fragments.append("font-style:italic")
|
||||
# #156-A8 — underline is written from the viewer too, so it is read back
|
||||
# (the toggle in the formatting menu needs to see its own effect).
|
||||
if font and font.underline:
|
||||
fragments.append("text-decoration:underline")
|
||||
fmt = cell.number_format
|
||||
if fmt and fmt not in ("General", "@"):
|
||||
# A custom number format is signalled typographically (mono font)
|
||||
@@ -313,24 +340,65 @@ def _table(
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value."""
|
||||
budget = _MAX_PROBE_BYTES
|
||||
def _is_sheet_xml(name: str) -> bool:
|
||||
"""True for the worksheet XML parts the cached-formula probe scans."""
|
||||
return name.startswith("xl/worksheets/sheet") and name.endswith(".xml")
|
||||
|
||||
|
||||
def _scan_cached_formulas(zf: zipfile.ZipFile) -> tuple[bool, bool]:
|
||||
"""Scan the sheet XML for a formula carrying a non-empty cached result.
|
||||
|
||||
Returns ``(found, unverified)``. ``unverified`` is True when the byte
|
||||
budget stopped the scan before every sheet could be read to the end: a
|
||||
negative result is then **not** proof that the workbook holds no cached
|
||||
value (BUG-099), so callers must not treat it as a licence to write.
|
||||
|
||||
Each sheet gets its own :data:`_MAX_PROBE_BYTES_PER_SHEET` allowance (a
|
||||
single huge sheet can no longer starve the others) while
|
||||
:data:`_MAX_PROBE_BYTES_TOTAL` bounds the whole archive. A hit short-
|
||||
circuits the scan: the answer is already known.
|
||||
"""
|
||||
budget = _MAX_PROBE_BYTES_TOTAL
|
||||
unverified = False
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"):
|
||||
if not _is_sheet_xml(name):
|
||||
continue
|
||||
sheet_budget = min(_MAX_PROBE_BYTES_PER_SHEET, budget)
|
||||
exhausted = False
|
||||
try:
|
||||
with zf.open(name) as fh:
|
||||
while budget > 0:
|
||||
chunk = fh.read(65536)
|
||||
while sheet_budget > 0:
|
||||
# Read at most what the sheet's allowance has left, so one
|
||||
# large chunk can never consume the whole total budget.
|
||||
chunk = fh.read(min(65536, sheet_budget))
|
||||
if not chunk:
|
||||
break
|
||||
break # read to the end: this sheet is verified clean
|
||||
sheet_budget -= len(chunk)
|
||||
budget -= len(chunk)
|
||||
if _CACHED_FORMULA_RE.search(chunk):
|
||||
return True
|
||||
return True, False
|
||||
else:
|
||||
# Left the loop on the budget, not on EOF.
|
||||
exhausted = True
|
||||
except (KeyError, OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
return False
|
||||
if exhausted:
|
||||
unverified = True
|
||||
if budget <= 0:
|
||||
unverified = True
|
||||
break
|
||||
return False, unverified
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value.
|
||||
|
||||
Boolean view of :func:`_scan_cached_formulas` for the display path (the
|
||||
second ``data_only=True`` read): a truncated scan simply skips the shadow
|
||||
grid, it never claims the workbook is lossless.
|
||||
"""
|
||||
found, _ = _scan_cached_formulas(zf)
|
||||
return found
|
||||
|
||||
|
||||
def inspect_workbook(file_path: Path) -> list[str]:
|
||||
@@ -342,6 +410,10 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
|
||||
``cached_values`` is a synthetic key: openpyxl keeps the formula but drops
|
||||
the cached result, so the workbook stays correct once Excel recalculates it.
|
||||
``cached_values_unverified`` (BUG-099) is the other synthetic key: the
|
||||
cached-value probe ran out of budget, so a negative result is not proof —
|
||||
the entry keeps the write guard cautious (409 + confirmation) rather than
|
||||
promising a lossless round-trip it cannot vouch for.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
@@ -351,13 +423,27 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
for key, prefixes in LOSSY_PARTS.items()
|
||||
if any(name.startswith(prefix) for name in names for prefix in prefixes)
|
||||
}
|
||||
if _has_cached_formulas(zf):
|
||||
cached_found, cached_unverified = _scan_cached_formulas(zf)
|
||||
if cached_found:
|
||||
found.add("cached_values")
|
||||
elif cached_unverified:
|
||||
found.add("cached_values_unverified")
|
||||
return sorted(found)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return []
|
||||
|
||||
|
||||
def invalidate_workbook_meta(file_path: Path | str) -> None:
|
||||
"""Drop the cached metadata of one workbook (#156-A13).
|
||||
|
||||
Called by the write path right after the atomic replace: the (mtime, size)
|
||||
key already changes on a rewrite, this only closes the theoretical window
|
||||
where a same-size write lands on the same timestamp tick.
|
||||
"""
|
||||
with _meta_cache_lock:
|
||||
_meta_cache.pop(str(file_path), None)
|
||||
|
||||
|
||||
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
|
||||
|
||||
@@ -365,7 +451,31 @@ def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
fragments are the workbook's own values, a failure yields ``{}`` per sheet
|
||||
so the viewer keeps its plain rendering. Styles are read with
|
||||
``data_only=False`` — the edited value is the formula, not its result.
|
||||
|
||||
#156-A13 — the result is cached on ``(path, mtime_ns, size)``: a truncated
|
||||
sheet is fetched window by window, and each window used to pay a full
|
||||
workbook load for these maps alone. Callers only read from the mapping.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return {}
|
||||
key = str(file_path)
|
||||
stamp = (st.st_mtime_ns, st.st_size)
|
||||
with _meta_cache_lock:
|
||||
hit = _meta_cache.get(key)
|
||||
if hit and hit[0] == stamp:
|
||||
return hit[1]
|
||||
out = _read_workbook_meta_uncached(file_path)
|
||||
with _meta_cache_lock:
|
||||
_meta_cache[key] = (stamp, out)
|
||||
while len(_meta_cache) > _META_CACHE_MAX:
|
||||
_meta_cache.pop(next(iter(_meta_cache)))
|
||||
return out
|
||||
|
||||
|
||||
def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Load the workbook once and build the per-sheet metadata maps."""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), data_only=False)
|
||||
except Exception:
|
||||
@@ -424,6 +534,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||
sheets = []
|
||||
for i, title in enumerate(titles):
|
||||
grid = _trim(formulas[i])
|
||||
# BUG-094 — a blank sheet still needs an editable grid (see constants):
|
||||
# the viewer's cell editing and structure actions all hang off a cell.
|
||||
if not grid:
|
||||
grid = [[""] * EMPTY_SHEET_COLS for _ in range(EMPTY_SHEET_ROWS)]
|
||||
# The shadow grid is NOT trimmed independently: _trim drops the
|
||||
# trailing empty columns of each grid on its own width, which would
|
||||
# shift every cached value left of its formula. Indexing it
|
||||
@@ -770,17 +884,46 @@ def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
|
||||
# ── #153 A16 — additional spreadsheet formats ───────────────────────────────
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str = ",") -> str:
|
||||
# BUG-098 — a CSV written by a French office suite is `;`-separated, and the
|
||||
# delimiter must be *detected* (then reused on write-back), not assumed.
|
||||
CSV_SNIFF_BYTES = 4096
|
||||
_CSV_DELIMITERS = (",", ";", "\t", "|")
|
||||
|
||||
|
||||
def sniff_csv_delimiter(raw: str) -> str:
|
||||
"""Return the most likely field delimiter of *raw* (``,`` as fallback).
|
||||
|
||||
:class:`csv.Sniffer` handles quoted fields and multi-line values; it is
|
||||
unreliable on short or single-column samples, so the first non-empty line
|
||||
is counted as a tie-breaker and a comma remains the last resort.
|
||||
"""
|
||||
sample = raw[:CSV_SNIFF_BYTES]
|
||||
try:
|
||||
return csv.Sniffer().sniff(sample, delimiters="".join(_CSV_DELIMITERS)).delimiter
|
||||
except csv.Error:
|
||||
pass
|
||||
first = next((line for line in sample.splitlines() if line.strip()), "")
|
||||
counts = {delim: first.count(delim) for delim in _CSV_DELIMITERS}
|
||||
best = max(counts, key=lambda delim: counts[delim])
|
||||
return best if counts[best] else ","
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str | None = None) -> str:
|
||||
"""Render CSV text as the same HTML table shape the xlsx viewer consumes.
|
||||
|
||||
Row numbers replace the A1 column: a CSV has no fixed column count, so
|
||||
the first row is a plain data row like the others (the viewer offers the
|
||||
toolbar either way). Every cell is HTML-escaped at render time.
|
||||
|
||||
``delimiter`` defaults to the sniffed one (:func:`sniff_csv_delimiter`,
|
||||
BUG-098): a `;`-separated file used to render as a single column.
|
||||
"""
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter)
|
||||
reader = csv_mod.reader(
|
||||
io_mod.StringIO(raw), delimiter=delimiter or sniff_csv_delimiter(raw)
|
||||
)
|
||||
try:
|
||||
rows = [row for row in reader]
|
||||
except csv_mod.Error:
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.43.0"
|
||||
version = "2.46.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.43.0"
|
||||
version = "2.46.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.43.0",
|
||||
"version": "2.46.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -19,7 +19,7 @@ avec :
|
||||
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
|
||||
- **Stemming français** — les variantes des mots sont rapprochées.
|
||||
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
|
||||
- **Facettes** — compteurs par vault et par tag sur les résultats.
|
||||
- **Facettes** — compteurs par vault, par tag et par extension sur les résultats.
|
||||
- **Pagination** — 50 résultats par page.
|
||||
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
|
||||
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
|
||||
@@ -164,9 +164,11 @@ l'application. Si vous refusez, rien n'est écrit.
|
||||
|
||||
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
|
||||
|
||||
Si le classeur est modifié ailleurs entre-temps (verrou concurrent), ObsiGate
|
||||
n'interrompt pas votre travail : un bandeau vous propose de **réessayer**
|
||||
l'enregistrement, vos modifications restant en place.
|
||||
Si le classeur est modifié ailleurs entre-temps (autre poste, Excel,
|
||||
synchronisation…), ObsiGate n'interrompt pas votre travail : un bandeau vous
|
||||
propose de **réessayer**. Le bouton **Réessayer** relit d'abord le fichier pour
|
||||
récupérer la version courante, puis rejoue l'enregistrement — vos
|
||||
modifications restent en place pendant tout ce temps.
|
||||
|
||||
### Formules
|
||||
|
||||
@@ -182,8 +184,14 @@ curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx"
|
||||
- `allow_formula` : `true` pour écrire une vraie formule (`=B1*2`).
|
||||
- `force` : `true` pour enregistrer malgré les éléments non préservés
|
||||
(sinon l'API répond **409** `xlsx_lossy_content`).
|
||||
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
|
||||
**409** `conflict` au lieu d'écraser la première.
|
||||
- `if_match` (facultatif) : la **version** du fichier attendue, telle que la
|
||||
lecture la renvoie (`xlsx_revision` ou `revision`). Si le fichier a changé
|
||||
depuis, l'écriture est refusée (**409** `conflict`, `details.reason =
|
||||
"stale_revision"`) au lieu d'écraser le travail de l'autre écrivain ;
|
||||
relisez le fichier puis renvoyez la nouvelle version. Les trois routes
|
||||
d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête
|
||||
`If-Match` ou le champ `if_match` et renvoient la version à jour dans
|
||||
`revision`.
|
||||
|
||||
### Feuilles volumineuses et lecture par fenêtres
|
||||
|
||||
@@ -206,48 +214,118 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
|
||||
lignes (1 à 1 000 par requête).
|
||||
- La réponse porte `total_rows`, `truncated` et `has_more` pour paginer.
|
||||
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
|
||||
n'est pas un `.xlsx`.
|
||||
n'est ni un `.xlsx` ni un `.xlsm`.
|
||||
|
||||
### Fonctions avancées
|
||||
|
||||
**Barre de formule et navigation clavier** — la cellule active est nommée en
|
||||
A1 au-dessus du tableau ; `Tab`/`Maj+Tab` et les flèches circulent entre les
|
||||
cellules, `Entrée` valide, `Maj+Entrée` insère un retour à la ligne, une
|
||||
plage se copie telle quelle vers un tableur.
|
||||
**Barre de formule, zone Nom et navigation clavier** — au-dessus du tableau, la
|
||||
**zone Nom** affiche l'adresse de la cellule active (`B12`) ou de la plage
|
||||
sélectionnée (`A1:B3`) et elle est **éditable** (« Atteindre ») : saisissez une
|
||||
référence puis `Entrée` pour y aller (`B12`, `A1:B3`, `$A$1`, ou `Feuille2!A1`
|
||||
pour changer d'onglet) ; une référence inconnue est refusée avec un message et
|
||||
l'adresse précédente est restaurée. La barre de formule reflète la cellule
|
||||
active et propose les **noms de fonctions** courants pendant la saisie.
|
||||
|
||||
`Tab`/`Maj+Tab` et les flèches circulent entre les cellules, `Maj+flèches` étend
|
||||
la sélection, `Entrée` valide, `Maj+Entrée` insère un saut de ligne **dans** la
|
||||
cellule, `F2` ouvre la cellule en édition, `Suppr` vide la sélection,
|
||||
`Échap` restaure la valeur d'origine ; `Origine`/`Fin` vont au bord de la ligne,
|
||||
`Ctrl+Origine`/`Ctrl+Fin` aux coins de la feuille affichée,
|
||||
`PgPréc`/`PgSuiv` font défiler d'un écran, `Ctrl+flèches` saute au bout de la
|
||||
plage de données, `Ctrl+A` sélectionne toute la feuille affichée et `Ctrl+S`
|
||||
enregistre. Tant que la cellule n'est pas en cours d'édition, `Suppr`
|
||||
efface la sélection plutôt qu'un caractère.
|
||||
|
||||
**Presse-papiers de plage** — copier, couper et coller un **bloc** de cellules
|
||||
(`Ctrl+C`, `Ctrl+X`, `Ctrl+V`, ou les entrées correspondantes du menu
|
||||
contextuel) : coller un bloc copié ici **ou depuis Excel** remplit la plage à
|
||||
partir de la cellule active et la laisse sélectionnée. Le collage est du
|
||||
**texte** (formules et valeurs recopiées telles quelles) et reste **annulable** ;
|
||||
« couper » efface la source après le collage (un collage sur place ne l'efface
|
||||
pas). Un bloc plus large que la grille affichée est tronqué, avec un message.
|
||||
|
||||
**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient
|
||||
sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
|
||||
|
||||
**Tri, filtre, recherche, export** — chaque colonne se trie (ascendant /
|
||||
descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes
|
||||
se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en
|
||||
correspondance, et la feuille s'exporte en CSV. Rien de tout cela ne
|
||||
modifie le classeur.
|
||||
**Sélection et menu contextuel** — cliquer une cellule l'active, **glisser**
|
||||
ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
|
||||
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
|
||||
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
|
||||
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
|
||||
contenu.
|
||||
|
||||
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
|
||||
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
|
||||
la recherche (`Ctrl+F` du panneau) parcourt **toutes les feuilles** : le compteur
|
||||
indique le nombre de feuilles concernées et passer sur une correspondance
|
||||
**active l'onglet** qui la contient.
|
||||
|
||||
La sortie propose quatre formats, toujours sur le **contenu affiché** (et jamais
|
||||
sur les valeurs calculées en cache) :
|
||||
|
||||
- **CSV** (bouton `CSV`) — exporte la **sélection** quand une plage de plusieurs
|
||||
cellules est active (le nom du fichier reprend la plage, ex.
|
||||
`Fruits-A1B2.csv`), sinon la feuille entière ;
|
||||
- **Markdown** et **HTML** (menu **Exporter**) — tableau markdown ou document
|
||||
HTML autonome, mêmes règles de sélection ;
|
||||
- **Imprimer** (menu **Exporter**) — imprime la feuille ou la sélection seule,
|
||||
sans le ruban ni les panneaux de l'application.
|
||||
|
||||
Rien de tout cela ne modifie le classeur.
|
||||
|
||||
**Structure** — le menu **Structure** de la barre d'outils ajoute,
|
||||
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
|
||||
colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
|
||||
automatique et confirmation, comme pour l'édition des cellules).
|
||||
|
||||
**Styles et mise en page** — la lecture restitue couleurs de police et de
|
||||
fond, gras/italique/souligné, alignements, cellules fusionnées et volets
|
||||
figés ; l'ancrage de la zone figée est conservé au défilement.
|
||||
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
|
||||
**sélection courante** (une cellule ou une plage) :
|
||||
|
||||
- **caractère** — gras, italique, souligné, effacer la mise en forme ;
|
||||
- **alignement** — gauche, centré, droite ;
|
||||
- **couleurs** — couleur de police et couleur de fond (sélecteur natif, aucune
|
||||
palette imposée) ;
|
||||
- **format de nombre** — général, nombre, pourcentage, devise, date, texte ;
|
||||
- **structure** — fusionner / défusionner les cellules, figer / libérer les
|
||||
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
|
||||
plage).
|
||||
|
||||
L'écriture passe par `PUT …/xlsx/style`, avec les mêmes garanties que l'édition
|
||||
des cellules : backup automatique, écriture atomique, confirmation si
|
||||
l'opération détruirait des éléments non préservables (graphiques, valeurs
|
||||
calculées en cache…) et **détection d'un écrivain externe** (`If-Match` →
|
||||
message « Réessayer »). Un `.csv` ne portant pas de mise en forme, le bouton
|
||||
n'y est pas proposé (il est également absent d'une grille en lecture seule).
|
||||
La lecture restitue par ailleurs les couleurs, polices, cellules fusionnées et
|
||||
volets figés du fichier ; l'ancrage de la zone figée est conservé au défilement.
|
||||
Les **commentaires**, liens hypertexte, validation de données, mise en forme
|
||||
conditionnelle et bordures restent hors périmètre.
|
||||
|
||||
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
|
||||
**macros sont préservées** à l'enregistrement ; `.xls` et `.ods` s'affichent
|
||||
en **lecture seule** ; un `.csv` s'ouvre dans la même grille et se réécrit
|
||||
conformément à la RFC 4180 (les guillemets et séparateurs sont
|
||||
échappés).
|
||||
**macros sont préservées** à l'enregistrement (y compris le chargement des
|
||||
lignes au-delà du plafond) ; `.xls` et `.ods` s'affichent en **lecture
|
||||
seule** ; un `.csv` s'ouvre dans la même grille et se réécrit conformément à
|
||||
la RFC 4180 (les guillemets et séparateurs sont échappés). Le **séparateur
|
||||
du CSV est détecté** (`;`, `,` ou tabulation) à la lecture et **réutilisé à
|
||||
l'enregistrement** : un fichier exporté par Excel en français (point-virgule)
|
||||
s'affiche donc en colonnes distinctes et le reste après édition.
|
||||
|
||||
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
|
||||
latéral droit** (la grille reste visible à côté) qui liste les plages
|
||||
nommées du classeur (nom, référence, portée), signale les feuilles
|
||||
contenant des graphiques ou des tableaux croisés, et donne pour chaque
|
||||
feuille un résumé (cellules, lignes, colonnes, formules, valeurs
|
||||
numériques) avec quelques chiffres clés. L'en-tête de l'inspecteur offre
|
||||
numériques) avec quelques chiffres clés. Cliquer une **plage nommée**
|
||||
sélectionne sa première cellule dans la grille, et le panneau est
|
||||
**redimensionnable**. L'en-tête de l'inspecteur offre
|
||||
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
|
||||
plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
`append_xlsx_rows`).
|
||||
plages. Ses outils couvrent désormais les **trois formats édités**
|
||||
(`.xlsx`, `.xlsm`, `.csv`) : `list_xlsx_sheets` et `xlsx_to_markdown` pour lire,
|
||||
`search_workbook` (recherche dans toutes les feuilles, comptée par feuille),
|
||||
`analyze_range` (agrégats — nombre, somme, moyenne, min, max — d'une plage A1),
|
||||
`update_xlsx_cells` et `append_xlsx_rows` pour modifier, et
|
||||
`edit_xlsx_structure` pour la structure (ajouter/renommer/dupliquer/supprimer
|
||||
une feuille, insérer/supprimer des lignes ou des colonnes).
|
||||
|
||||
### Limites
|
||||
|
||||
@@ -255,14 +333,33 @@ plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
sous une feuille plus grande, le bouton **« Charger la suite »** (ou le
|
||||
défilement vers le bas du tableau) ajoute les lignes suivantes par
|
||||
fenêtres de 500 — elles deviennent aussitôt éditables et
|
||||
sauvegardables.
|
||||
sauvegardables. Le chargement paresseux est **vertical uniquement** :
|
||||
l'axe des colonnes reste tronqué à 40 (les colonnes au-delà ne sont ni
|
||||
affichées ni exportées).
|
||||
- Un **format de nombre personnalisé** (devise, pourcentage…) est signalé
|
||||
par une police à chasse fixe, mais la valeur reste affichée brute.
|
||||
- L'application de **styles** depuis la visionneuse (mettre en gras,
|
||||
colorer) n'est pas proposée — seuls les styles existants sont rendus.
|
||||
par une police à chasse fixe à la lecture ; depuis le bouton **Mise en
|
||||
forme**, appliquer un format ne change que le format de la cellule, **pas**
|
||||
la valeur affichée (aucun recalcul n'est fait, cf. les limites d'export
|
||||
ci-dessous).
|
||||
- `.xls` et `.ods` restent en lecture seule (convertir vers `.xlsx` pour
|
||||
éditer) ; les macros d'un `.xlsm` sont conservées mais ne s'exécutent
|
||||
pas dans ObsiGate.
|
||||
- La **poignée de recopie** (fill), la multi-sélection `Ctrl+clic` et le
|
||||
glisser-déposer de lignes/colonnes ne sont pas proposés ; un collage de
|
||||
plusieurs cellules s'annule **cellule par cellule** (`Ctrl+Z` répété).
|
||||
- Les exports (CSV, Markdown, HTML, impression) reflètent ce qui est **affiché** :
|
||||
une feuille tronquée s'exporte tronquée, et les formules sortent telles
|
||||
qu'enregistrées (aucune valeur calculée n'est recalculée). Un classeur reste
|
||||
la source de vérité : utilisez **Charger la suite** pour exporter au-delà du
|
||||
plafond.
|
||||
- **Aucun moteur de formule** : ObsiGate lit et écrit les formules telles
|
||||
qu'Excel les a enregistrées, sans jamais les recalculer. Une saisie
|
||||
commençant par `=` ou `@` est stockée comme **texte** (garde anti-DDE)
|
||||
tant que le bouton `f(x)` n'est pas activé ; l'enregistrement vous le
|
||||
signale par un message. Excel reste la référence pour les valeurs calculées.
|
||||
- L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les
|
||||
actions de structure ; en revanche une **suppression** (feuille, ligne,
|
||||
colonne) n'est pas annulable, faute d'inverse.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -201,6 +201,13 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| *BUG-092* | Les tests réseau dépendent du DNS réel du runner : `test_worker_failure_maps_to_tool_error` échoue en `dns_error` au lieu d'atteindre le worker Playwright mocké, et le job CI `test` rougit de façon intermittente | 🟢 corrigé | P1 | CI / tests | IA | `tests/test_webrender.py`, `tests/test_web_tools.py` | Sur un runner au DNS instable : `pytest tests/test_webrender.py -k test_worker_failure_maps_to_tool_error` → `assert 'dns_error' == 'render_unavailable'` | Fixture `no_dns` mockant les **deux** références du garde SSRF `_assert_public_http_url` (celle de `backend/tools/web.py` et celle importée dans le namespace de `backend/tools/webrender.py`, ligne 30 — la seconde avait d'abord échappé au correctif). Les tests de garde SSRF n'utilisent pas la fixture et continuent de traverser le vrai garde | Le garde est appelé par `fetch_url` **avant** le traitement ; seule la couche httpx était mockée. Contre-preuve : DNS coupé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** |
|
||||
| *BUG-093* | Le job CI `security` échoue : `pip-audit` bloque sur deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910, PYSEC-2026-3911) — et le plancher `pypdf>=4.0` ne les corrigeait pas, car l'image Act du runner embarque 6.16.0 *préinstallé* dans sa toolcache Python (`Requirement already satisfied` ⇒ jamais mis à niveau) | 🟢 corrigé | P0 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | Run Gitea #1660, job `security` : `Found 2 known vulnerabilities, ignored 2 in 1 package` → `pypdf 6.16.0 PYSEC-2026-3910 6.16.1` / `PYSEC-2026-3911 6.16.1` | Plancher `pypdf>=6.16.1` (correctif des deux advisories), commenté pour expliquer la contrainte de la toolcache. Ajout de `tests/test_ci_workflow.py::TestDependencySecurityFloors`, qui verrouille les planchers de sécurité (`pypdf`, `pyjwt`) et interdit qu'ils retombent sous le correctif | Les deux advisories sont des **consommations de ressources non contrôlées** (PDF à outlines multiples ou à nombreux XForm réutilisés) et sont donc **atteignables** par ObsiGate, dont `backend/pdf_reader.py` extrait le texte et parcourt les outlines de PDF fournis par l'utilisateur. Contre-preuve : plancher remis à `>=4.0` → le garde-fou échoue. pip-audit local : 6.16.1, 6.16.2 et 6.19.0 sans vulnérabilité connue. Correction découverte en lisant le log du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log de l'étape Semgrep collé précédemment datait d'un run antérieur |
|
||||
| *BUG-094* | Feuille `.xlsx` vide ou nouvellement ajoutée : impossible d'y saisir une valeur et d'y insérer une ligne/colonne — la feuille s'affiche « Feuille vide » sans aucune cellule | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `frontend/js/viewer.js::renderXlsxViewer` | Ajouter une feuille (`PUT …/xlsx/structure` `sheet_add`) puis tenter de saisir A1 ou d'insérer une ligne/colonne | `render_sheets()` remplace une grille vide par un quadrillage vierge 20×8 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) aux vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`) | Le classeur n'était pas en cause : seule la **représentation HTML** était vide, donc aucun `td` à sélectionner → aucune cellule active → aucune action de structure possible. Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52 |
|
||||
| *BUG-095* | Le job CI `security` échoue : `pip-audit` bloque sur CVE-2026-102274 dans `pyjwt` 2.13.0 (correctif 2.14.0) — le plancher `>=2.13.0` (BUG-091) est désormais sous le dernier correctif | 🟢 corrigé | P1 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | Run Gitea du push v2.44.0, job `security` : `Found 1 known vulnerability, ignored 2 in 1 package` → `pyjwt 2.13.0 CVE-2026-102274 2.14.0` | Plancher `pyjwt[crypto]>=2.14.0` (au-dessus de la version préinstallée de la toolcache du runner, sinon `pip` répond « already satisfied »). Garde-fou `TestDependencySecurityFloors` : `FLOORS["pyjwt"]` porté à `(2, 14, 0)` — contre-preuve : plancher remis à `2.13.0` → test rouge. Commentaire de la job `security` mis à jour | Le plancher de BUG-091 (2.13.0) corrigeait PYSEC-2026-178 mais est lui-même vulnérable depuis. Même mécanisme que BUG-093 (pypdf) : un plancher de sécurité doit rester au-dessus du dernier correctif. Vérifié : `test_ci_workflow.py` 9 passed ; pip-audit local OK (pyjwt 2.15.1 ≥ 2.14.0) |
|
||||
| *BUG-096* | [🟡 IMPORTANT] Enregistrer un `.csv` depuis la visionneuse échoue : `TypeError` sur `sheets[…].name` (aucun `PUT …/csv/save` émis) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`renderXlsxViewer`, construction des jobs de sauvegarde) | Ouvrir un `.csv` dans ObsiGate, modifier une cellule, cliquer **Enregistrer** | `frontend/js/viewer.js` : le job de sauvegarde ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV — l'origine du `TypeError`) — repli feuille → `data.title`/`data.path`. Test JSDOM « saving an edited csv PUTs /csv/save (payload without xlsx_sheets) ». Vérifié : suite 1490 passed / 6 skipped, `xlsx-viewer.test.mjs` 61/61 | Défaut #156 A1. En mode CSV la réponse de lecture n'a pas de `xlsx_sheets` (`backend/routers/files_read.py`) : `sheets` est vide et `sheets[Number(panel.dataset.sheet)].name` lève un `TypeError` **avant** le `try`, donc aucun `PUT /api/file/{vault}/csv/save` n'est émis (le service `save_csv_cells()` et l'endpoint sont, eux, corrects). **Reproduit en JSDOM le 2026-09-29** (harnais de `tests/frontend/xlsx-viewer.test.mjs`) : `Cannot read properties of undefined (reading 'name')`, 0 requête d'API enregistrée. Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-097* | [🟡 IMPORTANT] Feuille `.xlsm` tronquée : « Charger la suite » échoue en **415** (`GET …/xlsx/sheet` n'accepte que `.xlsx`) alors que le `.xlsm` est éditable | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/routers/files_read.py` (`api_file_xlsx_sheet`), `frontend/js/viewer.js` (`wireLazyRows`) | Ouvrir un `.xlsm` de plus de 500 lignes puis cliquer le pied « Charger la suite » (ou approcher du bas du tableau) | `backend/routers/files_read.py` : `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (autres formats → 415 inchangé). Tests `TestXlsmEditable::test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats` | Défaut #156 A2. `.xlsm` est servi éditable (`files_read.py:491-512`, pas de `xlsx_readonly`) donc la visionneuse câble le chargement paresseux, mais `api_file_xlsx_sheet` refuse tout ce qui n'est pas `.xlsx` (`files_read.py:272`). Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
@@ -296,6 +303,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-27 | BUG-083 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-083** : job `security` rouge — le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée (`unexpected EOF while looking for matching '"'"`, `/var/run/act/workflow/4` ligne 2). Seul `run:` du workflow avec un `#` (les `#` des noms d'étapes Bandit/Npm audit sont inoffensifs, ces étapes passent). Correctif : echo sans `#` (réf `#87` en commentaire YAML). Garde-fou `test_ci_workflow.py` (aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM — BUG-082) + contre-preuve sur l'ancien `ci.yml`. Vérifié : 56 passed. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-081 | Correction | `backend/auth/router.py`, `tests/test_mfa.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-081** : `GET /api/auth/mfa/status` répondait 500 quand l'auth est désactivée — le pseudo-user `anonymous` n'a aucune entrée en store (`get_user` → `None`, `AttributeError` sur `user.get`). Garde `user is None` → payload « MFA désactivé ». Test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-094 | Correction | `backend/xlsx_reader.py`, `frontend/js/viewer.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-094 — une feuille vide ou nouvellement ajoutée devient éditable et manipulable.** `render_sheets()` substitue une grille vierge 20×8 (`EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) quand la feuille ne porte aucune cellule, avec de vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`). Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-095 | Correction CI / sécurité | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Le job `security` repasse au vert** : `pyjwt` 2.13.0 est vulnérable (CVE-2026-102274, correctif 2.14.0) et le plancher de BUG-091 (`>=2.13.0`) était donc sous le dernier correctif. Plancher porté à `pyjwt[crypto]>=2.14.0` (au-dessus de la toolcache du runner, sinon `pip` répond « already satisfied »), garde-fou `TestDependencySecurityFloors` mis à jour (contre-preuve : plancher remis à 2.13.0 → test rouge), commentaire de la job `security` aligné. Vérifié : `test_ci_workflow.py` 9 passed, pip-audit local OK (pyjwt 2.15.1). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-096 → BUG-099 (#156 A1-A4) | Correction | `frontend/js/viewer.js`, `frontend/locales/{fr,en}.json`, `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/test_xlsx_viewer.py`, `tests/test_xlsx_formats.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `docs/ISSUES_TODOLIST.md` | **Les 4 défauts P0 du tableur sont corrigés.** (BUG-096) l'enregistrement d'un `.csv` depuis la visionneuse ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV) : le `TypeError` partait **avant** tout appel réseau, aucun `PUT …/csv/save` n'était émis. (BUG-097) `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` — le chargement des lignes au-delà du plafond fonctionne pour les classeurs macro. (BUG-098) `sniff_csv_delimiter()` détecte `;`/`,`/tabulation et `save_csv_cells()` réutilise le délimiteur : un CSV français s'affiche en colonnes distinctes et **le reste** après édition. (BUG-099) sonde de perte **par feuille** (4 Mo) + plafond global (32 Mo) et clé `cached_values_unverified` quand le budget est épuisé — plus de perte silencieuse possible des valeurs calculées. Tests : `TestXlsxCachedValueProbe` (3, contre-preuve : budget épuisé signalé au lieu de `[]`), `TestCsvDelimiter` + 3 tests CSV, fenêtre `.xlsm` (+ refus `.csv`), 1 test JSDOM CSV. Vérifié : suite **1490 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), `xlsx-viewer.test.mjs` 61/61, validate-imports 40 modules, unit 12/12, i18n parity. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | #156 A5-A7 (P1) | Fonctionnalité (sans nouveau défaut) | `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | **P1 livré** — (A5) presse-papiers de plage : copier/couper/coller un bloc TSV au clavier et au menu contextuel, presse-papiers interne + miroir système, remplissage multi-cellules, insertion **texte** échappée, débordement signalé, annulable ; (A6) clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) ; (A7) zone Nom éditable (« Atteindre ») + liste de fonctions. Vérifié : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux), E2E Playwright. | ✅ livré |
|
||||
| 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré |
|
||||
| 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+88
-12
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.43.0 | **Dernière mise à jour :** 2026-09-29
|
||||
> **Version :** 2.46.0 | **Dernière mise à jour :** 2026-10-01
|
||||
> **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)**
|
||||
@@ -97,13 +97,12 @@
|
||||
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
|
||||
- **Statut :** 🔵 **en cours** — **Lots 1, 2, 3 & 4 livrés le 2026-09-29** (ruban de commandes groupé,
|
||||
onglets de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non
|
||||
recalculées, tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`,
|
||||
bandeau de conflit 409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** pour le
|
||||
tableau de bord avec entrée Assistant IA ; **undo/redo**, chargement via `IntersectionObserver`,
|
||||
ARIA `role="grid"`). Reste **A5** : découpage modulaire (`frontend/js/xlsx/*`) et finitions
|
||||
(lien dashboard → grille, inspecteur redimensionnable, split).
|
||||
- **Statut :** ✅ **livré le 2026-09-29 (Lots 1 → 5, A1-A5)** — ruban de commandes groupé, onglets
|
||||
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non recalculées,
|
||||
tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`, bandeau de conflit
|
||||
409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** (tableau de bord + entrée
|
||||
Assistant IA, redimensionnable) ; **undo/redo**, chargement via `IntersectionObserver`,
|
||||
ARIA `role="grid"` ; extraction des unités sans état dans `frontend/js/xlsx/*`.
|
||||
- **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md)
|
||||
- **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale :
|
||||
commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés
|
||||
@@ -116,7 +115,74 @@
|
||||
- [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
|
||||
- [x] **A3** Inspecteur droit repliable : Tableau de bord + entrée Assistant IA (Lot 3)
|
||||
- [x] **A4** Undo/redo, chargement via `IntersectionObserver`, sémantique ARIA (Lot 4)
|
||||
- [ ] **A5** Découpage `frontend/js/xlsx/*` + lien dashboard → grille, inspecteur redimensionnable, split
|
||||
- [x] **A5** Découpage `frontend/js/xlsx/*`, lien dashboard → grille, inspecteur redimensionnable (Lot 5)
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Ergonomie tableur
|
||||
|
||||
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
|
||||
|
||||
- **Effort :** 2-4 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré** — 2026-09-29 (menu contextuel clic droit + appui long, sélection type Excel)
|
||||
- **Analyse, conception et critères :** [features/xlsx-context-menu.md](./features/xlsx-context-menu.md)
|
||||
- **Description :** retours utilisateur après #154 — ajouter un **menu contextuel** (clic droit +
|
||||
appui long tactile) sur les cellules et en-têtes, et aligner la **sélection/curseur** sur le
|
||||
comportement d'Excel (plage par glisser, `Maj`, sélection de ligne/colonne par en-tête).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Menu contextuel thémé (clic droit + appui long) : insérer/supprimer ligne & colonne,
|
||||
trier, effacer le contenu
|
||||
- [x] **A2** Sélection type Excel : plage (glisser / `Maj+clic` / `Maj+flèches`), en-têtes
|
||||
ligne/colonne, curseur croix, zone Nom affichant la plage
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Tableur Excel (complétude)
|
||||
|
||||
### 156. Éditeur Excel — complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
- **Effort :** 15-22 jours (P0 2-3 j ✅ · P1 4-6 j ✅ · P2 5-7 j ✅ · P3 4-6 j ✅) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-09-30** — **P0** (4 défauts **BUG-096 → BUG-099** corrigés),
|
||||
**P1** (**A5-A7** : presse-papiers de plage, clavier complet, zone Nom éditable), **P2**
|
||||
(**A8** mise en forme en écriture, **A9** décision « pas de moteur de formule, annoncée dans
|
||||
l'UI », **A10** undo/redo unifié conservé au re-rendu) et **P3** (**A11** export sélection/
|
||||
Markdown/HTML/impression + recherche multi-feuilles, **A12** concurrence optimiste `If-Match`,
|
||||
**A13** cache des métadonnées, **A14** outils IA `.xlsm`/`.csv` + recherche/analyse/structure)
|
||||
- **Analyse, défauts, risques et critères d'acceptation :** [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md)
|
||||
- **Description :** #153 a rendu l'éditeur **correct** sur la grille de valeurs, #154/#155 l'ont
|
||||
rendu **convivial** (ruban, inspecteur, undo/redo des cellules, sélection et menu contextuel
|
||||
type Excel). Il reste l'écart avec un vrai éditeur tableur : coller une **plage**, écrire la
|
||||
**mise en forme**, **calculer**, **sortir** le résultat, et détecter un écrivain concurrent.
|
||||
La fiche porte l'audit ; les cases ci-dessous sont le **suivi de référence**.
|
||||
- **Défauts corrigés le 2026-09-29 (cf. [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md)) :**
|
||||
- ✅ BUG-096 — enregistrer un `.csv` depuis la visionneuse levait un `TypeError`
|
||||
(`sheet: sheets[…].name` alors qu'un CSV n'a pas de `xlsx_sheets`) : aucun `PUT …/csv/save`
|
||||
n'était émis (reproduit en JSDOM, corrigé + test) ;
|
||||
- ✅ BUG-097 — feuille `.xlsm` tronquée : « Charger la suite » échouait en **415** —
|
||||
`GET …/xlsx/sheet` accepte désormais `.xlsx` **et** `.xlsm` ;
|
||||
- ✅ BUG-098 — délimiteur CSV détecté (`;`/`,`/tabulation) et réutilisé à l'écriture :
|
||||
un CSV français s'affiche en colonnes et le reste après édition ;
|
||||
- ✅ BUG-099 — sonde de perte **par feuille** + signal `cached_values_unverified` quand le
|
||||
budget est épuisé : plus de perte silencieuse possible des valeurs calculées.
|
||||
- **Sous-tâches :**
|
||||
- **P0 — défauts (🔴, 2-3 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A1** Sauvegarde `.csv` depuis la visionneuse — BUG-096 (test JSDOM : `PUT …/csv/save` émis)
|
||||
- [x] **A2** Chargement paresseux des `.xlsm` — BUG-097 (`GET …/xlsx/sheet` accepte `.xlsx`/`.xlsm`)
|
||||
- [x] **A3** Délimiteur CSV détecté et réutilisé à l'écriture — BUG-098 (`sniff_csv_delimiter`)
|
||||
- [x] **A4** Sonde de perte par feuille + signal `cached_values_unverified` — BUG-099
|
||||
- **P1 — presse-papiers & clavier (🟡, 4-6 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A5** Presse-papiers de plage — copier/couper/coller un bloc TSV (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`, menu contextuel), presse-papiers interne + système, remplissage multi-cellules (tests JSDOM : `A1:B2` → `D5:E6`)
|
||||
- [x] **A6** Clavier complet — `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` (saut de ligne en cellule)
|
||||
- [x] **A7** Zone Nom éditable (« Atteindre » : `B12`, `A1:B3`, `Feuille!A1`) + aide à la saisie des fonctions
|
||||
- **P2 — mise en forme, calcul, undo (🟡, 5-7 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A8** Mise en forme en écriture — bouton **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur) via `PUT …/xlsx/style` (verrou, backup, garde de perte, `If-Match`)
|
||||
- [x] **A9** Calcul — **décision documentée** : pas de moteur de formule, l'enregistrement annonce « enregistrée comme texte » (la garde anti-DDE reste)
|
||||
- [x] **A10** Undo/redo unifié (cellules, effacement, tri/filtre, structure) et conservé au re-rendu (`_xlsxHistory` par fichier)
|
||||
- **P3 — sortie, robustesse, performances (🟢, 4-6 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A11** Sortie & recherche : export de la sélection / Markdown / HTML / impression, recherche sur toutes les feuilles (compteur `n/m · k feuilles`)
|
||||
- [x] **A12** Concurrence optimiste inter-processus (`ETag`/`If-Match`, **409** réparable, bouton « Réessayer » qui relit)
|
||||
- [x] **A13** Performances : cache des métadonnées par `(chemin, mtime, taille)` (LRU 8), invalidé à chaque écriture (axe des colonnes > `MAX_COLS` : hors périmètre)
|
||||
- [x] **A14** Outils IA étendus — `.xlsm`/`.csv` acceptés, `search_workbook`, `analyze_range`, `edit_xlsx_structure`
|
||||
|
||||
---
|
||||
|
||||
@@ -168,6 +234,9 @@
|
||||
| # | Domaine / fonctionnalité | Version | Détails |
|
||||
|---|---|---|---|
|
||||
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.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) |
|
||||
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
|
||||
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
|
||||
@@ -235,6 +304,7 @@
|
||||
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
|
||||
| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) | 2.46.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -247,8 +317,10 @@
|
||||
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
|
||||
| ⚪ 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 |
|
||||
| 🔵 En cours | #154 Refonte UI/UX tableur (A1 ✅ Lot 1 · A2-A4 restants) | ~5-7 jours |
|
||||
| **Total chemin critique** | **#77 fin + #87 + #154** | **~9-13 jours** |
|
||||
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
|
||||
| ✅ Terminé | #155 Ergonomie tableur — menu contextuel & sélection type Excel | 0 jour restant |
|
||||
| ✅ Terminé | #156 Éditeur Excel — complétude — **P0-P3 ✅ livrés le 2026-09-30** (BUG-096 → BUG-099, A5-A14) | 0 jour restant |
|
||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||
|
||||
---
|
||||
|
||||
@@ -256,7 +328,11 @@
|
||||
|
||||
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
|
||||
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
|
||||
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; Lot 1 livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille).
|
||||
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
|
||||
- **Ajout 2026-09-29 :** #155 livré — menu contextuel (clic droit / appui long) et sélection type Excel sur la grille ([features/xlsx-context-menu.md](./features/xlsx-context-menu.md)) ; au passage **BUG-094** corrigé (feuille vide/nouvelle désormais éditable, quadrillage vierge 20×8).
|
||||
- **Clôture 2026-09-30 :** #156 **livré (P0 → P3)** — **A8** mise en forme en écriture (bouton **Mise en forme** : gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — via `PUT …/xlsx/style`), **A9** décision « pas de moteur de formule, annoncée dans l'UI », **A10** undo/redo unifié conservé au re-rendu, **A11** export de la sélection / Markdown / HTML / impression + recherche sur toutes les feuilles, **A12** concurrence optimiste (`If-Match`, **409** réparable), **A13** cache des métadonnées par `mtime`, **A14** outils IA `.xlsm`/`.csv` + `search_workbook`/`analyze_range`/`edit_xlsx_structure` — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Ajout 2026-09-29 :** #156 **P1 livré** — **A5** presse-papiers de plage (copier/couper/coller un bloc, presse-papiers interne + système, entrées du menu contextuel, remplissage multi-cellules), **A6** clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) et **A7** zone Nom éditable + aide à la saisie ; restent P2 (mise en forme, calcul, undo unifié) et P3 (sortie, concurrence optimiste, performances, outils IA).
|
||||
- **Ajout 2026-09-29 :** #156 ouvert — audit de complétude de l'éditeur Excel : **4 défauts recensés** (BUG-096 enregistrement `.csv`, BUG-097 lazy-load `.xlsm`, BUG-098 délimiteur CSV, BUG-099 sonde de perte), **corrigés le jour même (P0 ✅)** avec tests de non-régression ; restent P1-P3 (presse-papiers, clavier, mise en forme, calcul, export, concurrence optimiste) puis presse-papiers de plage, clavier complet, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **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.
|
||||
|
||||
@@ -419,6 +419,22 @@ première modification et téléchargement du fichier d'origine.
|
||||
|
||||
---
|
||||
|
||||
## #157 — Recherche : facette « Extensions » ✅ TERMINÉ
|
||||
|
||||
Le panneau de facettes de la page de résultats de recherche affiche désormais **trois** groupes :
|
||||
**Vaults**, **Tags** et **Extensions**. Le troisième liste les extensions de fichiers présents
|
||||
dans le résultat courant (`.md`, `.xlsx`, `.pdf`, …) avec leur compteur ; un clic ajoute
|
||||
l'opérateur `ext:<type>` à la requête (un `ext:` déjà actif est remplacé, jamais dupliqué).
|
||||
|
||||
| Aspect | Détail |
|
||||
|---|---|
|
||||
| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence |
|
||||
| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) |
|
||||
| i18n / aide | `help.desc_facet_extensions` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` |
|
||||
| Tests | `tests/test_search.py::test_facets` (clé `extensions`), `tests/frontend/search-facets.test.mjs` (4 tests JSDOM, ajoutés au CI) |
|
||||
|
||||
---
|
||||
|
||||
## Grosses fonctionnalités — fiches dédiées
|
||||
|
||||
| # | Feature | Version | Fiche |
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# #155 — Ergonomie tableur : menu contextuel, sélection type Excel
|
||||
|
||||
> **Item de roadmap :** [#155 — Ergonomie tableur (menu contextuel + sélection)](../ROADMAP.md)
|
||||
> **Origine :** retours utilisateur après #154 (refonte UI/UX tableur)
|
||||
> **Statut :** ✅ **livré** — 2026-09-29
|
||||
> **Effort estimé :** 2-4 jours (réalisé en 1 lot)
|
||||
|
||||
---
|
||||
|
||||
## 1. Demande
|
||||
|
||||
1. **Menu contextuel** au clic droit (et **appui long** sur mobile/tactile) dans la grille, sur une
|
||||
cellule, un numéro de ligne ou un en-tête de colonne.
|
||||
2. **Comportement du curseur / de la sélection comme Excel** : le curseur en forme de croix
|
||||
au-dessus des cellules, sélection d'une **plage** (glisser, `Maj+clic`, `Maj+flèches`), clic sur
|
||||
un en-tête de ligne/colonne pour sélectionner toute la ligne/colonne.
|
||||
3. (BUG-094, traité séparément) une **feuille vide/nouvelle** doit rester éditable et permettre
|
||||
d'ajouter lignes/colonnes.
|
||||
|
||||
## 2. Conception
|
||||
|
||||
### Menu contextuel
|
||||
- Positionné au **pointeur** (souris) ou au point de contact (tactile), borné au viewport.
|
||||
- Thème de l'application (classes `.xlsx-context-menu`, réutilise `.btn-action`).
|
||||
- Contenu selon la cible et le mode :
|
||||
- **Insérer** : ligne au-dessus / en dessous, colonne à gauche / à droite (sur la plage
|
||||
sélectionnée, via `PUT …/xlsx/structure`, `count` = taille de la plage).
|
||||
- **Supprimer** : ligne(s) / colonne(s) — confirmation modale (destructif).
|
||||
- **Trier** : A→Z / Z→A sur la colonne de gauche de la sélection.
|
||||
- **Effacer le contenu** : vide les cellules sélectionnées (modification locale, non
|
||||
sauvegardée tant que l'utilisateur n'enregistre pas).
|
||||
- Masqué pour les formats en lecture seule (`.xls`/`.ods`) ; sans les actions de structure pour un
|
||||
`.csv`.
|
||||
- **Appui long** (tactile) : minuteur ~500 ms annulé au déplacement / relâchement.
|
||||
|
||||
### Sélection type Excel
|
||||
- **Clic** : cellule active + sélection simple ; la barre de formule (zone Nom) affiche la plage
|
||||
(`A1` ou `A1:B3`).
|
||||
- **Glisser** : sélection rectangulaire (`mousedown` → `mouseover` sur une autre cellule →
|
||||
`mouseup`) ; le texte de la cellule n'est plus sélectionné pendant le glisser.
|
||||
- **`Maj+clic`** et **`Maj+flèches`** : étendent la sélection depuis l'ancre.
|
||||
- **En-têtes** : clic sur un numéro de ligne / lettre de colonne sélectionne la ligne/colonne
|
||||
entière. Le tri n'est plus déclenché par simple clic sur l'en-tête (il passe au menu contextuel),
|
||||
conformément au comportement Excel.
|
||||
- **Curseur** : `cursor: cell` (croix) sur les cellules, `pointer` sur les en-têtes.
|
||||
|
||||
## 3. Fichiers
|
||||
|
||||
| Couche | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Menu | `frontend/js/xlsx/context-menu.js` (nouveau) | Menu contextuel générique thémé (build + position + dismiss) |
|
||||
| Sélection + câblage | `frontend/js/viewer.js` | État de sélection, drag, `Maj`, en-têtes, ouverture du menu, actions |
|
||||
| Styles | `frontend/style.css` | `.xlsx-selected`, `.xlsx-context-menu`, curseurs |
|
||||
| i18n | `frontend/locales/{fr,en}.json` | Libellés du menu |
|
||||
| Tests | `tests/frontend/xlsx-viewer.test.mjs` | Sélection, menu contextuel, tri via menu |
|
||||
|
||||
## 4. Critères d'acceptation
|
||||
|
||||
- Clic droit sur une cellule ouvre un menu thémé aux coordonnées du pointeur ; `Échap` ou clic
|
||||
ailleurs le ferme.
|
||||
- Insérer/supprimer ligne/colonne via le menu envoie la bonne action `PUT …/xlsx/structure`
|
||||
(avec `count` pour une plage) puis re-rend.
|
||||
- Glisser de A1 vers B2 surligne A1:B2 et la zone Nom affiche `A1:B2`.
|
||||
- `Maj+clic` étend la sélection ; cliquer un en-tête sélectionne la ligne/colonne entière.
|
||||
- Aucune régression des tests #154 (édition, save, undo/redo, dashboard).
|
||||
|
||||
## 5. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Ouverture #155 (menu contextuel + sélection type Excel) suite aux retours utilisateur |
|
||||
| 2026-09-29 | **Livré** : menu contextuel thémé (`frontend/js/xlsx/context-menu.js`) ouvert au clic droit et à l'appui long, actions résultat de `PUT …/xlsx/structure` (insérer/supprimer ligne & colonne avec `count`), tri et effacement ; sélection type Excel (glisser, `Maj+clic`, `Maj+flèches`, en-têtes ligne/colonne, curseur croix, zone Nom affichant la plage) |
|
||||
@@ -0,0 +1,322 @@
|
||||
# #156 — Éditeur Excel : complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
> **Item de roadmap :** [#156 — Éditeur Excel — complétude fonctionnelle](../ROADMAP.md)
|
||||
> **Origine :** audit de complétude demandé le 2026-09-29, après #153 (backlog tableur),
|
||||
> #154 (refonte UI/UX) et #155 (menu contextuel & sélection)
|
||||
> **Statut :** ✅ **livré le 2026-09-30** — P0 ✅ (BUG-096 → BUG-099), P1 ✅ (A5, A6, A7),
|
||||
> P2 ✅ (A8, A9, A10) et P3 ✅ (A11, A12, A13, A14) ; ouverture 2026-09-29 (audit statique,
|
||||
> A1 reproduit en JSDOM)
|
||||
> **Effort estimé :** 15-22 jours (P0 2-3 j · P1 4-6 j · P2 5-7 j · P3 4-6 j) — **réalisé dans
|
||||
> l'enveloppe annoncée**
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, les défauts, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
#153 a rendu l'éditeur XLSX **correct sur le périmètre « grille de valeurs »** (lecture/écriture
|
||||
gardées, styles en lecture, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation, outils IA,
|
||||
tableau de bord). #154/#155 l'ont rendu **convivial** (ruban, inspecteur, undo/redo des cellules,
|
||||
sélection et menu contextuel type Excel).
|
||||
|
||||
Reste l'écart avec ce qu'un utilisateur attend d'un **éditeur tableur** : coller une plage,
|
||||
penser la mise en forme, calculer, sortir le résultat, et se protéger d'un autre écrivain.
|
||||
Cet item recense **4 défauts** (A1 reproduit, A4 théorique) et les **manques fonctionnels**
|
||||
correspondants.
|
||||
|
||||
## 2. Périmètre (couches touchées)
|
||||
|
||||
| Couche | Fichier | Rôle dans cet item |
|
||||
|---|---|---|
|
||||
| Lecture XLSX | `backend/xlsx_reader.py` | Fenêtres de lignes, délimiteur CSV, sonde de perte, métadonnées |
|
||||
| Écriture XLSX | `backend/services/mutations.py` | Cellules, structure, **style (A8)**, coercion, `revision` (A12) |
|
||||
| Endpoints | `backend/routers/files_read.py`, `files_write.py` | `xlsx/sheet`, `xlsx/save`, `xlsx/structure`, **`xlsx/style` (A8)**, `csv/save` |
|
||||
| Outils IA | `backend/tools/spreadsheets.py` | Lecture/édition par l'assistant (`.xlsx`/`.xlsm`/`.csv`, recherche, analyse, structure — A14) |
|
||||
| Visionneuse | `frontend/js/viewer.js` + `frontend/js/xlsx/*` | Presse-papiers, clavier, barre de formule, undo/redo, export |
|
||||
| Styles | `frontend/style.css` | Affordances presse-papiers/mise en forme (variables CSS) |
|
||||
| i18n | `frontend/locales/{fr,en}.json` | Tout libellé nouveau, FR **et** EN |
|
||||
| Tests | `tests/test_xlsx_*.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js` | Non-régression par sous-tâche |
|
||||
|
||||
## 3. Audit
|
||||
|
||||
### 3.1 Défauts recensés (P0) — ✅ corrigés le 2026-09-29
|
||||
|
||||
#### ✅ A1 → BUG-096 — Enregistrer un `.csv` depuis la visionneuse échoue
|
||||
|
||||
`frontend/js/viewer.js` construit l'unique job de sauvegarde ainsi :
|
||||
|
||||
```js
|
||||
const jobs = panelEls.map((panel) => {
|
||||
const cells = {};
|
||||
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
|
||||
return { sheet: sheets[Number(panel.dataset.sheet)].name, cells }; // ← ligne 1757
|
||||
}).filter((job) => Object.keys(job.cells).length);
|
||||
```
|
||||
|
||||
En mode CSV, la réponse de lecture (`backend/routers/files_read.py:548-556`) **ne contient pas
|
||||
`xlsx_sheets`** : `sheets` est donc vide et `sheets[...].name` lève un `TypeError`. Vérifié le
|
||||
2026-09-29 avec le harnais JSDOM (`tests/frontend/xlsx-viewer.test.mjs`) : après édition d'une
|
||||
cellule CSV et clic sur **Enregistrer**, `TypeError: Cannot read properties of undefined
|
||||
(reading 'name')` et **aucun** `PUT /api/file/{vault}/csv/save` n'est émis. Le service backend
|
||||
`save_csv_cells()` et l'endpoint sont pourtant corrects : le défaut est purement côté visionneuse.
|
||||
|
||||
*Cause racine :* le mode CSV réutilise un panneau unique (`data-sheet="0"`) sans feuille
|
||||
correspondante dans `sheets` ; le chemin d'enregistrement n'a pas été adapté comme l'ont été les
|
||||
onglets, la structure et le tableau de bord (qui, eux, testent `isCsv`).
|
||||
|
||||
**Correctif (2026-09-29) :** le nom de feuille est résolu avec repli
|
||||
(`sheets[idx]?.name || sheets[0]?.name || data.title || data.path`). Test JSDOM : un CSV monté
|
||||
**sans** `xlsx_sheets` émet un unique `PUT …/csv/save`.
|
||||
|
||||
#### ✅ A2 → BUG-097 — `.xlsm` éditable : « Charger la suite » renvoie 415 au-delà de 500 lignes
|
||||
|
||||
Un `.xlsm` est servi **éditable** (`backend/routers/files_read.py:491-512`, pas de
|
||||
`xlsx_readonly`) : la visionneuse câble donc le chargement paresseux. Mais
|
||||
`GET /api/file/{vault}/xlsx/sheet` refuse **tout ce qui n'est pas `.xlsx`** (`files_read.py:272`,
|
||||
**415**). Une feuille `.xlsm` de plus de 500 lignes affiche donc le pied « Charger la suite » dont
|
||||
chaque clic échoue (toast d'erreur), alors que la limitation n'est pas annoncée comme telle pour
|
||||
ce format.
|
||||
|
||||
**Correctif (2026-09-29) :** `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (les autres
|
||||
formats restent en 415). Tests : fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`.
|
||||
|
||||
#### ✅ A3 → BUG-098 — Délimiteur CSV figé : un `.csv` français s'ouvre en une seule colonne
|
||||
|
||||
`render_csv_table(raw)` utilise le délimiteur par défaut `,` (`backend/xlsx_reader.py:784`),
|
||||
appelé sans argument (`backend/routers/files_read.py:551`) — tandis que l'**export** CSV de la
|
||||
visionneuse écrit du `;` et un BOM UTF-8. Un CSV produit par Excel/Sheets en locale française
|
||||
(`;`) s'affiche donc en une colonne. Le même `,` est utilisé par `save_csv_cells()` pour re-parser
|
||||
et réécrire le fichier : l'édition peut re-sérialiser dans un format différent de la source.
|
||||
|
||||
**Correctif (2026-09-29) :** `sniff_csv_delimiter()` (Sniffer, repli par décompte sur la
|
||||
première ligne, puis `,`) est partagé par le rendu et la réécriture — `save_csv_cells()`
|
||||
réutilise le délimiteur détecté. Tests : lecture, écriture et champs entre guillemets contenant
|
||||
le séparateur.
|
||||
|
||||
#### ✅ A4 → BUG-099 — Sonde de perte plafonnée à 8 Mo : perte silencieuse possible
|
||||
|
||||
`inspect_workbook()` détecte les valeurs calculées en cache via un budget global
|
||||
`_MAX_PROBE_BYTES = 8_000_000` (`backend/xlsx_reader.py:69`) partagé entre toutes les feuilles
|
||||
(`_has_cached_formulas`). Sur un classeur dont le XML des feuilles dépasse ce budget **avant** la
|
||||
première formule cachée, `xlsx_lossy_features` ne contient pas `cached_values` : le `409`
|
||||
`xlsx_lossy_content` n'est pas déclenché et l'enregistrement détruit ces valeurs **sans
|
||||
avertissement** — exactement le risque n°1 de #153. À ce stade, risque **théorique** (non
|
||||
reproduit).
|
||||
|
||||
**Correctif (2026-09-29) :** budget **par feuille** (4 Mo) + plafond global (32 Mo) ;
|
||||
`_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute
|
||||
`cached_values_unverified` (libellé i18n FR/EN) : la sauvegarde demande une confirmation au lieu
|
||||
de passer en silence. Trucs de test : budgets monkeypatchés. Contre-preuve : budget épuisé →
|
||||
signalé au lieu de `[]` (comportement antérieur).
|
||||
|
||||
### 3.2 Fonctions manquantes (écart vs Excel / Google Sheets)
|
||||
|
||||
**Presse-papiers et sélection** — ✅ traité en P1 (A5)
|
||||
|
||||
- ✅ Coller une **plage** : le handler forcait tout sur une ligne
|
||||
(`viewer.js` : `replace(/\r?\n/g, " ")`) — coller un bloc TSV/CSV depuis Excel écrasait une seule
|
||||
cellule. Le collage découpe désormais le bloc (`parseMatrix`) et remplit la plage à partir de la
|
||||
cellule active (`fillFrom`), 1×1 restant un remplacement de cellule.
|
||||
- ✅ Copier / Couper / Coller de plage côté application : `Ctrl+C`/`Ctrl+X`/`Ctrl+V` (événements
|
||||
`copy`/`cut`/`paste`) **et** entrées du menu contextuel ; une sélection de texte dans la cellule
|
||||
éditée garde le comportement natif.
|
||||
- Toujours absents : poignée de recopie (fill), multi-sélection `Ctrl+clic`, glisser-déposer de
|
||||
lignes/colonnes, copie d'une image/du HTML.
|
||||
|
||||
**Clavier / navigation** — ✅ traité en P1 (A6, A7)
|
||||
|
||||
- ✅ `Ctrl+S`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Ctrl+A`
|
||||
sont câblés dans un handler unique de la grille ; une cellule reste en mode « navigation »
|
||||
jusqu'à la saisie (ou `F2`), sinon `Suppr`/`Home`/`End` voleraient une touche du curseur.
|
||||
- ✅ `Maj+Entrée` insère un saut de ligne **dans** la cellule (texte uniquement).
|
||||
- ✅ La **zone Nom** est un champ éditable (« Atteindre ») : `B12`, `A1:B3`, `Feuille!A1`.
|
||||
- ✅ Aide à la saisie : la barre de formule propose les noms de fonctions courants (`<datalist>`,
|
||||
liste localisée FR/EN). L'autocomplétion contextuelle reste à faire si un moteur de formules
|
||||
arrive (A9).
|
||||
|
||||
**Mise en forme en écriture** — ✅ traitée (A8, 2026-09-30)
|
||||
|
||||
- ✅ Un bouton **Mise en forme** (`#xlsx-format-btn`, classeurs éditables non-CSV) agit sur la
|
||||
**sélection** via `PUT …/xlsx/style` : gras / italique / souligné / effacer, alignement
|
||||
gauche-centré-droite, couleur de police et de fond (`<input type="color">`, donc aucune
|
||||
palette codée en dur), formats de nombre (général, nombre, pourcentage, devise, date, texte),
|
||||
fusion/défusion, volets figés/libérés, largeur de colonne et hauteur de ligne. La fusion exige
|
||||
une vraie plage (`xlsx.format_merge_needs_range`).
|
||||
- Écriture sur le modèle de `xlsx/save` / `xlsx/structure` : verrou par fichier, backup, écriture
|
||||
atomique (`.tmp` + `os.replace`), garde de perte (refus sans `force`), `If-Match` (A12), garde
|
||||
anti-formule de BUG-088, plafond `MAX_STYLE_CELLS = 10 000`, invalidation du cache méta (A13).
|
||||
- Toujours **hors périmètre** : commentaires, liens hypertexte, validation de données, mise en
|
||||
forme conditionnelle, protection de cellules (lecture seule), bordures et palette Excel.
|
||||
|
||||
**Calcul** — ✅ décision documentée (A9, 2026-09-30)
|
||||
|
||||
- Aucun moteur de formule n'est livré : ObsiGate lit et écrit les formules telles qu'Excel les a
|
||||
enregistrées, sans recalcul (pas de références inter-feuilles, pas d'agrégats). Le choix est
|
||||
**annoncé à l'utilisateur** : un enregistrement contenant une valeur commençant par `=` ou `@`
|
||||
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé ; la garde
|
||||
anti-DDE de BUG-088 reste en place.
|
||||
|
||||
**Sortie et recherche** — ✅ traité (A11, 2026-09-30)
|
||||
|
||||
- ✅ Export de la **sélection** (CSV, nom `Feuille-A1B2.csv`) quand une plage de plusieurs cellules
|
||||
est active, sinon la feuille entière ; l'écart doc/code de #153-A13 est tranché dans ce sens.
|
||||
- ✅ Menu **Exporter** : Markdown (tableau), HTML (document autonome) et **impression** (iframe
|
||||
hors écran, cadre précédent détruit). Les quatre formats suivent le **contenu affiché**.
|
||||
- ✅ La recherche (`Ctrl+F`) parcourt **toutes les feuilles** : compteur `n/m · k feuilles` et
|
||||
activation automatique de l'onglet contenant la correspondance.
|
||||
|
||||
**Robustesse / multi-postes** — ✅ traitée (A10, A12, 2026-09-30)
|
||||
|
||||
- ✅ Concurrence optimiste : `revision` (`mtime_ns` + taille) renvoyée à la lecture et acceptée en
|
||||
`If-Match` sur `xlsx/save`, `xlsx/structure`, `xlsx/style` et `csv/save` ; un écrivain externe
|
||||
détecté donne un **409** `conflict` (`details.reason = "stale_revision"`) au lieu d'un
|
||||
écrasement, et le bandeau « Réessayer » **relit** le fichier avant de rejouer.
|
||||
- ✅ Piles undo/redo **unifiées et conservées au re-rendu** (`_xlsxHistory` par fichier) : édition,
|
||||
effacement, tri/filtre et structure ; action destructive (suppression) non annulable faute
|
||||
d'inverse, et lecture du filtre **coalescée** (une entrée par session de frappe).
|
||||
- Le tri/filtre restent **d'affichage** : après un tri, un enregistrement ne persiste pas l'ordre
|
||||
(seules les cellules sales sont écrites) — le libellé `xlsx.sort_applied` le dit.
|
||||
|
||||
**Outils IA** — ✅ traités (A14, 2026-09-30)
|
||||
|
||||
- ✅ `_spreadsheet_path()` accepte `.xlsx`, `.xlsm` (macros préservées) et `.csv` (délimiteur
|
||||
conservé) ; `update_xlsx_cells` délègue à `save_csv_cells` pour un CSV.
|
||||
- ✅ Nouveaux outils : `search_workbook` (recherche multi-feuilles, comptée par feuille),
|
||||
`analyze_range` (nombre, somme, moyenne, min, max d'une plage A1), `edit_xlsx_structure`
|
||||
(feuilles, lignes, colonnes — risque WRITE, **confirmation conservée**).
|
||||
- Bornes explicites : `MAX_SCAN_ROWS/MAX_SCAN_COLS`, `MAX_SEARCH_RESULTS = 100`,
|
||||
`MAX_RANGE_VALUES = 200`, `MAX_RANGE_CELLS = 10 000`.
|
||||
|
||||
### 3.3 Limites techniques relevées
|
||||
|
||||
- `MAX_COLS = 40` sans pagination de colonnes : le chargement paresseux ne concerne que les
|
||||
lignes (`read_sheet_window`), l'axe des colonnes reste tronqué.
|
||||
- Coût de lecture : `render_sheets()` charge le classeur pour les formules, une seconde fois si
|
||||
des valeurs cachées existent, puis `read_workbook_meta()` (troisième chargement, scan
|
||||
`500 × 40` par feuille). ✅ **A13** : `read_workbook_meta()` est mémoïsé par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8) et invalidé à chaque écriture — le reload complet à chaque
|
||||
fenêtre demandée a disparu ; `render_sheets()` reste hors cache (il doit relire les valeurs).
|
||||
- `allow_formula` (bouton `f(x)`) reste actif pour toute la session une fois activé : rien ne le
|
||||
remet à zéro après un enregistrement.
|
||||
- `role="grid"` posé (#154-A4) mais sans `aria-rowcount`/`aria-colcount`/`aria-selected`/
|
||||
`aria-activedescendant` ni annonce des changements de cellule active.
|
||||
|
||||
## 4. Backlog #156 — sous-tâches
|
||||
|
||||
Légende : 🔴 P0 (défaut) · 🟡 P1 (productivité immédiate) · 🟢 P2 (fidélité/finitions) ·
|
||||
effort en jours-homme (développement + tests).
|
||||
|
||||
### P0 — défauts (2-3 j)
|
||||
|
||||
- [x] **A1 — Sauvegarde `.csv` depuis la visionneuse (BUG-096).** Nom de feuille résolu avec
|
||||
repli (feuille → `data.title`/`data.path`), le chemin d'enregistrement ne dépend plus de la
|
||||
structure du classeur. *Vérifié :* test JSDOM « saving an edited csv PUTs /csv/save
|
||||
(payload without xlsx_sheets) » (contre-preuve : échouait avec le `TypeError`).
|
||||
- [x] **A2 — Chargement paresseux des `.xlsm` (BUG-097).** `GET …/xlsx/sheet` accepte `.xlsx`
|
||||
**et** `.xlsm` ; les autres formats restent refusés (415). *Vérifié :*
|
||||
`test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats`.
|
||||
- [x] **A3 — Détection du délimiteur CSV (BUG-098).** `sniff_csv_delimiter()` partagé par la
|
||||
lecture et la réécriture, qui conserve le délimiteur détecté. *Vérifié :* `TestCsvDelimiter`
|
||||
(3 séparateurs + repli) et 3 tests client (lecture, écriture, guillemets).
|
||||
- [x] **A4 — Sonde de perte par feuille (BUG-099).** Budget par feuille + plafond global ;
|
||||
budget épuisé → clé `cached_values_unverified` (i18n FR/EN) au lieu d'un silence. *Vérifié :*
|
||||
`TestXlsxCachedValueProbe` (3), dont la contre-preuve du silence antérieur.
|
||||
|
||||
### P1 — presse-papiers & clavier (4-6 j)- [x] **A5 — Presse-papiers de plage (livré le 2026-09-29).** Copier / couper / coller un bloc
|
||||
(presse-papiers interne TSV, miroir système best-effort) via `Ctrl+C`/`Ctrl+X`/`Ctrl+V` et les
|
||||
entrées du menu contextuel ; remplissage multi-cellules depuis la cellule active, bloc laissé
|
||||
sélectionné, collage **texte** uniquement (R4) et annulable cellule par cellule ; un bloc plus
|
||||
grand que la grille rendue est tronqué et signalé (`xlsx.paste_out_of_grid`). Couper efface la
|
||||
source au collage, sauf en collage sur place.
|
||||
*Vérifié :* JSDOM — `A1:B2` copié puis collé en `D5` donne `D5:E6`, bloc TSV Excel + retour
|
||||
ligne final, 1×1 qui remplace la cellule, coupe → collage, menu contextuel, débordement
|
||||
signalé, `E2E` (Playwright : copie/collage par le menu contextuel sur la fixture 520 lignes).
|
||||
- [x] **A6 — Clavier complet (livré le 2026-09-29).** `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`,
|
||||
`Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` ; undo/redo déplacés
|
||||
dans le même handler priorisé et `Suppr` passe par `clearRange()` (donc **annulable**).
|
||||
*Vérifié :* JSDOM — un test par raccourci, dont la contre-épreuve « `Suppr` pendant la saisie
|
||||
supprime un caractère, pas la sélection » ; `E2E` (Ctrl+A, Ctrl+Home/End, Suppr).
|
||||
- [x] **A7 — Zone Nom éditable & aide à la saisie (livré le 2026-09-29).** La zone Nom est un
|
||||
`<input>` (`#xlsx-active-cell`) : `B12`, `A1:B3`, `$A$1`, `Feuille!A1` (changement d'onglet) ;
|
||||
référence inconnue → message `xlsx.name_box_invalid` et adresse précédente restaurée. Le champ
|
||||
de formule porte la liste des fonctions (`<datalist id="xlsx-function-list">`, `xlsx.fn_suggestions`).
|
||||
*Vérifié :* JSDOM (atteindre, plage normalisée, refus d'une référence inconnue, bascule
|
||||
d'onglet, liste de fonctions) ; `E2E` (zone Nom → `B12`).
|
||||
|
||||
### P2 — mise en forme, calcul, undo (5-7 j)
|
||||
|
||||
- [x] **A8 — Mise en forme en écriture (livré le 2026-09-30).** Bouton **Mise en forme** :
|
||||
gras/italique/souligné/effacer, alignements, couleurs de police et de fond (sélecteur natif),
|
||||
formats de nombre (général/nombre/pourcentage/devise/date/texte), fusion/défusion, volets
|
||||
figés/libérés, largeur de colonne et hauteur de ligne, sur la sélection courante. Nouvelle route
|
||||
`PUT …/xlsx/style` (`mutate_xlsx_style`) sur le modèle de `PUT …/xlsx/structure` : verrou,
|
||||
`.tmp`/`os.replace`, backup, garde de perte (`force`), `If-Match`, garde anti-formule BUG-088,
|
||||
plafond `MAX_STYLE_CELLS`. i18n FR/EN. *Vérifié :* `tests/test_xlsx_styles.py` (17 : rechargement,
|
||||
fusions/tailles/volets, effacement, entrées invalides, CSV refusé, `if_match`, backup) et JSDOM
|
||||
(application d'un format, refus d'un CSV, garde de perte).
|
||||
- [x] **A9 — Calcul : décision documentée (livré le 2026-09-30).** Pas de moteur de formule ; le
|
||||
gap est **annoncé dans l'UI** (toast « enregistrée comme texte » à l'enregistrement si une valeur
|
||||
commence par `=` ou `@` sans le bouton `f(x)`), la garde anti-DDE reste. *Vérifié :* JSDOM
|
||||
(l'avertissement apparaît, disparaît une fois `f(x)` activé).
|
||||
- [x] **A10 — Undo/redo unifié (livré le 2026-09-30).** Entrées typées `cell|order|hidden|structure`,
|
||||
piles **par fichier** (`_xlsxHistory`, 8 fichiers) conservées au re-rendu, filtre coalescé,
|
||||
rejeu de structure silencieux, action destructive non annulable. *Vérifié :* JSDOM (édition →
|
||||
effacement → tri annulés successivement, insertion de ligne rejouée, filtre annulé en un `Ctrl+Z`).
|
||||
|
||||
### P3 — sortie, robustesse, performances (4-6 j)
|
||||
|
||||
- [x] **A11 — Sortie & recherche (livré le 2026-09-30).** Bouton **Exporter** : CSV de la
|
||||
**sélection** (sinon de la feuille, nom `Feuille-A1B2.csv`), Markdown, HTML et impression
|
||||
(iframe hors écran) ; écart #153-A13 tranché. `runFind` parcourt **toutes** les feuilles avec
|
||||
compteur `n/m · k feuilles` et activation de l'onglet correspondant. *Vérifié :* JSDOM
|
||||
(sélection multi-cellules, feuille entière, 4 formats, compteur multi-feuilles) et E2E.
|
||||
- [x] **A12 — Concurrence optimiste (livré le 2026-09-30).** `revision` (`mtime_ns` + taille)
|
||||
renvoyée à la lecture (`xlsx_revision`) et acceptée en `If-Match`/`if_match` sur `xlsx/save`,
|
||||
`xlsx/structure`, `xlsx/style` et `csv/save` ; unconflict produit un **409** `conflict`
|
||||
(`details.reason = "stale_revision"`) et le bandeau « Réessayer » relit avant de rejouer.
|
||||
*Vérifié :* `TestOptimisticConcurrency` (6) + JSDOM (version rafraîchie par la réponse, relecture
|
||||
avant le retry).
|
||||
- [x] **A13 — Performances & plafonds (livré le 2026-09-30).** Cache de `read_workbook_meta()` par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8), invalidé à chaque écriture : charger une fenêtre ne
|
||||
rescanne plus le classeur. *Hors périmètre :* la pagination de l'axe des colonnes au-delà de
|
||||
`MAX_COLS = 40` (documenté dans les limites du guide). *Vérifié :* `TestMetaCache` (4).
|
||||
- [x] **A14 — Outils IA étendus (livré le 2026-09-30).** `SPREADSHEET_EXTENSIONS` =
|
||||
`.xlsx`/`.xlsm`/`.csv` (`_spreadsheet_path`, `_read_grid`), `update_xlsx_cells`/`append_xlsx_rows`
|
||||
adaptés, nouveaux outils `search_workbook` (READ), `analyze_range` (READ) et `edit_xlsx_structure`
|
||||
(WRITE, **confirmation conservée**, refus d'un CSV). *Vérifié :*
|
||||
`tests/test_spreadsheet_tools.py` (34, dont `TestXlsmSupport`, `TestCsvSupport`,
|
||||
`TestSearchWorkbook`, `TestAnalyzeRange`, `TestEditXlsxStructure`).
|
||||
|
||||
## 5. Risques et sécurité
|
||||
|
||||
| # | Risque | Où | Traitement |
|
||||
|---|---|---|---|
|
||||
| R1 | Perte silencieuse de valeurs calculées (sonde plafonnée) | `xlsx_reader.inspect_workbook` | ✅ A4 — budget par feuille + clé `cached_values_unverified` (BUG-099) |
|
||||
| R2 | Écrasement par un écrivain externe | `mutations`, routes write | ✅ A12 — `If-Match` + **409** `conflict`, retry qui relit |
|
||||
| R3 | Réécriture CSV dans un délimiteur différent de la source | `xlsx_reader`, `mutations.save_csv_cells` | ✅ A3 — `sniff_csv_delimiter` partagé (BUG-098) |
|
||||
| R4 | Collage d'un bloc : ne jamais injecter de HTML/`contenteditable` brut | `viewer.js` | ✅ A5 — insertion **texte** uniquement, échappée |
|
||||
| R5 | Mise en forme en écriture : garder la garde anti-formule (BUG-088) et la garde de perte (BUG-085) | `mutations` | ✅ A8 — `mutate_xlsx_style` réutilise verrou, backup, swap atomique, garde de perte et `If-Match` |
|
||||
|
||||
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
||||
|
||||
- Chaque sous-tâche démarre par son **ID stable** (`#156-A<n>`) ; un **défaut** est ouvert comme
|
||||
`BUG-NNN` dans `docs/ISSUES_TODOLIST.md` (A1→BUG-096, A2→BUG-097, A3→BUG-098, A4→BUG-099).
|
||||
- Tout correctif arrive avec son **test de non-régression** (contre-preuve quand c'est possible).
|
||||
- Frontend : `vanilla JS`, **zéro build**, `safeCreateIcons()`, **variables CSS**, **i18n FR+EN**
|
||||
(`test_i18n_parity.py` vert).
|
||||
- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans
|
||||
`backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`.
|
||||
- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée + index), cette fiche,
|
||||
guide utilisateur i18n + README si impact utilisateur.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Audit de complétude de l'éditeur Excel → ouverture de #156 et de **BUG-096 → BUG-099** ; défaut A1 reproduit en JSDOM (`TypeError` sur l'enregistrement d'un CSV, aucun `PUT …/csv/save` émis) |
|
||||
| 2026-09-29 | **P1 livré** — A5 (presse-papiers de plage), A6 (clavier complet) et A7 (zone Nom éditable + aide à la saisie) : presse-papiers interne TSV (miroir système best-effort), collage texte/annulable, `Suppr`/`F2`/`Ctrl+flèches`/`PgUp-PgDn`/`Ctrl+A`/`Maj+Entrée`, zone Nom `<input>` et liste de fonctions i18n FR/EN. Tests : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux) + E2E Playwright |
|
||||
| 2026-09-30 | **P2 livré** — A8 (mise en forme en écriture : bouton Mise en forme, `PUT …/xlsx/style`, verrou/backup/garde de perte/`If-Match`), A9 (décision « pas de moteur de formule », annoncée dans l'UI), A10 (undo/redo unifié, piles conservées au re-rendu). Tests : `test_xlsx_styles.py` 17 + JSDOM `xlsx-viewer.test.mjs` |
|
||||
| 2026-09-30 | **P3 livré** — A11 (export sélection / Markdown / HTML / impression, recherche multi-feuilles), A12 (`If-Match` + 409 réparable), A13 (cache des métadonnées par `mtime`), A14 (outils IA `.xlsm`/`.csv`, recherche, analyse de plage, structure). Tests : `test_spreadsheet_tools.py` 34, `test_xlsx_viewer.py` (`TestOptimisticConcurrency`, `TestMetaCache`), JSDOM |
|
||||
| 2026-09-30 | **Backlog #156 clôturé** — P0 → P3 livrés (A1 → A14, dont 4 défauts **BUG-096 → BUG-099**). Suite complète : `pytest tests/` 1525 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 107/107, E2E `xlsx` 10/10 |
|
||||
| 2026-09-29 | **P0 livré** — A1-A4 corrigés et testés (BUG-096 → BUG-099) : sauvegarde `.csv` depuis la visionneuse, fenêtres `.xlsm`, délimiteur CSV détecté/réutilisé, sonde de perte par feuille + signal `cached_values_unverified`. Suite complète : 1490 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 61/61 |
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
|
||||
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
|
||||
> **Statut :** 🔵 **en cours** — Lots 1, 2, 3 & 4 livrés le 2026-09-29 (reste A5, découpage modulaire)
|
||||
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5)
|
||||
> **Statut :** ✅ **terminé** — Lots 1 → 5 livrés le 2026-09-29 (A1-A5)
|
||||
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5 ✅)
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.**
|
||||
|
||||
@@ -95,13 +95,25 @@ la grille ni le backend.
|
||||
défilement pour les environnements sans IO).
|
||||
- **A4.3** Sémantique **ARIA** : `role="grid"` / `row` / `gridcell` / `columnheader` / `rowheader`.
|
||||
|
||||
### Lot 5 — Découpage modulaire & finitions (à venir)
|
||||
### Lot 5 — Découpage modulaire & finitions ✅ *(2026-09-29)*
|
||||
|
||||
- Extraction de `renderXlsxViewer` en modules (`frontend/js/xlsx/*.js`) : `toolbar`, `formula-bar`,
|
||||
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api` (refactor sans changement de
|
||||
comportement, sous couvert des tests JSDOM/E2E existants).
|
||||
- Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection), inspecteur
|
||||
**redimensionnable**, détachement en split via `PaneManager.splitRight()`.
|
||||
- **A5.1** Extraction des parties pures/sans état du monolithe `renderXlsxViewer` dans
|
||||
`frontend/js/xlsx/` : **`refs.js`** (`parseRef`, `columnName`, `findTd`, `sheetOfRef`,
|
||||
`firstCellOfRange`), **`command-bar.js`** (`buildCommandBar` : onglets + ruban + pastilles),
|
||||
**`dashboard.js`** (`renderDashboardLoading`, `renderDashboardHtml`). Le noyau **avec état**
|
||||
(orchestration DOM, édition, écouteurs) reste dans `viewer.js` : l'extraction est volontairement
|
||||
limitée aux unités sans état, à comportement constant et sous couvert des tests.
|
||||
- **A5.2** Lien **dashboard → grille** : cliquer une plage nommée sélectionne et révèle sa
|
||||
première cellule (change d'onglet si la plage est sur une autre feuille).
|
||||
- **A5.3** Inspecteur **redimensionnable** (poignée gauche, largeur 260–640 px, restaurée par
|
||||
session via `localStorage`).
|
||||
- **A5.4** `SW_VERSION` incrémenté (`v28`) et nouveaux modules ajoutés au pré-cache du service
|
||||
worker.
|
||||
|
||||
> **Hors périmètre (documenté) :** le détachement de l'inspecteur en split view
|
||||
> (`PaneManager.splitRight()`) n'est pas retenu — l'inspecteur est intrinsèquement lié à la
|
||||
> visionneuse d'un document ; un split générique ouvrirait un second contexte sans le classeur.
|
||||
> À réévaluer si un usage concret apparaît.
|
||||
|
||||
## 5. Recommandations techniques (contrainte « zéro build »)
|
||||
|
||||
@@ -134,3 +146,4 @@ la grille ni le backend.
|
||||
| 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) |
|
||||
| 2026-09-29 | **Lot 3** livré : le Tableau de bord passe dans un **inspecteur droit repliable** (grille toujours visible), en-tête d'inspecteur avec entrée **Assistant IA** et fermeture, responsive < 900 px |
|
||||
| 2026-09-29 | **Lot 4** livré : **undo/redo** (boutons + `Ctrl+Z`/`Ctrl+Maj+Z`/`Ctrl+Y`), chargement par `IntersectionObserver`, **ARIA** `role="grid"` ; le découpage modulaire est reporté en A5 |
|
||||
| 2026-09-29 | **Lot 5** livré (clôture #154) : extraction des unités sans état dans `frontend/js/xlsx/*` (`refs.js`, `command-bar.js`, `dashboard.js`), lien **dashboard → grille**, inspecteur **redimensionnable**, `SW_VERSION` v28 + pré-cache. Split view écarté (documenté) |
|
||||
|
||||
+7
-4
@@ -1196,16 +1196,15 @@
|
||||
>Ctrl+Shift+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de commandes</span
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
<span class="qh-kbd"
|
||||
>Ctrl+Alt+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
<span class="qh-desc">Palette de commandes</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
@@ -3695,6 +3694,10 @@
|
||||
<strong>Facettes vaults</strong><span data-i18n="help.desc_facet_vaults">: Compteurs
|
||||
par vault</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Facettes extensions</strong><span data-i18n="help.desc_facet_extensions">:
|
||||
Compteurs par extension de fichier dans les résultats</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tri par pertinence</strong><span data-i18n="help.desc_9fec424d"> : Score
|
||||
TF-IDF (défaut)</span></li>
|
||||
|
||||
+36
-7
@@ -14,17 +14,26 @@ function getCurrentFile() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Query parser: +word = required, -word = exclude, word = required ──
|
||||
// ── Query parser: +word = required, -word = exclude, word = required, ext:xxx, vault:xxx, tag:xxx ──
|
||||
function parseQuery(raw) {
|
||||
const positive = [];
|
||||
const negative = [];
|
||||
const filters = { ext: null, vault: null, tag: null };
|
||||
const parts = (raw || '').split(/\s+/).filter(Boolean);
|
||||
for (const p of parts) {
|
||||
if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
|
||||
const lower = p.toLowerCase();
|
||||
if (lower.startsWith('ext:')) {
|
||||
filters.ext = lower.slice(4).trim().replace(/^\./, '').toLowerCase();
|
||||
} else if (lower.startsWith('vault:')) {
|
||||
filters.vault = lower.slice(6).trim();
|
||||
} else if (lower.startsWith('tag:') || (lower.startsWith('#') && p.length > 1)) {
|
||||
const tag = lower.startsWith('tag:') ? lower.slice(4) : lower.slice(1);
|
||||
if (tag) filters.tag = tag.trim();
|
||||
} else if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
|
||||
else if (p.startsWith('+')) positive.push(p.slice(1).toLowerCase());
|
||||
else positive.push(p.toLowerCase());
|
||||
}
|
||||
return { positive, negative };
|
||||
return { positive, negative, filters };
|
||||
}
|
||||
|
||||
// ── DOM / state ──
|
||||
@@ -407,8 +416,8 @@ function createDOM() {
|
||||
_overlay.className = 'command-palette-overlay';
|
||||
_overlay.tabIndex = -1;
|
||||
_overlay.innerHTML = `<div class="command-palette">
|
||||
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
|
||||
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
|
||||
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure ext:md vault:nom tag:monTag)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
|
||||
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot ext:xxx vault:nom tag:xxx) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
|
||||
<div class="cp-results"></div>
|
||||
<div class="cp-footer"><span>↑↓ naviguer</span><span>↵ ouvrir/entrer</span><span>⌫ monter</span><span>Esc fermer</span></div></div>`;
|
||||
_input = _overlay.querySelector('.cp-input');
|
||||
@@ -488,11 +497,14 @@ async function searchFiles(rawQ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { positive, negative } = parseQuery(rawQ);
|
||||
const { positive, negative, filters } = parseQuery(rawQ);
|
||||
|
||||
try {
|
||||
const q = positive.join(' ');
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=all`);
|
||||
let vaultFilter = 'all';
|
||||
if (filters.vault) vaultFilter = filters.vault;
|
||||
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=${encodeURIComponent(vaultFilter)}`);
|
||||
let sug = d.suggestions || [];
|
||||
|
||||
// Client-side negative filter
|
||||
@@ -506,6 +518,23 @@ async function searchFiles(rawQ) {
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by extension if specified
|
||||
if (filters.ext) {
|
||||
sug = sug.filter(s => {
|
||||
const name = s.path.split('/').pop() || '';
|
||||
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
||||
return ext === filters.ext;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by tag if specified
|
||||
if (filters.tag) {
|
||||
sug = sug.filter(s => {
|
||||
const tags = s.tags || [];
|
||||
return tags.some(t => t.toLowerCase() === filters.tag.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
if (sug.length === 0) {
|
||||
_items = [{ type:'_browse', label:'📂 Parcourir les répertoires...', sublabel:'Aucun résultat — naviguer manuellement', act:()=>startBrowse('open-file') }];
|
||||
} else {
|
||||
|
||||
+27
-1
@@ -1034,7 +1034,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
}
|
||||
|
||||
// Facets panel
|
||||
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) {
|
||||
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0 || Object.keys(data.facets.extensions || {}).length > 0)) {
|
||||
const facetsDiv = el("div", { class: "search-facets" });
|
||||
|
||||
// Vault facets
|
||||
@@ -1078,6 +1078,32 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
facetsDiv.appendChild(group);
|
||||
}
|
||||
|
||||
// Extension facets
|
||||
const extFacets = data.facets.extensions || {};
|
||||
if (Object.keys(extFacets).length > 0) {
|
||||
const group = el("div", { class: "search-facets__group", "data-facet": "extensions" });
|
||||
const label = el("span", { class: "search-facets__label" });
|
||||
label.textContent = "Extensions";
|
||||
group.appendChild(label);
|
||||
for (const [extName, count] of Object.entries(extFacets)) {
|
||||
const item = el("span", { class: "search-facets__item" });
|
||||
// textContent: extension suffixes come from filenames (untrusted), never innerHTML
|
||||
item.appendChild(document.createTextNode(`.${extName} `));
|
||||
const facetCount = el("span", { class: "facet-count" });
|
||||
facetCount.textContent = String(count);
|
||||
item.appendChild(facetCount);
|
||||
item.addEventListener("click", () => {
|
||||
const input = document.getElementById("search-input");
|
||||
// Replace any existing ext: operator, then trigger a new search
|
||||
const current = input.value.replace(/ext:\S+\s*/gi, "").trim();
|
||||
input.value = current + " ext:" + extName;
|
||||
_triggerAdvancedSearch(input.value);
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsDiv.appendChild(group);
|
||||
}
|
||||
|
||||
area.appendChild(facetsDiv);
|
||||
}
|
||||
|
||||
|
||||
+1450
-210
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
/* ObsiGate — XLSX command bar builder (#154-A5).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
/**
|
||||
* @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 }}
|
||||
*/
|
||||
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
|
||||
// as a real affordance; an editable workbook gets an explicit “+” button.
|
||||
const tabs = isCsv
|
||||
? ""
|
||||
: `<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>`
|
||||
: ""}</div>`;
|
||||
|
||||
// Status pills make the viewer's limits visible up front.
|
||||
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.
|
||||
const actionGroups = [];
|
||||
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>
|
||||
<i data-lucide="undo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-redo-btn" type="button" title="${escapeHtml(t("xlsx.redo"))}" disabled>
|
||||
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
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>
|
||||
</span>`);
|
||||
}
|
||||
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"))}">
|
||||
<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"))}">
|
||||
<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")}
|
||||
</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 };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
let _menu = null;
|
||||
let _openedAt = 0;
|
||||
let _dismissBound = false;
|
||||
|
||||
export function closeContextMenu() {
|
||||
if (_menu) {
|
||||
_menu.remove();
|
||||
_menu = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isContextMenuOpen() {
|
||||
return _menu !== null;
|
||||
}
|
||||
|
||||
function bindDismiss() {
|
||||
if (_dismissBound) return;
|
||||
_dismissBound = true;
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!_menu) return;
|
||||
// Ignore the very gesture that opened the menu (touch long-press can emit
|
||||
// a click right after).
|
||||
if (Date.now() - _openedAt < 250) return;
|
||||
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
|
||||
closeContextMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeContextMenu();
|
||||
});
|
||||
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @returns {HTMLElement} the menu element
|
||||
*/
|
||||
export function openContextMenu(x, y, items) {
|
||||
bindDismiss();
|
||||
closeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "xlsx-context-menu";
|
||||
menu.setAttribute("role", "menu");
|
||||
for (const item of items) {
|
||||
if (item.separator) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "xlsx-context-sep";
|
||||
menu.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
|
||||
btn.setAttribute("role", "menuitem");
|
||||
btn.textContent = item.label;
|
||||
btn.disabled = Boolean(item.disabled);
|
||||
btn.addEventListener("click", () => {
|
||||
closeContextMenu();
|
||||
if (!item.disabled && item.onClick) item.onClick();
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
|
||||
// which simply leaves the requested position).
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const vw = (typeof window !== "undefined" && window.innerWidth) || 1024;
|
||||
const vh = (typeof window !== "undefined" && window.innerHeight) || 768;
|
||||
const left = Math.max(4, Math.min(x, vw - rect.width - 4));
|
||||
const top = Math.max(4, Math.min(y, vh - rect.height - 4));
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
|
||||
_menu = menu;
|
||||
_openedAt = Date.now();
|
||||
return menu;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* ObsiGate — XLSX dashboard renderer (#154-A5).
|
||||
*
|
||||
* Pure string builders for the workbook dashboard inspector (named ranges, KPI
|
||||
* cards). Extracted from frontend/js/viewer.js; the viewer handles the fetch,
|
||||
* the inspector host and the range → grid link.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
export function renderDashboardLoading() {
|
||||
return `<div class="xlsx-dashboard-loading">…</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} dash - payload of GET …/xlsx/dashboard
|
||||
* @returns {string} the inspector body markup
|
||||
*/
|
||||
export function renderDashboardHtml(dash) {
|
||||
const namedRanges = dash.named_ranges || [];
|
||||
const sheets = dash.sheets || [];
|
||||
const rangeRows = namedRanges.map((r) =>
|
||||
`<tr class="xlsx-range-row" data-ref="${escapeHtml(r.ref || "")}"><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
|
||||
).join("");
|
||||
const kpiCards = sheets.map((s) => {
|
||||
const cards = (s.kpi || []).map((k) =>
|
||||
`<span class="xlsx-kpi"><span class="xlsx-kpi-label">${escapeHtml(k.label)}</span><span class="xlsx-kpi-value">${escapeHtml(String(k.value))}</span></span>`,
|
||||
).join("");
|
||||
return `<div class="xlsx-kpi-sheet">
|
||||
<h4>${escapeHtml(s.name)}</h4>
|
||||
<p class="xlsx-kpi-meta">${escapeHtml(t("xlsx.dashboard_stats", {
|
||||
cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric,
|
||||
}))}</p>
|
||||
<div class="xlsx-kpi-cards">${cards || "<span class=\"xlsx-kpi-empty\">—</span>"}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
return `
|
||||
<div class="xlsx-dashboard-head">
|
||||
<h3><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.dashboard_title"))}</h3>
|
||||
<span class="xlsx-dashboard-objects">
|
||||
${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))}
|
||||
· ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))}
|
||||
</span>
|
||||
</div>
|
||||
${namedRanges.length || sheets.length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
|
||||
${namedRanges.length ? `
|
||||
<table class="csv-table xlsx-ranges-table"><thead><tr>
|
||||
<th>${escapeHtml(t("xlsx.dashboard_nr_name"))}</th>
|
||||
<th>${escapeHtml(t("xlsx.dashboard_nr_scope"))}</th>
|
||||
<th>${escapeHtml(t("xlsx.dashboard_nr_ref"))}</th>
|
||||
</tr></thead><tbody>${rangeRows}</tbody></table>` : ""}
|
||||
<div class="xlsx-kpi-grid">${kpiCards}</div>
|
||||
<p class="xlsx-dashboard-hint">${escapeHtml(t("xlsx.dashboard_hint"))}</p>`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* ObsiGate — XLSX A1 reference helpers (#154-A5).
|
||||
*
|
||||
* Pure functions (no DOM, no i18n): safe to unit-test in Node and shared by the
|
||||
* viewer. Extracted from frontend/js/viewer.js without behaviour change.
|
||||
*/
|
||||
|
||||
/** Parse an A1 reference ("B12") into its 1-based (row, col) parts. */
|
||||
export function parseRef(ref) {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
|
||||
if (!m) return null;
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { row: Number(m[2]), col };
|
||||
}
|
||||
|
||||
/** Column number (1-based) → letters ("A", "Z", "AA"). */
|
||||
export function columnName(col) {
|
||||
let name = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
name = String.fromCharCode(65 + rem) + name;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 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}"]`);
|
||||
}
|
||||
|
||||
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
|
||||
export function sheetOfRef(ref) {
|
||||
const s = String(ref || "");
|
||||
if (!s.includes("!")) return null;
|
||||
return s.split("!")[0].replace(/^'|'$/g, "");
|
||||
}
|
||||
|
||||
/** First cell of a range ref ("Data!$A$1:$B$5" → "A1"), or null. */
|
||||
export function firstCellOfRange(ref) {
|
||||
const s = String(ref || "");
|
||||
const body = s.includes("!") ? s.split("!").pop() : s;
|
||||
const m = /([A-Za-z]+)\$?(\d+)/.exec(body);
|
||||
return m ? `${m[1].toUpperCase()}${m[2]}` : null;
|
||||
}
|
||||
@@ -1101,6 +1101,7 @@
|
||||
"help.desc_f952db17": "Refresh tokens : Cookies HttpOnly avec rotation automatique",
|
||||
"help.desc_fa126609": "Converts the document structure into an Obsidian Canvas (visual cards).",
|
||||
"help.desc_facet_vaults": ": Counters by vault",
|
||||
"help.desc_facet_extensions": ": Counters by file extension in the results",
|
||||
"help.desc_fc9391f3": "ObsiGate sends an HTTP POST to your webhook URL with event details.",
|
||||
"help.desc_fdeaf803": ": Each file type has its icon (markdown, code, etc.)",
|
||||
"help.desc_file_suggest": ": File title suggestions",
|
||||
@@ -1848,13 +1849,59 @@
|
||||
"xlsx.load_error": "Could not load the remaining rows",
|
||||
"xlsx.formula_bar_placeholder": "Active cell content",
|
||||
"xlsx.active_cell": "Cell",
|
||||
"xlsx.find_placeholder": "Search in the sheet…",
|
||||
"xlsx.name_box_label": "Name Box — jump to a cell or a range",
|
||||
"xlsx.name_box_hint": "Type a reference (B12, A1:B3) then Enter to jump there",
|
||||
"xlsx.name_box_invalid": "Unknown reference: {ref}",
|
||||
"xlsx.copy": "Copy",
|
||||
"xlsx.cut": "Cut",
|
||||
"xlsx.paste": "Paste",
|
||||
"xlsx.paste_empty": "Clipboard empty or unreadable",
|
||||
"xlsx.paste_out_of_grid": "Part of the pasted block is outside the displayed grid and was skipped",
|
||||
"xlsx.fn_suggestions": "SUM;AVERAGE;COUNT;COUNTA;MIN;MAX;IF;VLOOKUP;XLOOKUP;SUMIF;SUMIFS;COUNTIF;CONCAT;LEFT;RIGHT;MID;TODAY;NOW;ROUND;ABS;SQRT",
|
||||
"xlsx.formula_stored_as_text": "{n} value(s) starting with “=” or “@” saved as text — turn on the f(x) button to write real formulas",
|
||||
"xlsx.find_placeholder": "Search in the workbook…",
|
||||
"xlsx.find_prev": "Previous",
|
||||
"xlsx.find_next": "Next",
|
||||
"xlsx.find_case": "Match case",
|
||||
"xlsx.find_no_match": "No match",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Export the sheet as CSV",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
|
||||
"xlsx.csv_export": "Export the sheet or the selection as CSV",
|
||||
"xlsx.export_btn": "Export",
|
||||
"xlsx.export_title": "Export the selection or the sheet",
|
||||
"xlsx.export_md": "Export as Markdown",
|
||||
"xlsx.export_html": "Export as HTML",
|
||||
"xlsx.print": "Print",
|
||||
"xlsx.export_scope_sheet": "the sheet",
|
||||
"xlsx.export_scope_selection": "the selection",
|
||||
"xlsx.export_done": "Exported {scope}",
|
||||
"xlsx.format_btn": "Formatting",
|
||||
"xlsx.format_bold": "Bold",
|
||||
"xlsx.format_italic": "Italic",
|
||||
"xlsx.format_underline": "Underline",
|
||||
"xlsx.format_clear": "Clear formatting",
|
||||
"xlsx.format_align_left": "Align left",
|
||||
"xlsx.format_align_center": "Center",
|
||||
"xlsx.format_align_right": "Align right",
|
||||
"xlsx.format_font_color": "Text colour",
|
||||
"xlsx.format_fill_color": "Fill colour",
|
||||
"xlsx.format_num_general": "General format",
|
||||
"xlsx.format_num_number": "Number (2 decimals)",
|
||||
"xlsx.format_num_percent": "Percentage",
|
||||
"xlsx.format_num_currency": "Currency (€)",
|
||||
"xlsx.format_num_date": "Date (DD/MM/YYYY)",
|
||||
"xlsx.format_num_text": "Text",
|
||||
"xlsx.format_merge": "Merge cells",
|
||||
"xlsx.format_unmerge": "Unmerge cells",
|
||||
"xlsx.format_merge_needs_range": "Select at least two cells to merge",
|
||||
"xlsx.format_freeze": "Freeze panes here",
|
||||
"xlsx.format_unfreeze": "Unfreeze panes",
|
||||
"xlsx.format_col_width": "Column width…",
|
||||
"xlsx.format_col_prompt": "Width of column {col} (0 to 255)",
|
||||
"xlsx.format_row_height": "Row height…",
|
||||
"xlsx.format_row_prompt": "Height of row {row} (0 to 409)",
|
||||
"xlsx.format_applied": "Formatting saved",
|
||||
"xlsx.format_error": "Formatting failed",
|
||||
"xlsx.sort_asc": "Sort column A→Z",
|
||||
"xlsx.sort_desc": "Sort column Z→A",
|
||||
"xlsx.sort_applied": "Sort applied on {col} — display only, the workbook is unchanged",
|
||||
@@ -1875,6 +1922,17 @@
|
||||
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
|
||||
"xlsx.select_column": "Select the column",
|
||||
"xlsx.select_row": "Select the row",
|
||||
"xlsx.insert_row_above": "Insert a row above",
|
||||
"xlsx.insert_row_below": "Insert a row below",
|
||||
"xlsx.insert_col_left": "Insert a column to the left",
|
||||
"xlsx.insert_col_right": "Insert a column to the right",
|
||||
"xlsx.delete_rows": "Delete {n} row(s)",
|
||||
"xlsx.delete_cols": "Delete {n} column(s)",
|
||||
"xlsx.clear_contents": "Clear contents",
|
||||
"xlsx.confirm_delete_rows": "Delete {n} row(s)? This changes the file (a backup is created).",
|
||||
"xlsx.confirm_delete_cols": "Delete {n} column(s)? This changes the file (a backup is created).",
|
||||
"xlsx.structure_saved": "Structure updated",
|
||||
"xlsx.structure_error": "Could not change the structure",
|
||||
"xlsx.last_sheet": "The last sheet cannot be deleted",
|
||||
@@ -1882,6 +1940,8 @@
|
||||
"xlsx.inspector_title": "Inspector",
|
||||
"xlsx.inspector_ai": "AI assistant",
|
||||
"xlsx.inspector_ai_short": "AI",
|
||||
"xlsx.inspector_resize": "Resize the inspector",
|
||||
"xlsx.range_not_visible": "Cell out of view (truncated sheet)",
|
||||
"xlsx.dashboard_title": "Workbook dashboard",
|
||||
"xlsx.dashboard_stats": "{{cells}} cells · {{rows}} rows · {{cols}} columns · {{formulas}} formulas · {{numeric}} numeric values",
|
||||
"xlsx.dashboard_charts": "{{n}} chart(s)",
|
||||
@@ -1892,6 +1952,7 @@
|
||||
"xlsx.dashboard_nr_ref": "Reference",
|
||||
"xlsx.dashboard_hint": "Select a range or open the AI assistant to analyse this data.",
|
||||
"xlsx.feature_cached_values": "cached values",
|
||||
"xlsx.feature_cached_values_unverified": "cached values could not be verified (workbook too large)",
|
||||
"xlsx.feature_slicers": "slicers and timelines",
|
||||
"xlsx.feature_form_controls": "form controls",
|
||||
"xlsx.feature_connections": "connections and queries",
|
||||
@@ -2067,6 +2128,9 @@
|
||||
"ai.step.xlsx_read": "Workbook read: {value}",
|
||||
"ai.step.xlsx_update": "Cells edited: {value}",
|
||||
"ai.step.xlsx_append": "Rows appended: {value}",
|
||||
"ai.step.xlsx_search": "Workbook searched: {value}",
|
||||
"ai.step.xlsx_analyze": "Range analysed: {value}",
|
||||
"ai.step.xlsx_structure": "Structure changed: {value}",
|
||||
"ai.step.docx_create": "Word document proposed: {value}",
|
||||
"ai.step.csv_create": "CSV file proposed: {value}",
|
||||
"ai.step.pdf_create": "PDF document proposed: {value}",
|
||||
|
||||
@@ -1101,6 +1101,7 @@
|
||||
"help.desc_f952db17": "Refresh tokens : Cookies\n HttpOnly avec rotation automatique",
|
||||
"help.desc_fa126609": "🔷\n Convert to canvas :\n Convertit en diagramme Mermaid.js",
|
||||
"help.desc_facet_vaults": ": Compteurs par vault",
|
||||
"help.desc_facet_extensions": ": Compteurs par extension de fichier dans les résultats",
|
||||
"help.desc_fc9391f3": "# Exemple avec curl + jq dans un script bash\nWEBHOOK_URL=\"https://discord.com/api/webhooks/...\"\nOBSIGATE_SECRET=\"mon-secret\"\n\n# Lire le payload depuis stdin\nread -r payload\nevent=$(echo \"$payload\" | jq -r '.event')\npath=$(echo \"$payload\" | jq -r '.data.path')\n\ncurl -H \"Content-Type: application/json\" -d \"{\n \\\"content\\\": \\\"📝 **$event** — $path\\\"\n}\" \"$WEBHOOK_URL\"\n\n Tester avec curl\n # Simuler ce qu'ObsiGate envoie (pour tester votre endpoint)\ncurl -X POST https://votre-serveur.com/webhook \\\n -H \"Content-Type: application/json\" \\\n -H \"X-ObsiGate-Event: file_created\" \\\n -H \"X-ObsiGate-Signature: sha256=$(echo -n '{\"event\":\"file_created\",...}' | openssl dgst -sha256 -hmac 'mon-secret' | cut -d' ' -f2)\" \\\n -d '{\"event\":\"file_created\",\"timestamp\":\"2026-06-03T14:30:00Z\",\"data\":{\"vault\":\"IT\",\"path\":\"notes/test.md\"}}'\n\n Limitations actuelles\n \n \n Timeout de 5 secondes par\n requête — les webhooks sont \"fire and\n forget\" (pas de retry)\n \n \n Les modifications détectées par le watcher\n filesystem (watchdog)\n ne déclenchent pas de\n webhooks — seules les opérations via l'API\n REST sont prises en compte\n \n \n file_modified n'est pas encore\n dispatché (réservé pour version future)\n \n Pas de retry automatique en cas d'échec\n \n Pas de filtrage par vault — un webhook\n écoute toutes les vaults\n \n \n \n \n 💾 Sauvegardes et Audits\n\n Backup automatique\n \n ObsiGate crée automatiquement des sauvegardes\n avant chaque modification destructive.",
|
||||
"help.desc_fdeaf803": ": Chaque type de\n fichier a son icône (markdown, code, etc.)",
|
||||
"help.desc_file_suggest": ": Suggestions de titres de fichiers",
|
||||
@@ -1848,13 +1849,59 @@
|
||||
"xlsx.load_error": "Chargement de la suite impossible",
|
||||
"xlsx.formula_bar_placeholder": "Contenu de la cellule active",
|
||||
"xlsx.active_cell": "Cellule",
|
||||
"xlsx.find_placeholder": "Rechercher dans la feuille…",
|
||||
"xlsx.name_box_label": "Zone Nom — atteindre une cellule ou une plage",
|
||||
"xlsx.name_box_hint": "Saisissez une référence (B12, A1:B3) puis Entrée pour y aller",
|
||||
"xlsx.name_box_invalid": "Référence inconnue : {ref}",
|
||||
"xlsx.copy": "Copier",
|
||||
"xlsx.cut": "Couper",
|
||||
"xlsx.paste": "Coller",
|
||||
"xlsx.paste_empty": "Presse-papiers vide ou illisible",
|
||||
"xlsx.paste_out_of_grid": "Une partie du collage dépasse la grille affichée et a été ignorée",
|
||||
"xlsx.fn_suggestions": "SOMME;MOYENNE;NB;NBVAL;MIN;MAX;SI;RECHERCHEV;RECHERCHEX;SOMME.SI;SOMME.SI.ENS;NB.SI;CONCAT;GAUCHE;DROITE;STXT;AUJOURDHUI;MAINTENANT;ARRONDI;ABS;RACINE",
|
||||
"xlsx.formula_stored_as_text": "{n} valeur(s) commençant par « = » ou « @ » enregistrée(s) comme texte — activez le bouton f(x) pour écrire de vraies formules",
|
||||
"xlsx.find_placeholder": "Rechercher dans le classeur…",
|
||||
"xlsx.find_prev": "Précédent",
|
||||
"xlsx.find_next": "Suivant",
|
||||
"xlsx.find_case": "Respecter la casse",
|
||||
"xlsx.find_no_match": "Aucune correspondance",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Exporter la feuille en CSV",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
|
||||
"xlsx.csv_export": "Exporter la feuille ou la sélection en CSV",
|
||||
"xlsx.export_btn": "Exporter",
|
||||
"xlsx.export_title": "Exporter la sélection ou la feuille",
|
||||
"xlsx.export_md": "Exporter en Markdown",
|
||||
"xlsx.export_html": "Exporter en HTML",
|
||||
"xlsx.print": "Imprimer",
|
||||
"xlsx.export_scope_sheet": "la feuille",
|
||||
"xlsx.export_scope_selection": "la sélection",
|
||||
"xlsx.export_done": "Export de {scope} terminé",
|
||||
"xlsx.format_btn": "Mise en forme",
|
||||
"xlsx.format_bold": "Gras",
|
||||
"xlsx.format_italic": "Italique",
|
||||
"xlsx.format_underline": "Souligné",
|
||||
"xlsx.format_clear": "Effacer la mise en forme",
|
||||
"xlsx.format_align_left": "Aligner à gauche",
|
||||
"xlsx.format_align_center": "Centrer",
|
||||
"xlsx.format_align_right": "Aligner à droite",
|
||||
"xlsx.format_font_color": "Couleur du texte",
|
||||
"xlsx.format_fill_color": "Couleur de fond",
|
||||
"xlsx.format_num_general": "Format général",
|
||||
"xlsx.format_num_number": "Nombre (2 décimales)",
|
||||
"xlsx.format_num_percent": "Pourcentage",
|
||||
"xlsx.format_num_currency": "Monétaire (€)",
|
||||
"xlsx.format_num_date": "Date (JJ/MM/AAAA)",
|
||||
"xlsx.format_num_text": "Texte",
|
||||
"xlsx.format_merge": "Fusionner les cellules",
|
||||
"xlsx.format_unmerge": "Annuler la fusion",
|
||||
"xlsx.format_merge_needs_range": "Sélectionnez au moins deux cellules pour fusionner",
|
||||
"xlsx.format_freeze": "Figer les volets ici",
|
||||
"xlsx.format_unfreeze": "Libérer les volets",
|
||||
"xlsx.format_col_width": "Largeur de colonne…",
|
||||
"xlsx.format_col_prompt": "Largeur de la colonne {col} (0 à 255)",
|
||||
"xlsx.format_row_height": "Hauteur de ligne…",
|
||||
"xlsx.format_row_prompt": "Hauteur de la ligne {row} (0 à 409)",
|
||||
"xlsx.format_applied": "Mise en forme enregistrée",
|
||||
"xlsx.format_error": "Échec de la mise en forme",
|
||||
"xlsx.sort_asc": "Trier la colonne A→Z",
|
||||
"xlsx.sort_desc": "Trier la colonne Z→A",
|
||||
"xlsx.sort_applied": "Tri appliqué sur {col} — l'affichage seul, le classeur n'est pas modifié",
|
||||
@@ -1875,6 +1922,17 @@
|
||||
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.select_column": "Sélectionner la colonne",
|
||||
"xlsx.select_row": "Sélectionner la ligne",
|
||||
"xlsx.insert_row_above": "Insérer une ligne au-dessus",
|
||||
"xlsx.insert_row_below": "Insérer une ligne en dessous",
|
||||
"xlsx.insert_col_left": "Insérer une colonne à gauche",
|
||||
"xlsx.insert_col_right": "Insérer une colonne à droite",
|
||||
"xlsx.delete_rows": "Supprimer {n} ligne(s)",
|
||||
"xlsx.delete_cols": "Supprimer {n} colonne(s)",
|
||||
"xlsx.clear_contents": "Effacer le contenu",
|
||||
"xlsx.confirm_delete_rows": "Supprimer {n} ligne(s) ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.confirm_delete_cols": "Supprimer {n} colonne(s) ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_saved": "Structure mise à jour",
|
||||
"xlsx.structure_error": "Modification de la structure impossible",
|
||||
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
|
||||
@@ -1882,6 +1940,8 @@
|
||||
"xlsx.inspector_title": "Inspecteur",
|
||||
"xlsx.inspector_ai": "Assistant IA",
|
||||
"xlsx.inspector_ai_short": "IA",
|
||||
"xlsx.inspector_resize": "Redimensionner l'inspecteur",
|
||||
"xlsx.range_not_visible": "Cellule hors de l'affichage (feuille tronquée)",
|
||||
"xlsx.dashboard_title": "Tableau de bord du classeur",
|
||||
"xlsx.dashboard_stats": "{{cells}} cellules · {{rows}} lignes · {{cols}} colonnes · {{formulas}} formules · {{numeric}} valeurs numériques",
|
||||
"xlsx.dashboard_charts": "{{n}} graphique(s)",
|
||||
@@ -1892,6 +1952,7 @@
|
||||
"xlsx.dashboard_nr_ref": "Référence",
|
||||
"xlsx.dashboard_hint": "Sélectionnez une plage ou ouvrez l'assistant IA pour analyser ces données.",
|
||||
"xlsx.feature_cached_values": "valeurs calculées",
|
||||
"xlsx.feature_cached_values_unverified": "valeurs calculées non vérifiables (classeur trop volumineux)",
|
||||
"xlsx.feature_slicers": "segments et chronologies",
|
||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||
"xlsx.feature_connections": "connexions et requêtes",
|
||||
@@ -2067,6 +2128,9 @@
|
||||
"ai.step.xlsx_read": "Classeur lu : {value}",
|
||||
"ai.step.xlsx_update": "Cellules modifiées : {value}",
|
||||
"ai.step.xlsx_append": "Lignes ajoutées : {value}",
|
||||
"ai.step.xlsx_search": "Recherche dans le classeur : {value}",
|
||||
"ai.step.xlsx_analyze": "Plage analysée : {value}",
|
||||
"ai.step.xlsx_structure": "Structure modifiée : {value}",
|
||||
"ai.step.docx_create": "Document Word proposé : {value}",
|
||||
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
||||
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
||||
|
||||
+145
-2
@@ -11148,7 +11148,8 @@ body.desktop-mode .editor-container {
|
||||
border-bottom: 2px solid var(--grid-border);
|
||||
}
|
||||
.xlsx-table td[contenteditable] {
|
||||
cursor: text;
|
||||
/* #155 — Excel-style cross cursor over the cells. */
|
||||
cursor: cell;
|
||||
min-width: 40px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -11171,8 +11172,11 @@ body.desktop-mode .editor-container {
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
/* #156-A7 — the Name Box is editable (“Atteindre”): a plain input styled like
|
||||
the read-only badge it replaced. */
|
||||
.xlsx-active-cell {
|
||||
min-width: 52px;
|
||||
width: 84px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
@@ -11183,6 +11187,10 @@ body.desktop-mode .editor-container {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.xlsx-active-cell:focus {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-formula-sep {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -11252,6 +11260,72 @@ body.desktop-mode .editor-container {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* #156-A11 — export menu (CSV stays a button; MD/HTML/Print live here). */
|
||||
.xlsx-export-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.xlsx-export-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
padding: 4px;
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.xlsx-export-menu .export-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
.xlsx-export-menu .export-menu-item:hover {
|
||||
background: var(--surface-hover, rgba(127, 127, 127, 0.12));
|
||||
}
|
||||
.xlsx-print-frame {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* #156-A8 — formatting menu: the colour rows pair a label with the native
|
||||
picker (data-driven colours, no hardcoded swatch). */
|
||||
.xlsx-format-menu {
|
||||
min-width: 230px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.xlsx-format-colour {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-format-colour input[type="color"] {
|
||||
width: 34px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* #153 A15 — freeze panes: frozen rows sit UNDER the sticky thead, frozen
|
||||
columns stay left. z-index mirrors thead (3) without covering it. */
|
||||
.xlsx-table tr.xlsx-frozen-row td,
|
||||
@@ -11305,6 +11379,7 @@ body.desktop-mode .editor-container {
|
||||
max-width: 45%;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
@@ -11336,6 +11411,25 @@ body.desktop-mode .editor-container {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
/* #154-A5 — resize handle on the inspector's left edge. */
|
||||
.xlsx-inspector-resize {
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
.xlsx-inspector-resize:hover {
|
||||
background: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
.xlsx-range-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-range-row:hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.xlsx-inspector .xlsx-dashboard {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -11453,6 +11547,55 @@ body.desktop-mode .editor-container {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* #155 — Excel-like range selection + right-click / long-press context menu. */
|
||||
.xlsx-table tbody td.xlsx-selected {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
.xlsx-viewer.xlsx-selecting,
|
||||
.xlsx-viewer.xlsx-selecting td {
|
||||
user-select: none;
|
||||
}
|
||||
.xlsx-viewer.xlsx-selecting td[contenteditable] {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.xlsx-context-menu {
|
||||
position: fixed;
|
||||
z-index: 10050;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.xlsx-context-item {
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-context-item:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.xlsx-context-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.xlsx-context-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
.xlsx-context-sep {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* JSDOM shims for the tests that click anchors */
|
||||
mark {
|
||||
font: inherit;
|
||||
|
||||
+5
-1
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v27';
|
||||
const SW_VERSION = 'v29';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
@@ -30,6 +30,10 @@ const PRECACHE_URLS = [
|
||||
'/static/js/ui.js',
|
||||
'/static/js/sidebar.js',
|
||||
'/static/js/viewer.js',
|
||||
'/static/js/xlsx/refs.js',
|
||||
'/static/js/xlsx/command-bar.js',
|
||||
'/static/js/xlsx/dashboard.js',
|
||||
'/static/js/xlsx/context-menu.js',
|
||||
'/static/js/search.js',
|
||||
'/static/js/config.js',
|
||||
'/static/js/utils.js',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.43.0",
|
||||
"version": "2.46.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": {
|
||||
|
||||
@@ -241,17 +241,18 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
// Un clic sur une cellule active la barre avec son nom et son contenu.
|
||||
// (#156-A7 : la zone Nom est un input, donc on lit sa valeur.)
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await cell.click();
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('B2');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B2');
|
||||
const bar = page.locator('#xlsx-formula-input');
|
||||
await expect(bar).toHaveValue(/Operation 1/);
|
||||
|
||||
// Les flèches déplacent la cellule active.
|
||||
await cell.press('ArrowDown');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('B3');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B3');
|
||||
await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('C3');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveValue('C3');
|
||||
|
||||
// Éditer depuis la barre marque la cellule dirty, Échap annule.
|
||||
await bar.fill('Operation 2 modifiee');
|
||||
@@ -260,3 +261,56 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
||||
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #156 P1 — presse-papiers de plage, clavier complet, zone Nom ────────────
|
||||
|
||||
test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)', () => {
|
||||
test('coller une plage, la navigation clavier et la zone Nom (#156 A5/A6/A7)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const nameBox = page.locator('#xlsx-active-cell');
|
||||
const cellOf = (ref) => page.locator(`#content-area td[data-cell="${ref}"]`);
|
||||
const textOf = async (ref) => (await cellOf(ref).innerText()).trim();
|
||||
|
||||
// — A7 : la zone Nom est éditable et sert à « Atteindre ».
|
||||
await nameBox.fill('B12');
|
||||
await nameBox.press('Enter');
|
||||
await expect(nameBox).toHaveValue('B12');
|
||||
await expect(cellOf('B12')).toHaveClass(/xlsx-active/);
|
||||
|
||||
// — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins.
|
||||
await cellOf('B12').click();
|
||||
await page.keyboard.press('Control+a');
|
||||
await expect(nameBox).toHaveValue('A1:C500');
|
||||
await page.keyboard.press('Control+Home');
|
||||
await expect(nameBox).toHaveValue('A1');
|
||||
await page.keyboard.press('Control+End');
|
||||
await expect(nameBox).toHaveValue('C500');
|
||||
|
||||
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
|
||||
// utilise le presse-papiers interne de la grille : aucune permission
|
||||
// navigateur n'est requise.
|
||||
const src = { a1: await textOf('A1'), b1: await textOf('B1'), b2: await textOf('B2'), c10: await textOf('C10') };
|
||||
await cellOf('A1').click();
|
||||
await cellOf('B2').click({ modifiers: ['Shift'] });
|
||||
await expect(nameBox).toHaveValue('A1:B2');
|
||||
await cellOf('A1').click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'Copier' }).click();
|
||||
await cellOf('A10').click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'Coller' }).click();
|
||||
expect(await textOf('A10')).toBe(src.a1);
|
||||
expect(await textOf('B10')).toBe(src.b1);
|
||||
expect(await textOf('B11')).toBe(src.b2);
|
||||
expect(await textOf('C10')).toBe(src.c10);
|
||||
|
||||
// — A6 : Suppr vide la sélection (et seulement elle).
|
||||
await cellOf('A10').click();
|
||||
await cellOf('B11').click({ modifiers: ['Shift'] });
|
||||
await expect(nameBox).toHaveValue('A10:B11');
|
||||
await page.keyboard.press('Delete');
|
||||
expect(await textOf('A10')).toBe('');
|
||||
expect(await textOf('B11')).toBe('');
|
||||
expect(await textOf('C10')).toBe(src.c10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate - JSDOM tests for the search results facets panel (#157):
|
||||
* vault, tag and file-extension facets rendered by
|
||||
* `renderAdvancedSearchResults()` (frontend/js/search.js).
|
||||
*
|
||||
* Covers:
|
||||
* - the three facet groups are rendered (Vaults / Tags / Extensions)
|
||||
* - the extensions facet appears even when tags and vaults are empty
|
||||
* - clicking an extension replaces any `ext:` operator in the search
|
||||
* input and triggers a new search
|
||||
*
|
||||
* Usage: node tests/frontend/search-facets.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html><body>
|
||||
<input id="search-input" value="" />
|
||||
<select id="vault-filter"><option value="all">all</option></select>
|
||||
<div id="results-bar" style="display:none"></div>
|
||||
<span id="rb-count"></span>
|
||||
<div id="content-area"></div>
|
||||
</body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.KeyboardEvent = w.KeyboardEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
globalThis.MutationObserver = w.MutationObserver;
|
||||
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Backend payload shape for /api/search/advanced (facets only matter here).
|
||||
const PAYLOAD = {
|
||||
results: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
query_time_ms: 1,
|
||||
facets: {
|
||||
vaults: { IT: 4, Recettes: 2 },
|
||||
tags: { linux: 3 },
|
||||
extensions: { md: 5, pdf: 2, xlsx: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => PAYLOAD,
|
||||
});
|
||||
|
||||
const { renderAdvancedSearchResults } = await import(
|
||||
pathToFileURL(path.join(JS_DIR, "search.js")).href
|
||||
);
|
||||
|
||||
const facetGroups = () =>
|
||||
Array.from(document.querySelectorAll(".search-facets__group"));
|
||||
const extGroup = () =>
|
||||
document.querySelector('.search-facets__group[data-facet="extensions"]');
|
||||
const extItems = () =>
|
||||
Array.from(extGroup()?.querySelectorAll(".search-facets__item") || []);
|
||||
|
||||
console.log("Search facets (#157) — Vaults / Tags / Extensions");
|
||||
|
||||
await test("renders the three facet groups", async () => {
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
assert.equal(facetGroups().length, 3, "vault, tag and extension groups");
|
||||
const labels = facetGroups().map((g) =>
|
||||
g.querySelector(".search-facets__label").textContent,
|
||||
);
|
||||
assert.deepEqual(labels, ["Vaults", "Tags", "Extensions"]);
|
||||
});
|
||||
|
||||
await test("lists extensions with their counts", async () => {
|
||||
const texts = extItems().map((i) => i.textContent.trim());
|
||||
assert.deepEqual(texts, [".md 5", ".pdf 2", ".xlsx 1"]);
|
||||
assert.equal(
|
||||
extItems()[0].querySelector(".facet-count").textContent,
|
||||
"5",
|
||||
"count lives in .facet-count",
|
||||
);
|
||||
});
|
||||
|
||||
await test("extension facet shows even with empty tags and vaults", async () => {
|
||||
renderAdvancedSearchResults(
|
||||
{ ...PAYLOAD, facets: { vaults: {}, tags: {}, extensions: { md: 1 } } },
|
||||
"",
|
||||
null,
|
||||
);
|
||||
assert.ok(extGroup(), "extensions group rendered alone");
|
||||
assert.equal(extItems().length, 1);
|
||||
});
|
||||
|
||||
await test("clicking an extension adds the ext: operator", async () => {
|
||||
document.getElementById("search-input").value = "python ext:md";
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
extItems()[1].click(); // .pdf
|
||||
assert.equal(
|
||||
document.getElementById("search-input").value,
|
||||
"python ext:pdf",
|
||||
"existing ext: operator replaced, not duplicated",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
process.exit(1);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -252,7 +252,9 @@ class TestDependencySecurityFloors:
|
||||
#: (paquet, plancher minimal, advisories corrigées au-dessus)
|
||||
FLOORS = {
|
||||
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
|
||||
"pyjwt": (2, 13, 0), # PYSEC-2026-178 (fix 2.13.0)
|
||||
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
|
||||
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
|
||||
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
|
||||
}
|
||||
|
||||
def test_security_floors_are_declared(self):
|
||||
|
||||
@@ -225,6 +225,14 @@ class TestAdvancedSearchAPI:
|
||||
resp = client.get("/api/advanced-search?q=python&vault=all")
|
||||
data = self._check(resp)
|
||||
assert "facets" in data
|
||||
# #157 — extension facet: keys are query-ready (no leading dot) and,
|
||||
# when there are results, at least one extension is counted.
|
||||
exts = data["facets"]["extensions"]
|
||||
assert isinstance(exts, dict)
|
||||
assert all(not e.startswith(".") for e in exts)
|
||||
if data["total"] > 0:
|
||||
assert len(exts) >= 1
|
||||
assert sum(exts.values()) <= data["total"]
|
||||
|
||||
def test_empty_query(self, client):
|
||||
resp = client.get("/api/advanced-search?q=")
|
||||
|
||||
@@ -223,3 +223,197 @@ class TestAppendXlsxRows:
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "rows": [],
|
||||
})
|
||||
|
||||
|
||||
# ── #156-A14 — formats étendus, recherche, analyse de plage, structure ──────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notes_csv(vault: Path) -> Path:
|
||||
"""A `;`-separated CSV — the locale the viewer sniffs (BUG-098)."""
|
||||
path = vault / "notes.csv"
|
||||
path.write_text("Poste;Montant\nLoyer;900\nCourses;250\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class TestRegistryExtended:
|
||||
def test_read_tools_include_search_and_analyze(self):
|
||||
for name in ("search_workbook", "analyze_range"):
|
||||
spec = get_tool(name)
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.READ
|
||||
|
||||
def test_structure_tool_requires_confirmation(self, vault, workbook):
|
||||
spec = get_tool("edit_xlsx_structure")
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.WRITE
|
||||
with pytest.raises(ToolConfirmationRequired):
|
||||
call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
|
||||
})
|
||||
|
||||
|
||||
class TestXlsmSupport:
|
||||
def test_reads_and_edits_an_xlsm(self, vault):
|
||||
path = vault / "macro.xlsm"
|
||||
wb = openpyxl.Workbook()
|
||||
wb.active.title = "Donnees"
|
||||
wb.active.append(["A", "B"])
|
||||
wb.active.append([1, 2])
|
||||
wb.save(path)
|
||||
|
||||
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
|
||||
assert out.ok and out.data["sheets"][0]["name"] == "Donnees"
|
||||
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
|
||||
assert "| A | B |" in md.data["markdown"]
|
||||
|
||||
edit = call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "macro.xlsm", "sheet": "Donnees",
|
||||
"cells": {"A2": "9"},
|
||||
}, confirm=True)
|
||||
assert edit.ok
|
||||
wb2 = openpyxl.load_workbook(path)
|
||||
assert wb2["Donnees"]["A2"].value == 9
|
||||
wb2.close()
|
||||
|
||||
|
||||
class TestCsvSupport:
|
||||
def test_lists_reads_and_searches_a_csv(self, vault, notes_csv):
|
||||
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "notes.csv"})
|
||||
assert out.ok
|
||||
assert out.data["sheets"] == [
|
||||
{"name": "notes", "total_rows": 3, "total_cols": 2, "truncated": False}
|
||||
]
|
||||
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "notes.csv"})
|
||||
assert "| Poste | Montant |" in md.data["markdown"]
|
||||
found = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv", "query": "Loyer",
|
||||
})
|
||||
assert found.ok
|
||||
assert found.data["total"] == 1
|
||||
assert found.data["matches"][0] == {"sheet": "notes", "cell": "A2", "value": "Loyer"}
|
||||
assert found.data["by_sheet"] == {"notes": 1}
|
||||
|
||||
def test_updates_a_csv_keeping_its_delimiter(self, vault, notes_csv):
|
||||
out = call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv", "cells": {"B2": "1000"},
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
assert "Loyer;1000" in notes_csv.read_text(encoding="utf-8-sig")
|
||||
|
||||
def test_append_rows_refuses_a_csv(self, vault, notes_csv):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("append_xlsx_rows", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv",
|
||||
"sheet": "notes", "rows": [["x"]],
|
||||
}, confirm=True)
|
||||
|
||||
def test_structure_refuses_a_csv(self, vault, notes_csv):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv",
|
||||
"actions": [{"op": "sheet_add", "name": "X"}],
|
||||
}, confirm=True)
|
||||
|
||||
def test_wrong_extension_is_still_refused(self, vault):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "note.md", "query": "a",
|
||||
})
|
||||
|
||||
|
||||
class TestSearchWorkbook:
|
||||
def test_scans_every_sheet_with_counts(self, vault, workbook):
|
||||
out = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "o",
|
||||
})
|
||||
assert out.ok
|
||||
assert out.data["by_sheet"]["Budget"] > 0
|
||||
assert out.data["by_sheet"]["Notes"] == 1 # « bonjour » : une seule cellule
|
||||
assert out.data["truncated"] is False
|
||||
assert out.data["total"] == sum(out.data["by_sheet"].values())
|
||||
|
||||
def test_case_sensitive_and_sheet_scope(self, vault, workbook):
|
||||
loose = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
|
||||
})
|
||||
assert loose.data["total"] == 1
|
||||
strict = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
|
||||
"case_sensitive": True,
|
||||
})
|
||||
assert strict.data["total"] == 0
|
||||
scoped = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "bonjour",
|
||||
"sheet": "Budget",
|
||||
})
|
||||
assert scoped.data["total"] == 0
|
||||
|
||||
def test_empty_query_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": " ",
|
||||
})
|
||||
|
||||
|
||||
class TestAnalyzeRange:
|
||||
def test_aggregates_a_column(self, vault, workbook):
|
||||
out = call_tool("analyze_range", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "range": "B2:B3",
|
||||
})
|
||||
assert out.ok
|
||||
stats = out.data["numeric"]
|
||||
assert stats["count"] == 2
|
||||
assert stats["sum"] == 1150
|
||||
assert stats["mean"] == 575
|
||||
assert stats["min"] == 250 and stats["max"] == 900
|
||||
|
||||
def test_whole_sheet_and_fr_separators(self, vault):
|
||||
path = vault / "prix.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["Article", "Prix"])
|
||||
ws.append(["A", "1 234,5"])
|
||||
ws.append(["B", 10])
|
||||
wb.save(path)
|
||||
out = call_tool("analyze_range", _ctx(), {"vault": "Vault", "path": "prix.xlsx"})
|
||||
assert out.ok
|
||||
assert out.data["numeric"]["count"] == 2
|
||||
assert out.data["numeric"]["max"] == 1234.5
|
||||
assert out.data["cells"] == 6 # 2 en-têtes + 4 valeurs
|
||||
|
||||
def test_bad_range_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("analyze_range", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "range": "pas-une-plage",
|
||||
})
|
||||
|
||||
|
||||
class TestEditXlsxStructure:
|
||||
def test_inserts_a_row_and_survives_a_reload(self, vault, workbook):
|
||||
out = call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
|
||||
}, confirm=True)
|
||||
assert out.ok and out.data["actions"] == 1
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
assert wb["Budget"]["A3"].value == "Loyer" # la ligne 2 a été poussée
|
||||
wb.close()
|
||||
|
||||
def test_adds_a_sheet(self, vault, workbook):
|
||||
out = call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"actions": [{"op": "sheet_add", "name": "Trois"}],
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
assert "Trois" in wb.sheetnames
|
||||
wb.close()
|
||||
|
||||
def test_empty_actions_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "actions": [],
|
||||
})
|
||||
|
||||
@@ -95,9 +95,32 @@ class TestXlsmEditable:
|
||||
with zipfile.ZipFile(_vault_file(client, xlsm_book)) as zf:
|
||||
assert "xl/vbaProject.bin" in zf.namelist()
|
||||
|
||||
def test_lossy_gate_is_skipped_for_xlsm(self, formats_vault, xlsm_book):
|
||||
def test_sheet_window_is_served_for_xlsm(self, client, xlsm_book):
|
||||
"""BUG-097 — a .xlsm is editable, so its lazy row windows must work."""
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/sheet",
|
||||
params={"path": xlsm_book, "sheet": "Data", "offset": 0, "limit": 100},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["sheet"] == "Data"
|
||||
assert 'data-cell="A2"' in body["html"]
|
||||
assert body["has_more"] is False
|
||||
|
||||
def test_sheet_window_still_refuses_other_formats(self, client, formats_vault):
|
||||
(formats_vault / "plain.csv").write_text("a,b\n", encoding="utf-8")
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/sheet",
|
||||
params={"path": "plain.csv", "sheet": "Feuille1"},
|
||||
)
|
||||
assert resp.status_code == 415
|
||||
|
||||
def test_lossy_gate_is_skipped_for_xlsm(self, client, formats_vault, xlsm_book):
|
||||
from backend.services.mutations import edit_xlsx_cells
|
||||
|
||||
# `client` is required: the service resolves the vault through the app
|
||||
# registry (the test used to pass only thanks to state leaked by an
|
||||
# earlier test in this class).
|
||||
# No force flag: the save must succeed despite the vbaProject part
|
||||
# (it would raise xlsx_lossy_content on a plain .xlsx).
|
||||
result = edit_xlsx_cells(VAULT, xlsm_book, "Data", {"A2": "Alan"})
|
||||
@@ -149,6 +172,22 @@ class TestLegacyReadOnly:
|
||||
assert sheet["rows"] == 0
|
||||
|
||||
|
||||
class TestCsvDelimiter:
|
||||
"""BUG-098 — the delimiter is detected, not assumed to be a comma."""
|
||||
|
||||
def test_sniffing_handles_the_common_separators(self):
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
assert sniff_csv_delimiter("a;b\n1;2\n") == ";"
|
||||
assert sniff_csv_delimiter("a,b\n1,2\n") == ","
|
||||
assert sniff_csv_delimiter("a\tb\n1\t2\n") == "\t"
|
||||
|
||||
def test_single_column_falls_back_to_a_comma(self):
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
assert sniff_csv_delimiter("juste\nune\ncolonne\n") == ","
|
||||
|
||||
|
||||
class TestCsvEditable:
|
||||
def test_read_renders_the_xlsx_shaped_table(self, client, formats_vault):
|
||||
(formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
@@ -197,6 +236,42 @@ class TestCsvEditable:
|
||||
text = _vault_file(client, "quotes.csv").read_text(encoding="utf-8")
|
||||
assert '"Martin, Pierre"' in text
|
||||
|
||||
def test_semicolon_csv_renders_multiple_columns(self, client, formats_vault):
|
||||
"""BUG-098 — a French (semicolon) CSV is no longer a single column."""
|
||||
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
|
||||
html = client.get(
|
||||
f"/api/file/{VAULT}", params={"path": "fr.csv"}
|
||||
).json()["html"]
|
||||
assert 'data-cell="B1"' in html and 'data-cell="B2"' in html
|
||||
assert "Paris" in html
|
||||
|
||||
def test_semicolon_is_preserved_when_saving(self, client, formats_vault):
|
||||
"""BUG-098 — the write-back keeps the file's own delimiter."""
|
||||
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
|
||||
resp = client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "fr.csv"},
|
||||
json={"cells": {"B2": "Lyon"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
text = _vault_file(client, "fr.csv").read_text(encoding="utf-8")
|
||||
assert "Nom;Ville" in text
|
||||
assert "Ada;Lyon" in text
|
||||
assert "," not in text # the source delimiter is not switched to a comma
|
||||
|
||||
def test_quoted_semicolon_survives_a_round_trip(self, client, formats_vault):
|
||||
(formats_vault / "quote-fr.csv").write_text(
|
||||
'Nom;Note\n"Dupont; Jean";ok\n', encoding="utf-8"
|
||||
)
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "quote-fr.csv"},
|
||||
json={"cells": {"B2": "modifié"}},
|
||||
)
|
||||
text = _vault_file(client, "quote-fr.csv").read_text(encoding="utf-8")
|
||||
assert '"Dupont; Jean"' in text
|
||||
assert ";modifié" in text
|
||||
|
||||
def test_bad_reference_is_refused(self, client, formats_vault):
|
||||
(formats_vault / "liste.csv").write_text("a\n", encoding="utf-8")
|
||||
resp = client.put(
|
||||
|
||||
@@ -35,6 +35,20 @@ def styled_book(test_vault_dir: str) -> str:
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plain_book(test_vault_dir: str) -> str:
|
||||
"""An unstyled workbook — the write side starts from a blank slate."""
|
||||
path = Path(test_vault_dir) / "plain.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws.append(["Nom", "Valeur"])
|
||||
ws.append(["a", 1])
|
||||
ws.append(["b", 2])
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
class TestStyleReading:
|
||||
def test_bold_italic_and_font_color(self, styled_book):
|
||||
from backend.xlsx_reader import read_sheet_styles
|
||||
@@ -82,6 +96,160 @@ class TestStyleReading:
|
||||
assert read_sheet_styles(bad, "Data") == {}
|
||||
|
||||
|
||||
class TestStyleWriting:
|
||||
"""#156-A8 — the viewer can now WRITE formatting, not just read it.
|
||||
|
||||
One locked, atomic rewrite through ``PUT …/xlsx/style``: styles (bold,
|
||||
italic, underline, colours, alignment, number format), merges, column
|
||||
widths, row heights and frozen panes.
|
||||
"""
|
||||
|
||||
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_styles_survive_a_reload(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{
|
||||
"op": "cell", "sheet": "Data", "range": "A1:B1",
|
||||
"style": {
|
||||
"bold": True, "italic": True, "underline": True,
|
||||
"font_color": "#cc0000", "fill_color": "#ffe08a",
|
||||
"align": "center", "number_format": "0.00",
|
||||
},
|
||||
},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["revision"]
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
ws = wb["Data"]
|
||||
assert ws["A1"].font.bold is True and ws["A1"].font.italic is True
|
||||
assert ws["A1"].font.underline == "single"
|
||||
assert ws["A1"].font.color.rgb == "FFCC0000"
|
||||
assert ws["A1"].fill.start_color.rgb == "FFFFE08A"
|
||||
assert ws["A1"].alignment.horizontal == "center"
|
||||
assert ws["B1"].number_format == "0.00"
|
||||
assert ws["A1"].value == "Nom", "the value is untouched"
|
||||
assert not ws["A2"].font.bold, "a neighbour outside the range is untouched"
|
||||
wb.close()
|
||||
|
||||
def test_the_written_style_is_read_back_by_the_viewer(self, client, plain_book):
|
||||
self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "fill_color": "#ffe08a"}},
|
||||
])
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
assert resp.status_code == 200
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
style = sheet["styles"]["A1"]
|
||||
assert "font-weight:600" in style and "background:#ffe08a" in style
|
||||
|
||||
def test_merges_sizes_and_freeze(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "merge", "sheet": "Data", "range": "A1:B1"},
|
||||
{"op": "col_width", "sheet": "Data", "col": "A", "width": 24},
|
||||
{"op": "row_height", "sheet": "Data", "row": 2, "height": 30},
|
||||
{"op": "freeze", "sheet": "Data", "cell": "A2"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
ws = wb["Data"]
|
||||
assert [str(r) for r in ws.merged_cells.ranges] == ["A1:B1"]
|
||||
assert ws.column_dimensions["A"].width == 24
|
||||
assert ws.row_dimensions[2].height == 30
|
||||
assert str(ws.freeze_panes) == "A2"
|
||||
wb.close()
|
||||
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "unmerge", "sheet": "Data", "range": "A1:B1"},
|
||||
{"op": "freeze", "sheet": "Data", "cell": ""},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
ws = wb["Data"]
|
||||
assert not list(ws.merged_cells.ranges)
|
||||
assert ws.freeze_panes is None
|
||||
wb.close()
|
||||
|
||||
def test_a_style_can_be_cleared(self, client, plain_book):
|
||||
self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "font_color": "#cc0000", "fill_color": "#ffe08a"}},
|
||||
])
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": False, "font_color": "", "fill_color": "", "number_format": "General"}},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
ws = wb["Data"]
|
||||
assert ws["A1"].font.bold in (None, False)
|
||||
assert ws["A1"].font.color is None
|
||||
assert ws["A1"].fill.fill_type is None
|
||||
assert ws["A1"].number_format == "General"
|
||||
wb.close()
|
||||
|
||||
def test_invalid_inputs_are_refused(self, client, plain_book):
|
||||
bad_style = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"blink": True}},
|
||||
])
|
||||
assert bad_style.status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {}},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"fill_color": "rouge"}},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "pas-une-plage", "style": {"bold": True}},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Nope", "range": "A1", "style": {"bold": True}},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [{"op": "spin", "sheet": "Data"}]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", []).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "col_width", "sheet": "Data", "col": "A", "width": 900},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"align": "diagonal"}},
|
||||
]).status_code == 400
|
||||
|
||||
def test_a_csv_is_refused(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "style.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
resp = self._style(client, "style.csv", [
|
||||
{"op": "cell", "sheet": "x", "range": "A1", "style": {"bold": True}},
|
||||
])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_a_stale_revision_is_refused_and_the_fresh_one_accepted(self, client, plain_book):
|
||||
first = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
|
||||
])
|
||||
assert first.status_code == 200
|
||||
stale = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
|
||||
], if_match="deadbeef-1")
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["details"]["reason"] == "stale_revision"
|
||||
fresh = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
|
||||
], if_match=first.json()["revision"])
|
||||
assert fresh.status_code == 200
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A3", "style": {"bold": True}},
|
||||
], if_match=42).status_code == 400
|
||||
|
||||
def test_a_backup_is_written_before_the_rewrite(self, client, plain_book):
|
||||
from backend.services.backups import get_backup_dir
|
||||
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
|
||||
]).status_code == 200
|
||||
backups = list(get_backup_dir(VAULT, "plain.xlsx").glob("plain.xlsx.*.bak"))
|
||||
assert backups, "the rewrite is backed up first"
|
||||
|
||||
|
||||
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"})
|
||||
|
||||
@@ -96,6 +96,29 @@ class TestXlsxDisplay:
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "corrupt.xlsx"})
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_empty_sheet_renders_an_editable_blank_grid(self, client, test_vault_dir):
|
||||
"""BUG-094 — a blank sheet used to render as a bare « Feuille vide »
|
||||
paragraph with no cell, so a freshly added sheet could not be filled
|
||||
and had no way to insert a row/column. It now exposes a small editable
|
||||
grid with real A1 coordinates."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "blank.xlsx"
|
||||
wb = Workbook()
|
||||
wb.active.title = "Vide"
|
||||
wb.create_sheet("Vide2")
|
||||
wb.save(str(path))
|
||||
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "blank.xlsx"})
|
||||
assert resp.status_code == 200
|
||||
vide = next(s for s in resp.json()["xlsx_sheets"] if s["name"] == "Vide")
|
||||
assert "Feuille vide" not in vide["html"]
|
||||
assert 'data-cell="A1"' in vide["html"]
|
||||
assert 'data-cell="H20"' in vide["html"] # last cell of the blank grid
|
||||
assert vide["rows"] == 20
|
||||
assert vide["cols"] == 8
|
||||
assert vide["truncated"] is False
|
||||
|
||||
|
||||
# ── Index parity (tree visibility) ────────────────────────────────────────
|
||||
|
||||
@@ -451,6 +474,64 @@ class TestXlsxLossyGuard:
|
||||
assert inspect_workbook(bad) == []
|
||||
|
||||
|
||||
# ── BUG-099 — the cached-value probe must never fail silently ──────────────
|
||||
|
||||
|
||||
def _probe_zip(tmp_path: Path, sheets: list[bytes]) -> Path:
|
||||
"""Build a minimal archive holding the given worksheet XML parts."""
|
||||
path = tmp_path / "probe.xlsx"
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for i, blob in enumerate(sheets, start=1):
|
||||
zf.writestr(f"xl/worksheets/sheet{i}.xml", blob)
|
||||
return path
|
||||
|
||||
|
||||
class TestXlsxCachedValueProbe:
|
||||
"""BUG-099 — an exhausted probe is reported, never mistaken for clean."""
|
||||
|
||||
def test_exhausted_budget_is_reported_as_unverified(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from backend import xlsx_reader
|
||||
|
||||
# Budget smaller than the only sheet: the scan cannot reach its end.
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 64)
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 64)
|
||||
book = _probe_zip(tmp_path, [b"<x/>" + b"filler" * 1024])
|
||||
|
||||
# Contre-preuve: the old code returned [] here — an interrupted scan was
|
||||
# indistinguishable from a completed one, so the write guard stayed
|
||||
# silent and the cached values could be dropped without warning.
|
||||
assert xlsx_reader.inspect_workbook(book) == ["cached_values_unverified"]
|
||||
|
||||
def test_a_huge_first_sheet_does_not_starve_the_next_one(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from backend import xlsx_reader
|
||||
|
||||
# Per-sheet cap (1 KiB) well under the total allowance (4 KiB): a single
|
||||
# shared budget would spend everything on sheet 1 and never look at
|
||||
# sheet 2, where the only cached formula lives.
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 1024)
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 4096)
|
||||
cached = b'<sheetData><c r="A1"><f>1+1</f><v>2</v></c></sheetData>'
|
||||
book = _probe_zip(tmp_path, [b"filler" * 2048, cached])
|
||||
|
||||
assert "cached_values" in xlsx_reader.inspect_workbook(book)
|
||||
|
||||
def test_a_completed_clean_scan_reports_nothing(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from backend import xlsx_reader
|
||||
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 4096)
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 8192)
|
||||
book = _probe_zip(tmp_path, [b"<x/>", b"<x/>"])
|
||||
|
||||
# No cached value and no budget issue: no false positive either.
|
||||
assert xlsx_reader.inspect_workbook(book) == []
|
||||
|
||||
|
||||
# ── #153 A2 — atomic write ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -718,3 +799,164 @@ class TestXlsxFormulaGuard:
|
||||
ws = openpyxl.load_workbook(xlsx_file)["Budget"]
|
||||
assert ws["B3"].value == 42 and isinstance(ws["B3"].value, int)
|
||||
assert ws["B4"].value == -3.5
|
||||
|
||||
|
||||
class TestMetaCache:
|
||||
"""#156-A13 — ``read_workbook_meta`` is cached on (path, mtime_ns, size).
|
||||
|
||||
A truncated sheet is fetched window by window: without the cache every
|
||||
window paid a full normal-mode workbook load just for the style / merge /
|
||||
freeze maps of one sheet.
|
||||
"""
|
||||
|
||||
def test_second_call_is_served_from_the_cache(self, xlsx_file, monkeypatch):
|
||||
from backend import xlsx_reader
|
||||
|
||||
path = Path(xlsx_file)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
real_load = xlsx_reader.load_workbook
|
||||
loads = []
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
loads.append(1)
|
||||
return real_load(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
|
||||
first = xlsx_reader.read_workbook_meta(path)
|
||||
assert len(loads) == 1, "la première lecture charge le classeur"
|
||||
second = xlsx_reader.read_workbook_meta(path)
|
||||
assert len(loads) == 1, "la seconde lecture ne recharge pas le classeur"
|
||||
assert second is first, "la même carte est servie (lecture seule côté appelant)"
|
||||
assert "Budget" in first
|
||||
|
||||
def test_invalidation_forces_a_reload(self, xlsx_file, monkeypatch):
|
||||
from backend import xlsx_reader
|
||||
|
||||
path = Path(xlsx_file)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
real_load = xlsx_reader.load_workbook
|
||||
loads = []
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
loads.append(1)
|
||||
return real_load(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
|
||||
xlsx_reader.read_workbook_meta(path)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
xlsx_reader.read_workbook_meta(path)
|
||||
assert len(loads) == 2, "l'invalidation explicite force un rechargement"
|
||||
|
||||
def test_rewriting_the_file_invalidates_the_cache(self, xlsx_file):
|
||||
from backend import xlsx_reader
|
||||
|
||||
path = Path(xlsx_file)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == []
|
||||
wb = openpyxl.load_workbook(xlsx_file)
|
||||
wb["Budget"].merge_cells("A1:B1")
|
||||
wb.save(xlsx_file)
|
||||
wb.close()
|
||||
# (mtime_ns, size) changed: the cached maps cannot be served any more.
|
||||
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == ["A1:B1"]
|
||||
|
||||
def test_two_windows_share_one_metadata_load(self, xlsx_file, monkeypatch):
|
||||
from backend import xlsx_reader
|
||||
|
||||
path = Path(xlsx_file)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
real_load = xlsx_reader.load_workbook
|
||||
normal = []
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
if not kwargs.get("read_only"):
|
||||
normal.append(1)
|
||||
return real_load(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
|
||||
assert xlsx_reader.read_sheet_window(path, "Budget", 0, 1) is not None
|
||||
assert xlsx_reader.read_sheet_window(path, "Budget", 1, 1) is not None
|
||||
assert len(normal) == 1, "les deux fenêtres partagent une seule lecture de métadonnées"
|
||||
|
||||
|
||||
class TestOptimisticConcurrency:
|
||||
"""#156-A12 — the read exposes a revision, a write may require it.
|
||||
|
||||
A stale ``if_match`` (the file changed since it was read: Excel, the
|
||||
watcher, another worker) is refused with 409 ``conflict`` instead of
|
||||
silently overwriting the other writer.
|
||||
"""
|
||||
|
||||
def _save(self, client, body, path="budget.xlsx"):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body
|
||||
)
|
||||
|
||||
def _revision(self, client, path="budget.xlsx"):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": path})
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["xlsx_revision"]
|
||||
|
||||
def test_the_read_exposes_a_revision_and_the_write_refreshes_it(self, client, xlsx_file):
|
||||
rev = self._revision(client)
|
||||
assert isinstance(rev, str) and rev
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["revision"] and resp.json()["revision"] != rev
|
||||
|
||||
def test_a_stale_if_match_is_refused_and_writes_nothing(self, client, xlsx_file):
|
||||
rev = self._revision(client)
|
||||
assert self._save(
|
||||
client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev}
|
||||
).status_code == 200
|
||||
# The token is stale now: the client must re-read before writing again.
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "deux"}, "if_match": rev})
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == "conflict"
|
||||
assert resp.json()["details"]["reason"] == "stale_revision"
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value is None
|
||||
|
||||
def test_omitting_if_match_keeps_last_writer_wins(self, client, xlsx_file):
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "sans-jeton"}})
|
||||
assert resp.status_code == 200
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value == "sans-jeton"
|
||||
|
||||
def test_an_invalid_if_match_is_rejected(self, client, xlsx_file):
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "x"}, "if_match": 42})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_the_structure_endpoint_honours_if_match(self, client, xlsx_file):
|
||||
rev = self._revision(client)
|
||||
actions = [{"op": "row_insert", "sheet": "Budget", "at": 1}]
|
||||
ok = client.put(
|
||||
f"/api/file/{VAULT}/xlsx/structure",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"actions": actions, "if_match": rev},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
stale = client.put(
|
||||
f"/api/file/{VAULT}/xlsx/structure",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"actions": actions, "if_match": rev},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["code"] == "conflict"
|
||||
|
||||
def test_the_csv_endpoint_honours_if_match(self, client, test_vault_dir):
|
||||
csv_path = Path(test_vault_dir) / "notes.csv"
|
||||
csv_path.write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
rev = self._revision(client, "notes.csv")
|
||||
ok = client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "notes.csv"},
|
||||
json={"cells": {"A1": "x"}, "if_match": rev},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["revision"] != rev
|
||||
stale = client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "notes.csv"},
|
||||
json={"cells": {"A1": "y"}, "if_match": rev},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert csv_path.read_text(encoding="utf-8").startswith("x")
|
||||
|
||||
Reference in New Issue
Block a user