diff --git a/CHANGELOG.md b/CHANGELOG.md
index cfb1a14..7c7ebf9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.44.1**.
+> [Unreleased](#unreleased). La dernière version livrée est **2.45.0**.
---
@@ -14,6 +14,134 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [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 `……` 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é
diff --git a/README.fr.md b/README.fr.md
index e6e95cf..efe4152 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -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.44.1).
+Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.45.0).
---
-*Projet : ObsiGate | Version : 2.44.1 | Dernière mise à jour : Septembre 2026*
+*Projet : ObsiGate | Version : 2.45.0 | Dernière mise à jour : Septembre 2026*
diff --git a/README.md b/README.md
index 8ad0d42..8eacc1c 100644
--- a/README.md
+++ b/README.md
@@ -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.44.1).
+See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.45.0).
---
-*Project: ObsiGate | Version: 2.44.1 | Last updated: September 2026*
+*Project: ObsiGate | Version: 2.45.0 | Last updated: September 2026*
diff --git a/VERSION b/VERSION
index 3925454..e599014 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.44.1
+2.45.0
diff --git a/backend/openapi_docs.py b/backend/openapi_docs.py
index 1143700..ecd702b 100644
--- a/backend/openapi_docs.py
+++ b/backend/openapi_docs.py
@@ -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"): {
diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py
index 7fec5e9..ab2474b 100644
--- a/backend/routers/files_read.py
+++ b/backend/routers/files_read.py
@@ -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 ===
diff --git a/backend/routers/files_write.py b/backend/routers/files_write.py
index b4297f6..9f85217 100644
--- a/backend/routers/files_write.py
+++ b/backend/routers/files_write.py
@@ -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)
diff --git a/backend/schemas.py b/backend/schemas.py
index 86381ea..298418c 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -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):
diff --git a/backend/services/mutations.py b/backend/services/mutations.py
index 998cb44..8d99261 100644
--- a/backend/services/mutations.py
+++ b/backend/services/mutations.py
@@ -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),
}
diff --git a/backend/tools/labels.py b/backend/tools/labels.py
index fc22f27..7ee9a1a 100644
--- a/backend/tools/labels.py
+++ b/backend/tools/labels.py
@@ -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"),
diff --git a/backend/tools/schemas.py b/backend/tools/schemas.py
index a9a635c..e8a694a 100644
--- a/backend/tools/schemas.py
+++ b/backend/tools/schemas.py
@@ -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."""
diff --git a/backend/tools/spreadsheets.py b/backend/tools/spreadsheets.py
index f24db42..71e6688 100644
--- a/backend/tools/spreadsheets.py
+++ b/backend/tools/spreadsheets.py
@@ -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),
+ }
diff --git a/backend/xlsx_reader.py b/backend/xlsx_reader.py
index 5321c06..a5e87a9 100644
--- a/backend/xlsx_reader.py
+++ b/backend/xlsx_reader.py
@@ -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
@@ -65,8 +67,22 @@ LOSSY_PARTS: dict[str, tuple[str, ...]] = {
# Excel recalculates.
_CACHED_FORMULA_RE = re.compile(rb"][^<]*\s*[^<]")
-# 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, …).
@@ -111,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)
@@ -320,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]:
@@ -349,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:
@@ -358,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.
@@ -372,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:
@@ -781,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:
diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock
index 2bc55ea..30b5cf2 100644
--- a/desktop/Cargo.lock
+++ b/desktop/Cargo.lock
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
-version = "2.44.1"
+version = "2.45.0"
dependencies = [
"chrono",
"env_logger",
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index 5457c74..4e6ea9d 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
-version = "2.44.1"
+version = "2.45.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json
index 7f3b41e..f16e9f1 100644
--- a/desktop/tauri.conf.json
+++ b/desktop/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
- "version": "2.44.1",
+ "version": "2.45.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md
index d448ec1..816a1bc 100644
--- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md
+++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md
@@ -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,14 +214,35 @@ 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.
@@ -221,28 +250,65 @@ sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
**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 : insérer/supprimer
-une ligne ou une colonne, trier A→Z / Z→A, effacer le contenu.
+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,
-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.
+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
@@ -253,8 +319,13 @@ numériques) avec quelques chiffres clés. Cliquer une **plage nommée**
sélectionne sa première cellule dans la grille, et le panneau est
**redimensionnable**. L'en-tête de l'inspecteur offre
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
-plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
-`append_xlsx_rows`).
+plages. Ses outils couvrent désormais les **trois formats édités**
+(`.xlsx`, `.xlsm`, `.csv`) : `list_xlsx_sheets` et `xlsx_to_markdown` pour lire,
+`search_workbook` (recherche dans toutes les feuilles, comptée par feuille),
+`analyze_range` (agrégats — nombre, somme, moyenne, min, max — d'une plage A1),
+`update_xlsx_cells` et `append_xlsx_rows` pour modifier, et
+`edit_xlsx_structure` pour la structure (ajouter/renommer/dupliquer/supprimer
+une feuille, insérer/supprimer des lignes ou des colonnes).
### Limites
@@ -262,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.
---
diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md
index 3d523df..159536c 100644
--- a/docs/ISSUES_TODOLIST.md
+++ b/docs/ISSUES_TODOLIST.md
@@ -203,6 +203,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *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)
@@ -301,6 +305,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 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) |
---
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 4dbf44e..7dcea86 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
-> **Version :** 2.44.1 | **Dernière mise à jour :** 2026-09-29
+> **Version :** 2.45.0 | **Dernière mise à jour :** 2026-09-30
> **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)**
@@ -137,6 +137,55 @@
---
+## ✅ Terminé — Tableur Excel (complétude)
+
+### 156. Éditeur Excel — complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
+
+- **Effort :** 15-22 jours (P0 2-3 j ✅ · P1 4-6 j ✅ · P2 5-7 j ✅ · P3 4-6 j ✅) | **Impact :** 🟡
+- **Statut :** ✅ **livré le 2026-09-30** — **P0** (4 défauts **BUG-096 → BUG-099** corrigés),
+ **P1** (**A5-A7** : presse-papiers de plage, clavier complet, zone Nom éditable), **P2**
+ (**A8** mise en forme en écriture, **A9** décision « pas de moteur de formule, annoncée dans
+ l'UI », **A10** undo/redo unifié conservé au re-rendu) et **P3** (**A11** export sélection/
+ Markdown/HTML/impression + recherche multi-feuilles, **A12** concurrence optimiste `If-Match`,
+ **A13** cache des métadonnées, **A14** outils IA `.xlsm`/`.csv` + recherche/analyse/structure)
+- **Analyse, défauts, risques et critères d'acceptation :** [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md)
+- **Description :** #153 a rendu l'éditeur **correct** sur la grille de valeurs, #154/#155 l'ont
+ rendu **convivial** (ruban, inspecteur, undo/redo des cellules, sélection et menu contextuel
+ type Excel). Il reste l'écart avec un vrai éditeur tableur : coller une **plage**, écrire la
+ **mise en forme**, **calculer**, **sortir** le résultat, et détecter un écrivain concurrent.
+ La fiche porte l'audit ; les cases ci-dessous sont le **suivi de référence**.
+- **Défauts corrigés le 2026-09-29 (cf. [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md)) :**
+ - ✅ BUG-096 — enregistrer un `.csv` depuis la visionneuse levait un `TypeError`
+ (`sheet: sheets[…].name` alors qu'un CSV n'a pas de `xlsx_sheets`) : aucun `PUT …/csv/save`
+ n'était émis (reproduit en JSDOM, corrigé + test) ;
+ - ✅ BUG-097 — feuille `.xlsm` tronquée : « Charger la suite » échouait en **415** —
+ `GET …/xlsx/sheet` accepte désormais `.xlsx` **et** `.xlsm` ;
+ - ✅ BUG-098 — délimiteur CSV détecté (`;`/`,`/tabulation) et réutilisé à l'écriture :
+ un CSV français s'affiche en colonnes et le reste après édition ;
+ - ✅ BUG-099 — sonde de perte **par feuille** + signal `cached_values_unverified` quand le
+ budget est épuisé : plus de perte silencieuse possible des valeurs calculées.
+- **Sous-tâches :**
+ - **P0 — défauts (🔴, 2-3 j) — ✅ livré le 2026-09-29**
+ - [x] **A1** Sauvegarde `.csv` depuis la visionneuse — BUG-096 (test JSDOM : `PUT …/csv/save` émis)
+ - [x] **A2** Chargement paresseux des `.xlsm` — BUG-097 (`GET …/xlsx/sheet` accepte `.xlsx`/`.xlsm`)
+ - [x] **A3** Délimiteur CSV détecté et réutilisé à l'écriture — BUG-098 (`sniff_csv_delimiter`)
+ - [x] **A4** Sonde de perte par feuille + signal `cached_values_unverified` — BUG-099
+ - **P1 — presse-papiers & clavier (🟡, 4-6 j) — ✅ livré le 2026-09-29**
+ - [x] **A5** Presse-papiers de plage — copier/couper/coller un bloc TSV (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`, menu contextuel), presse-papiers interne + système, remplissage multi-cellules (tests JSDOM : `A1:B2` → `D5:E6`)
+ - [x] **A6** Clavier complet — `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` (saut de ligne en cellule)
+ - [x] **A7** Zone Nom éditable (« Atteindre » : `B12`, `A1:B3`, `Feuille!A1`) + aide à la saisie des fonctions
+ - **P2 — mise en forme, calcul, undo (🟡, 5-7 j) — ✅ livré le 2026-09-30**
+ - [x] **A8** Mise en forme en écriture — bouton **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur) via `PUT …/xlsx/style` (verrou, backup, garde de perte, `If-Match`)
+ - [x] **A9** Calcul — **décision documentée** : pas de moteur de formule, l'enregistrement annonce « enregistrée comme texte » (la garde anti-DDE reste)
+ - [x] **A10** Undo/redo unifié (cellules, effacement, tri/filtre, structure) et conservé au re-rendu (`_xlsxHistory` par fichier)
+ - **P3 — sortie, robustesse, performances (🟢, 4-6 j) — ✅ livré le 2026-09-30**
+ - [x] **A11** Sortie & recherche : export de la sélection / Markdown / HTML / impression, recherche sur toutes les feuilles (compteur `n/m · k feuilles`)
+ - [x] **A12** Concurrence optimiste inter-processus (`ETag`/`If-Match`, **409** réparable, bouton « Réessayer » qui relit)
+ - [x] **A13** Performances : cache des métadonnées par `(chemin, mtime, taille)` (LRU 8), invalidé à chaque écriture (axe des colonnes > `MAX_COLS` : hors périmètre)
+ - [x] **A14** Outils IA étendus — `.xlsm`/`.csv` acceptés, `search_workbook`, `analyze_range`, `edit_xlsx_structure`
+
+---
+
## ⚪ Backlog — Priorité 4 (P4)
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
@@ -187,6 +236,7 @@
| 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) |
@@ -268,6 +318,7 @@
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
| ✅ 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** |
---
@@ -278,6 +329,9 @@
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
- **Ajout 2026-09-29 :** #155 livré — menu contextuel (clic droit / appui long) et sélection type Excel sur la grille ([features/xlsx-context-menu.md](./features/xlsx-context-menu.md)) ; au passage **BUG-094** corrigé (feuille vide/nouvelle désormais éditable, quadrillage vierge 20×8).
+- **Clôture 2026-09-30 :** #156 **livré (P0 → P3)** — **A8** mise en forme en écriture (bouton **Mise en forme** : gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — via `PUT …/xlsx/style`), **A9** décision « pas de moteur de formule, annoncée dans l'UI », **A10** undo/redo unifié conservé au re-rendu, **A11** export de la sélection / Markdown / HTML / impression + recherche sur toutes les feuilles, **A12** concurrence optimiste (`If-Match`, **409** réparable), **A13** cache des métadonnées par `mtime`, **A14** outils IA `.xlsm`/`.csv` + `search_workbook`/`analyze_range`/`edit_xlsx_structure` — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
+- **Ajout 2026-09-29 :** #156 **P1 livré** — **A5** presse-papiers de plage (copier/couper/coller un bloc, presse-papiers interne + système, entrées du menu contextuel, remplissage multi-cellules), **A6** clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) et **A7** zone Nom éditable + aide à la saisie ; restent P2 (mise en forme, calcul, undo unifié) et P3 (sortie, concurrence optimiste, performances, outils IA).
+- **Ajout 2026-09-29 :** #156 ouvert — audit de complétude de l'éditeur Excel : **4 défauts recensés** (BUG-096 enregistrement `.csv`, BUG-097 lazy-load `.xlsm`, BUG-098 délimiteur CSV, BUG-099 sonde de perte), **corrigés le jour même (P0 ✅)** avec tests de non-régression ; restent P1-P3 (presse-papiers, clavier, mise en forme, calcul, export, concurrence optimiste) puis presse-papiers de plage, clavier complet, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
- **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.
diff --git a/docs/features/xlsx-editor-completeness.md b/docs/features/xlsx-editor-completeness.md
new file mode 100644
index 0000000..e45cdc8
--- /dev/null
+++ b/docs/features/xlsx-editor-completeness.md
@@ -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 (`