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. -[![Version](https://img.shields.io/badge/Version-2.44.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.45.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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. -[![Version](https://img.shields.io/badge/Version-2.44.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.45.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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 (``, + liste localisée FR/EN). L'autocomplétion contextuelle reste à faire si un moteur de formules + arrive (A9). + +**Mise en forme en écriture** — ✅ traitée (A8, 2026-09-30) + +- ✅ Un bouton **Mise en forme** (`#xlsx-format-btn`, classeurs éditables non-CSV) agit sur la + **sélection** via `PUT …/xlsx/style` : gras / italique / souligné / effacer, alignement + gauche-centré-droite, couleur de police et de fond (``, donc aucune + palette codée en dur), formats de nombre (général, nombre, pourcentage, devise, date, texte), + fusion/défusion, volets figés/libérés, largeur de colonne et hauteur de ligne. La fusion exige + une vraie plage (`xlsx.format_merge_needs_range`). +- Écriture sur le modèle de `xlsx/save` / `xlsx/structure` : verrou par fichier, backup, écriture + atomique (`.tmp` + `os.replace`), garde de perte (refus sans `force`), `If-Match` (A12), garde + anti-formule de BUG-088, plafond `MAX_STYLE_CELLS = 10 000`, invalidation du cache méta (A13). +- Toujours **hors périmètre** : commentaires, liens hypertexte, validation de données, mise en + forme conditionnelle, protection de cellules (lecture seule), bordures et palette Excel. + +**Calcul** — ✅ décision documentée (A9, 2026-09-30) + +- Aucun moteur de formule n'est livré : ObsiGate lit et écrit les formules telles qu'Excel les a + enregistrées, sans recalcul (pas de références inter-feuilles, pas d'agrégats). Le choix est + **annoncé à l'utilisateur** : un enregistrement contenant une valeur commençant par `=` ou `@` + affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé ; la garde + anti-DDE de BUG-088 reste en place. + +**Sortie et recherche** — ✅ traité (A11, 2026-09-30) + +- ✅ Export de la **sélection** (CSV, nom `Feuille-A1B2.csv`) quand une plage de plusieurs cellules + est active, sinon la feuille entière ; l'écart doc/code de #153-A13 est tranché dans ce sens. +- ✅ Menu **Exporter** : Markdown (tableau), HTML (document autonome) et **impression** (iframe + hors écran, cadre précédent détruit). Les quatre formats suivent le **contenu affiché**. +- ✅ La recherche (`Ctrl+F`) parcourt **toutes les feuilles** : compteur `n/m · k feuilles` et + activation automatique de l'onglet contenant la correspondance. + +**Robustesse / multi-postes** — ✅ traitée (A10, A12, 2026-09-30) + +- ✅ Concurrence optimiste : `revision` (`mtime_ns` + taille) renvoyée à la lecture et acceptée en + `If-Match` sur `xlsx/save`, `xlsx/structure`, `xlsx/style` et `csv/save` ; un écrivain externe + détecté donne un **409** `conflict` (`details.reason = "stale_revision"`) au lieu d'un + écrasement, et le bandeau « Réessayer » **relit** le fichier avant de rejouer. +- ✅ Piles undo/redo **unifiées et conservées au re-rendu** (`_xlsxHistory` par fichier) : édition, + effacement, tri/filtre et structure ; action destructive (suppression) non annulable faute + d'inverse, et lecture du filtre **coalescée** (une entrée par session de frappe). +- Le tri/filtre restent **d'affichage** : après un tri, un enregistrement ne persiste pas l'ordre + (seules les cellules sales sont écrites) — le libellé `xlsx.sort_applied` le dit. + +**Outils IA** — ✅ traités (A14, 2026-09-30) + +- ✅ `_spreadsheet_path()` accepte `.xlsx`, `.xlsm` (macros préservées) et `.csv` (délimiteur + conservé) ; `update_xlsx_cells` délègue à `save_csv_cells` pour un CSV. +- ✅ Nouveaux outils : `search_workbook` (recherche multi-feuilles, comptée par feuille), + `analyze_range` (nombre, somme, moyenne, min, max d'une plage A1), `edit_xlsx_structure` + (feuilles, lignes, colonnes — risque WRITE, **confirmation conservée**). +- Bornes explicites : `MAX_SCAN_ROWS/MAX_SCAN_COLS`, `MAX_SEARCH_RESULTS = 100`, + `MAX_RANGE_VALUES = 200`, `MAX_RANGE_CELLS = 10 000`. + +### 3.3 Limites techniques relevées + +- `MAX_COLS = 40` sans pagination de colonnes : le chargement paresseux ne concerne que les + lignes (`read_sheet_window`), l'axe des colonnes reste tronqué. +- Coût de lecture : `render_sheets()` charge le classeur pour les formules, une seconde fois si + des valeurs cachées existent, puis `read_workbook_meta()` (troisième chargement, scan + `500 × 40` par feuille). ✅ **A13** : `read_workbook_meta()` est mémoïsé par + `(chemin, mtime_ns, taille)` (LRU 8) et invalidé à chaque écriture — le reload complet à chaque + fenêtre demandée a disparu ; `render_sheets()` reste hors cache (il doit relire les valeurs). +- `allow_formula` (bouton `f(x)`) reste actif pour toute la session une fois activé : rien ne le + remet à zéro après un enregistrement. +- `role="grid"` posé (#154-A4) mais sans `aria-rowcount`/`aria-colcount`/`aria-selected`/ + `aria-activedescendant` ni annonce des changements de cellule active. + +## 4. Backlog #156 — sous-tâches + +Légende : 🔴 P0 (défaut) · 🟡 P1 (productivité immédiate) · 🟢 P2 (fidélité/finitions) · +effort en jours-homme (développement + tests). + +### P0 — défauts (2-3 j) + +- [x] **A1 — Sauvegarde `.csv` depuis la visionneuse (BUG-096).** Nom de feuille résolu avec + repli (feuille → `data.title`/`data.path`), le chemin d'enregistrement ne dépend plus de la + structure du classeur. *Vérifié :* test JSDOM « saving an edited csv PUTs /csv/save + (payload without xlsx_sheets) » (contre-preuve : échouait avec le `TypeError`). +- [x] **A2 — Chargement paresseux des `.xlsm` (BUG-097).** `GET …/xlsx/sheet` accepte `.xlsx` + **et** `.xlsm` ; les autres formats restent refusés (415). *Vérifié :* + `test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats`. +- [x] **A3 — Détection du délimiteur CSV (BUG-098).** `sniff_csv_delimiter()` partagé par la + lecture et la réécriture, qui conserve le délimiteur détecté. *Vérifié :* `TestCsvDelimiter` + (3 séparateurs + repli) et 3 tests client (lecture, écriture, guillemets). +- [x] **A4 — Sonde de perte par feuille (BUG-099).** Budget par feuille + plafond global ; + budget épuisé → clé `cached_values_unverified` (i18n FR/EN) au lieu d'un silence. *Vérifié :* + `TestXlsxCachedValueProbe` (3), dont la contre-preuve du silence antérieur. + +### P1 — presse-papiers & clavier (4-6 j)- [x] **A5 — Presse-papiers de plage (livré le 2026-09-29).** Copier / couper / coller un bloc + (presse-papiers interne TSV, miroir système best-effort) via `Ctrl+C`/`Ctrl+X`/`Ctrl+V` et les + entrées du menu contextuel ; remplissage multi-cellules depuis la cellule active, bloc laissé + sélectionné, collage **texte** uniquement (R4) et annulable cellule par cellule ; un bloc plus + grand que la grille rendue est tronqué et signalé (`xlsx.paste_out_of_grid`). Couper efface la + source au collage, sauf en collage sur place. + *Vérifié :* JSDOM — `A1:B2` copié puis collé en `D5` donne `D5:E6`, bloc TSV Excel + retour + ligne final, 1×1 qui remplace la cellule, coupe → collage, menu contextuel, débordement + signalé, `E2E` (Playwright : copie/collage par le menu contextuel sur la fixture 520 lignes). +- [x] **A6 — Clavier complet (livré le 2026-09-29).** `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`, + `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` ; undo/redo déplacés + dans le même handler priorisé et `Suppr` passe par `clearRange()` (donc **annulable**). + *Vérifié :* JSDOM — un test par raccourci, dont la contre-épreuve « `Suppr` pendant la saisie + supprime un caractère, pas la sélection » ; `E2E` (Ctrl+A, Ctrl+Home/End, Suppr). +- [x] **A7 — Zone Nom éditable & aide à la saisie (livré le 2026-09-29).** La zone Nom est un + `` (`#xlsx-active-cell`) : `B12`, `A1:B3`, `$A$1`, `Feuille!A1` (changement d'onglet) ; + référence inconnue → message `xlsx.name_box_invalid` et adresse précédente restaurée. Le champ + de formule porte la liste des fonctions (``, `xlsx.fn_suggestions`). + *Vérifié :* JSDOM (atteindre, plage normalisée, refus d'une référence inconnue, bascule + d'onglet, liste de fonctions) ; `E2E` (zone Nom → `B12`). + +### P2 — mise en forme, calcul, undo (5-7 j) + +- [x] **A8 — Mise en forme en écriture (livré le 2026-09-30).** Bouton **Mise en forme** : + gras/italique/souligné/effacer, alignements, couleurs de police et de fond (sélecteur natif), + formats de nombre (général/nombre/pourcentage/devise/date/texte), fusion/défusion, volets + figés/libérés, largeur de colonne et hauteur de ligne, sur la sélection courante. Nouvelle route + `PUT …/xlsx/style` (`mutate_xlsx_style`) sur le modèle de `PUT …/xlsx/structure` : verrou, + `.tmp`/`os.replace`, backup, garde de perte (`force`), `If-Match`, garde anti-formule BUG-088, + plafond `MAX_STYLE_CELLS`. i18n FR/EN. *Vérifié :* `tests/test_xlsx_styles.py` (17 : rechargement, + fusions/tailles/volets, effacement, entrées invalides, CSV refusé, `if_match`, backup) et JSDOM + (application d'un format, refus d'un CSV, garde de perte). +- [x] **A9 — Calcul : décision documentée (livré le 2026-09-30).** Pas de moteur de formule ; le + gap est **annoncé dans l'UI** (toast « enregistrée comme texte » à l'enregistrement si une valeur + commence par `=` ou `@` sans le bouton `f(x)`), la garde anti-DDE reste. *Vérifié :* JSDOM + (l'avertissement apparaît, disparaît une fois `f(x)` activé). +- [x] **A10 — Undo/redo unifié (livré le 2026-09-30).** Entrées typées `cell|order|hidden|structure`, + piles **par fichier** (`_xlsxHistory`, 8 fichiers) conservées au re-rendu, filtre coalescé, + rejeu de structure silencieux, action destructive non annulable. *Vérifié :* JSDOM (édition → + effacement → tri annulés successivement, insertion de ligne rejouée, filtre annulé en un `Ctrl+Z`). + +### P3 — sortie, robustesse, performances (4-6 j) + +- [x] **A11 — Sortie & recherche (livré le 2026-09-30).** Bouton **Exporter** : CSV de la + **sélection** (sinon de la feuille, nom `Feuille-A1B2.csv`), Markdown, HTML et impression + (iframe hors écran) ; écart #153-A13 tranché. `runFind` parcourt **toutes** les feuilles avec + compteur `n/m · k feuilles` et activation de l'onglet correspondant. *Vérifié :* JSDOM + (sélection multi-cellules, feuille entière, 4 formats, compteur multi-feuilles) et E2E. +- [x] **A12 — Concurrence optimiste (livré le 2026-09-30).** `revision` (`mtime_ns` + taille) + renvoyée à la lecture (`xlsx_revision`) et acceptée en `If-Match`/`if_match` sur `xlsx/save`, + `xlsx/structure`, `xlsx/style` et `csv/save` ; unconflict produit un **409** `conflict` + (`details.reason = "stale_revision"`) et le bandeau « Réessayer » relit avant de rejouer. + *Vérifié :* `TestOptimisticConcurrency` (6) + JSDOM (version rafraîchie par la réponse, relecture + avant le retry). +- [x] **A13 — Performances & plafonds (livré le 2026-09-30).** Cache de `read_workbook_meta()` par + `(chemin, mtime_ns, taille)` (LRU 8), invalidé à chaque écriture : charger une fenêtre ne + rescanne plus le classeur. *Hors périmètre :* la pagination de l'axe des colonnes au-delà de + `MAX_COLS = 40` (documenté dans les limites du guide). *Vérifié :* `TestMetaCache` (4). +- [x] **A14 — Outils IA étendus (livré le 2026-09-30).** `SPREADSHEET_EXTENSIONS` = + `.xlsx`/`.xlsm`/`.csv` (`_spreadsheet_path`, `_read_grid`), `update_xlsx_cells`/`append_xlsx_rows` + adaptés, nouveaux outils `search_workbook` (READ), `analyze_range` (READ) et `edit_xlsx_structure` + (WRITE, **confirmation conservée**, refus d'un CSV). *Vérifié :* + `tests/test_spreadsheet_tools.py` (34, dont `TestXlsmSupport`, `TestCsvSupport`, + `TestSearchWorkbook`, `TestAnalyzeRange`, `TestEditXlsxStructure`). + +## 5. Risques et sécurité + +| # | Risque | Où | Traitement | +|---|---|---|---| +| R1 | Perte silencieuse de valeurs calculées (sonde plafonnée) | `xlsx_reader.inspect_workbook` | ✅ A4 — budget par feuille + clé `cached_values_unverified` (BUG-099) | +| R2 | Écrasement par un écrivain externe | `mutations`, routes write | ✅ A12 — `If-Match` + **409** `conflict`, retry qui relit | +| R3 | Réécriture CSV dans un délimiteur différent de la source | `xlsx_reader`, `mutations.save_csv_cells` | ✅ A3 — `sniff_csv_delimiter` partagé (BUG-098) | +| R4 | Collage d'un bloc : ne jamais injecter de HTML/`contenteditable` brut | `viewer.js` | ✅ A5 — insertion **texte** uniquement, échappée | +| R5 | Mise en forme en écriture : garder la garde anti-formule (BUG-088) et la garde de perte (BUG-085) | `mutations` | ✅ A8 — `mutate_xlsx_style` réutilise verrou, backup, swap atomique, garde de perte et `If-Match` | + +## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`) + +- Chaque sous-tâche démarre par son **ID stable** (`#156-A`) ; un **défaut** est ouvert comme + `BUG-NNN` dans `docs/ISSUES_TODOLIST.md` (A1→BUG-096, A2→BUG-097, A3→BUG-098, A4→BUG-099). +- Tout correctif arrive avec son **test de non-régression** (contre-preuve quand c'est possible). +- Frontend : `vanilla JS`, **zéro build**, `safeCreateIcons()`, **variables CSS**, **i18n FR+EN** + (`test_i18n_parity.py` vert). +- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans + `backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`. +- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée + index), cette fiche, + guide utilisateur i18n + README si impact utilisateur. + +## 7. Historique + +| Date | Événement | +|---|---| +| 2026-09-29 | Audit de complétude de l'éditeur Excel → ouverture de #156 et de **BUG-096 → BUG-099** ; défaut A1 reproduit en JSDOM (`TypeError` sur l'enregistrement d'un CSV, aucun `PUT …/csv/save` émis) | +| 2026-09-29 | **P1 livré** — A5 (presse-papiers de plage), A6 (clavier complet) et A7 (zone Nom éditable + aide à la saisie) : presse-papiers interne TSV (miroir système best-effort), collage texte/annulable, `Suppr`/`F2`/`Ctrl+flèches`/`PgUp-PgDn`/`Ctrl+A`/`Maj+Entrée`, zone Nom `` et liste de fonctions i18n FR/EN. Tests : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux) + E2E Playwright | +| 2026-09-30 | **P2 livré** — A8 (mise en forme en écriture : bouton Mise en forme, `PUT …/xlsx/style`, verrou/backup/garde de perte/`If-Match`), A9 (décision « pas de moteur de formule », annoncée dans l'UI), A10 (undo/redo unifié, piles conservées au re-rendu). Tests : `test_xlsx_styles.py` 17 + JSDOM `xlsx-viewer.test.mjs` | +| 2026-09-30 | **P3 livré** — A11 (export sélection / Markdown / HTML / impression, recherche multi-feuilles), A12 (`If-Match` + 409 réparable), A13 (cache des métadonnées par `mtime`), A14 (outils IA `.xlsm`/`.csv`, recherche, analyse de plage, structure). Tests : `test_spreadsheet_tools.py` 34, `test_xlsx_viewer.py` (`TestOptimisticConcurrency`, `TestMetaCache`), JSDOM | +| 2026-09-30 | **Backlog #156 clôturé** — P0 → P3 livrés (A1 → A14, dont 4 défauts **BUG-096 → BUG-099**). Suite complète : `pytest tests/` 1525 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 107/107, E2E `xlsx` 10/10 | +| 2026-09-29 | **P0 livré** — A1-A4 corrigés et testés (BUG-096 → BUG-099) : sauvegarde `.csv` depuis la visionneuse, fenêtres `.xlsm`, délimiteur CSV détecté/réutilisé, sonde de perte par feuille + signal `cached_values_unverified`. Suite complète : 1490 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 61/61 | diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 33e3baa..2e19ef8 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -580,6 +580,31 @@ function buildThumbUrl(vault, path, size) { return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`; } +// #156-A10 — per-file undo/redo history. The stacks must outlive a re-render +// (a structure save rebuilds the whole viewer) without leaking across files, so +// they live in a small bounded module-level store keyed by vault + path. +const _xlsxHistory = new Map(); +const XLSX_HISTORY_MAX_FILES = 8; + +function xlsxHistory(key) { + let entry = _xlsxHistory.get(key); + if (!entry) { + entry = { undo: [], redo: [] }; + _xlsxHistory.set(key, entry); + } + if (_xlsxHistory.size > XLSX_HISTORY_MAX_FILES) { + for (const k of _xlsxHistory.keys()) { + if (k !== key) { _xlsxHistory.delete(k); break; } + } + } + return entry; +} + +/** Clear the per-file spreadsheet undo/redo history (tests, file close). */ +export function resetXlsxHistory() { + _xlsxHistory.clear(); +} + // #111 — directory listing cache. Opening / switching to another image must not // hammer `/api/browse` (the filmstrip and the sibling list share one listing). const _imageDirCache = new Map(); @@ -1052,11 +1077,17 @@ export function renderXlsxViewer(area, data) { // per workbook, not a global preference). let lossyConfirmed = false; let allowFormula = false; + // #156-A12 — revision of the file as last read or written. It rides every + // write as `if_match`: a write on a file that changed elsewhere is refused + // with 409 `conflict` instead of silently overwriting the other writer. + let revision = data.xlsx_revision || null; // #154-A1 — the command bar is grouped (Formules · Insertion · Vue · Fichier) // and the sheet tabs are ALWAYS rendered (a single sheet used to show no tab // at all, hiding the fact that a workbook can hold several). An editable // workbook also gets an explicit “+” button next to the tabs. const editable = !isCsv && !readOnly; + // #156-A10 — the undo/redo stacks are kept per file (module-level store). + const historyKey = `${data.vault || ""}\u0000${data.path || ""}`; // #154-A5 — the shell markup (tabs + grouped command bar + status pills) is // built by frontend/js/xlsx/command-bar.js. const { tabs, actionsHtml, statusBar } = buildCommandBar({ sheets, isCsv, readOnly, editable }); @@ -1158,10 +1189,14 @@ export function renderXlsxViewer(area, data) { ${lossWarning}
- A1 + + spellcheck="false" list="xlsx-function-list" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" /> + @@ -1308,8 +1343,16 @@ export function renderXlsxViewer(area, data) { ${escapeHtml(msg)} `; - banner.querySelector(".xlsx-banner-retry").addEventListener("click", () => { + // #156-A12 — the file changed elsewhere: re-read it first so the retry + // carries the CURRENT revision instead of failing on the same stale token. + banner.querySelector(".xlsx-banner-retry").addEventListener("click", async () => { bannerHost.innerHTML = ""; + try { + const fresh = await api( + `/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, + ); + if (fresh && fresh.xlsx_revision) revision = fresh.xlsx_revision; + } catch (_) { /* keep the previous token: the retry is refused again */ } saveBtn.click(); }); banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => { @@ -1465,7 +1508,7 @@ export function renderXlsxViewer(area, data) { if (td) td.classList.add("xlsx-selected"); } } - activeCellEl.textContent = rangeLabel(); + activeCellEl.value = rangeLabel(); }; const setSelection = (anchorTd, focusTd = anchorTd) => { @@ -1489,14 +1532,14 @@ export function renderXlsxViewer(area, data) { activeTd = td || null; if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active"); if (!td) { - activeCellEl.textContent = "—"; + activeCellEl.value = ""; formulaInput.value = ""; formulaInput.disabled = true; setSelection(null); return; } td.classList.add("xlsx-active"); - activeCellEl.textContent = cellName(td); + activeCellEl.value = cellName(td); formulaInput.disabled = false; formulaInput.value = td.textContent; setSelection(td); @@ -1580,35 +1623,59 @@ export function renderXlsxViewer(area, data) { } }); - // ── #154-A4 — local undo/redo for cell edits ────────────────────────── - // One command per committed edit (a cell loses focus with a new value). - // Structure changes re-render from the server, which rebuilds the whole - // viewer and therefore clears the stacks (they are per-render). - const undoStack = []; - const redoStack = []; + // ── #156-A10 — undo/redo unifié ────────────────────────── + // One stack for everything the viewer can reverse: a cell edit (typed, + // pasted, cleared), a row sort, and the REVERSIBLE structure actions + // (insert, rename, duplicate, add sheet — a delete is not replayable, the + // `.bak` stays the safety net). let pendingEdit = null; const undoBtn = area.querySelector("#xlsx-undo-btn"); const redoBtn = area.querySelector("#xlsx-redo-btn"); + const fileHistory = xlsxHistory(historyKey); + const undoStack = fileHistory.undo; + const redoStack = fileHistory.redo; const updateUndoButtons = () => { if (undoBtn) undoBtn.disabled = undoStack.length === 0; if (redoBtn) redoBtn.disabled = redoStack.length === 0; }; + const pushHistory = (entry) => { + undoStack.push(entry); + redoStack.length = 0; + updateUndoButtons(); + }; + /** Sheet identity of a panel: its name when known (a CSV has none). */ + const panelKey = (panel) => { + const idx = Number(panel?.dataset.sheet); + return sheets[idx]?.name || String(Number.isFinite(idx) ? idx : 0); + }; + /** Reverse lookup: a panel by sheet name, then by rendered index. */ + const panelForKey = (key) => { + const byName = sheets.findIndex((s) => s.name === key); + if (byName >= 0) return panelEls[byName]; + return panelEls.find((p) => p.dataset.sheet === String(key)) || null; + }; + const rowNumOf = (tr) => Number(tr.querySelector("th.xlsx-rownum")?.textContent); + const rowNums = (list) => list.map(rowNumOf); + + // One history entry per changed cell: shared by the focus/blur edit cycle + // and by the range operations (paste, cut, clear) so they can all be undone. + const recordEdit = (td, before, after) => { + if (before === after) return; + pushHistory({ kind: "cell", sheet: panelKey(td.closest(".xlsx-panel")), ref: td.dataset.cell, before, after }); + }; const commitEdit = () => { if (!pendingEdit) return; const { td, before } = pendingEdit; pendingEdit = null; - const after = td.textContent; - if (before === after) return; - undoStack.push({ sheet: td.closest(".xlsx-panel")?.dataset.sheet, ref: td.dataset.cell, before, after }); - redoStack.length = 0; - updateUndoButtons(); + recordEdit(td, before, td.textContent); }; const beginEdit = (td) => { if (pendingEdit && pendingEdit.td !== td) commitEdit(); if (!pendingEdit) pendingEdit = { td, before: td.textContent }; }; - const applyHistory = (entry, value) => { - const panel = area.querySelector(`.xlsx-panel[data-sheet="${entry.sheet}"]`) || panelEls[0]; + + const applyCellHistory = (entry, value) => { + const panel = panelForKey(entry.sheet) || panelEls[0]; const td = panel?.querySelector(`td[data-cell="${entry.ref}"]`); if (!td) return; td.textContent = value; @@ -1616,28 +1683,68 @@ export function renderXlsxViewer(area, data) { if (td === activeTd) syncFormulaBar(td); refreshSaveState(); }; + + /** Replay a recorded row order (undo/redo of a sort). */ + const applyRowOrder = (entry, order) => { + const tbody = panelForKey(entry.sheet)?.querySelector(".xlsx-table tbody"); + if (!tbody || !order) return; + const byNum = new Map([...tbody.querySelectorAll("tr")].map((tr) => [rowNumOf(tr), tr])); + if (order.length !== byNum.size || order.some((n) => !byNum.has(n))) return; + order.forEach((n) => tbody.appendChild(byNum.get(n))); + }; + + /** Replay a recorded filter visibility (and its needle). */ + const applyRowVisibility = (entry, side) => { + const panel = panelForKey(entry.sheet); + const hidden = new Set(entry[side]); + if (panel) { + panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { + tr.style.display = hidden.has(rowNumOf(tr)) ? "none" : ""; + }); + } + findInput.value = entry[`${side}Needle`] || ""; + }; + + /** Replay a structure batch through the same endpoint as the UI. */ + const applyStructureHistory = async (entry, side) => { + const actions = entry[side]; + if (!actions?.length) return; + try { + await putStructure(actions, false, { quiet: true, record: false }); + } catch (err) { + await structureError(err, actions); + } + }; + + const applyEntry = (entry, side) => { + if (entry.kind === "cell") return applyCellHistory(entry, entry[side]); + if (entry.kind === "order") return applyRowOrder(entry, entry[side]); + if (entry.kind === "hidden") return applyRowVisibility(entry, side); + if (entry.kind === "structure") return applyStructureHistory(entry, side); + return undefined; + }; const undo = () => { const entry = undoStack.pop(); if (!entry) return; - applyHistory(entry, entry.before); + applyEntry(entry, "before"); redoStack.push(entry); updateUndoButtons(); }; const redo = () => { const entry = redoStack.pop(); if (!entry) return; - applyHistory(entry, entry.after); + applyEntry(entry, "after"); undoStack.push(entry); updateUndoButtons(); }; if (undoBtn) undoBtn.addEventListener("click", undo); if (redoBtn) redoBtn.addEventListener("click", redo); - area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => { - if (!(e.ctrlKey || e.metaKey)) return; - const key = e.key.toLowerCase(); - if (key === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); } - else if (key === "y") { e.preventDefault(); redo(); } - }); + // The command bar always renders the buttons disabled: re-enable them when + // the history survived a re-render. + updateUndoButtons(); + // #156-A6 — undo/redo, save, clipboard and grid navigation all live in the + // grid-level keydown handler registered below (one place, one priority + // order); the cell handler only keeps the in-cell keys. const setupCell = (td) => { td.contentEditable = "true"; @@ -1648,33 +1755,62 @@ export function renderXlsxViewer(area, data) { td.tabIndex = 0; td.dataset.orig = td.textContent; td.addEventListener("input", () => { + // #156-A6 — typing puts the cell in “edit” mode: Delete then deletes a + // character instead of clearing the whole selection (spreadsheet rule). + td.dataset.editing = "1"; td.classList.add("xlsx-dirty"); syncFormulaBar(td); refreshSaveState(); }); td.addEventListener("focus", () => { + td.dataset.editing = ""; beginEdit(td); // #155 — a range extension keeps the selection; only the bar follows. if (suppressSelectionReset) { activeTd = td; - activeCellEl.textContent = cellName(td); + activeCellEl.value = cellName(td); formulaInput.disabled = false; formulaInput.value = td.textContent; return; } setActiveCell(td); }); - td.addEventListener("blur", commitEdit); + td.addEventListener("blur", () => { commitEdit(); td.dataset.editing = ""; }); td.addEventListener("keydown", (e) => { + const mod = e.ctrlKey || e.metaKey; + // #156-A6 — keys moved to the grid handler when combined with Ctrl + // (Ctrl+flèches = jump to the edge of a data run, Ctrl+Home/End…). + const move = e.key === "Tab" || e.key.startsWith("Arrow"); + if (move && mod) return; + if (e.key === "Enter" && e.shiftKey) { + // #156-A6 — Maj+Entrée: line break inside the cell (plain text only). + e.preventDefault(); + insertTextAtCaret(td, "\n"); + return; + } + if (e.key === "F2") { e.preventDefault(); startEditing(td); return; } + if (e.key === "Delete" && !mod && !isTypingInCell(td)) { + // #156-A6 — Suppr clears the selection when the cell is not edited. + e.preventDefault(); + const ref = parseRef(cellName(td)); + if (!ref) return; + const panel = td.closest(".xlsx-panel"); + const b = selection && selection.panel === panel + ? selectionBounds() + : { r1: ref.row, r2: ref.row, c1: ref.col, c2: ref.col }; + clearRange(panel, b); + return; + } + if (e.key.length === 1 && !mod) td.dataset.editing = "1"; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); } if (e.key === "Escape") { td.textContent = td.dataset.orig; td.classList.remove("xlsx-dirty"); + td.dataset.editing = ""; syncFormulaBar(td); refreshSaveState(); } - if (e.key === "Tab" || e.key === "ArrowUp" || e.key === "ArrowDown" - || e.key === "ArrowLeft" || e.key === "ArrowRight") { + if (move) { // Excel-like: arrows and Tab move to the neighbour cell. Direction is // the key's own (Left/Up = back, Right/Down = forward); Tab follows // Shift. Prevented so Tab never leaves the table and arrows never @@ -1687,9 +1823,13 @@ export function renderXlsxViewer(area, data) { } }); td.addEventListener("paste", (e) => { + // #156-A5 — a multi-cell block fills a range starting at this cell; a + // single value replaces the cell (or is inserted at the caret when the + // user has a live text selection). Plain text only, never HTML (R4). + const payload = clipTextOf(e) || (rangeClipboard ? toTsv(rangeClipboard.rows) : ""); + if (!payload) return; e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " "); - document.execCommand("insertText", false, text); + applyPaste(payload, td); }); }; @@ -1730,34 +1870,59 @@ export function renderXlsxViewer(area, data) { formulaBtn.classList.toggle("active", allowFormula); }); + /** Remember the revision a write returned (chained writes stay in sync). */ + const keepRevision = (res) => { + if (res && typeof res.revision === "string" && res.revision) revision = res.revision; + return res; + }; + const putSheet = isCsv - ? (job, force) => api( + ? async (job, force) => keepRevision(await api( `/api/file/${encodeURIComponent(data.vault)}/csv/save?path=${encodeURIComponent(data.path)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cells: job.cells }), + body: JSON.stringify({ cells: job.cells, if_match: revision }), }, - ) - : (job, force) => api( + )) + : async (job, force) => keepRevision(await api( `/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...job, allow_formula: allowFormula, force }), + body: JSON.stringify({ ...job, allow_formula: allowFormula, force, if_match: revision }), }, - ); + )); saveBtn.addEventListener("click", async () => { // One PUT per sheet (dirty cells can span tabs before a save). + // BUG-096 — a CSV is served as a single `html` table, with no + // `xlsx_sheets`: reading the sheet name from `sheets[…]` threw a + // TypeError before any request went out, so a CSV edit could never be + // saved. Fall back to the file name (the CSV endpoint ignores it). + const sheetNameOf = (panel) => { + const idx = Number(panel.dataset.sheet); + return sheets[idx]?.name || sheets[0]?.name || data.title || data.path || ""; + }; 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 }; + return { sheet: sheetNameOf(panel), cells }; }) .filter((job) => Object.keys(job.cells).length); if (!jobs.length) return; + // #156-A9 — the viewer has no formula engine: a value starting with "=" or + // "@" is stored as TEXT unless the f(x) toggle is on. Say so at save time + // instead of letting the user believe a formula was written (the workbook + // keeps Excel's own computed values as the source of truth). + if (!allowFormula && !isCsv) { + const asText = jobs.reduce( + (n, job) => n + Object.values(job.cells).filter((v) => typeof v === "string" && /^[=@]/.test(v.trim())).length, + 0, + ); + if (asText) showToast(t("xlsx.formula_stored_as_text", { n: asText }), "info"); + } saveBtn.disabled = true; try { for (const job of jobs) { @@ -1827,6 +1992,7 @@ export function renderXlsxViewer(area, data) { const tbody = panel.querySelector(".xlsx-table tbody"); if (!tbody) return; const rows = [...tbody.querySelectorAll("tr")]; + const orderBefore = rowNums(rows); const numeric = rows.every((tr) => { const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`); const v = td ? td.textContent.trim() : ""; @@ -1845,6 +2011,11 @@ export function renderXlsxViewer(area, data) { rows.forEach((tr) => tbody.appendChild(tr)); sortState = { col, dir }; sortResetBtn.style.display = ""; + // #156-A10 — the view change is undoable (the workbook stays untouched). + const orderAfter = rowNums(rows); + if (orderBefore.join() !== orderAfter.join()) { + pushHistory({ kind: "order", sheet: panelKey(panel), before: orderBefore, after: orderAfter }); + } showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info"); }; @@ -1883,14 +2054,37 @@ export function renderXlsxViewer(area, data) { }); }; - // Filter: rows whose cell text lacks the needle are hidden. + const hiddenRowsOf = (panel) => + [...panel.querySelectorAll(".xlsx-table tbody tr")] + .filter((tr) => tr.style.display === "none") + .map(rowNumOf); + + // Filter: rows whose cell text lacks the needle are hidden. Recorded as ONE + // coalesced undo entry per typing session (#156-A10), so Ctrl+Z clears the + // whole filter instead of replaying it keystroke by keystroke. const applyFilter = (panel, needle) => { + const before = hiddenRowsOf(panel); + const beforeNeedle = panel.dataset.filterNeedle || ""; const n = needle.trim().toLowerCase(); panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { const text = tr.textContent.toLowerCase(); tr.style.display = !n || text.includes(n) ? "" : "none"; }); if (n) sortResetBtn.style.display = ""; + panel.dataset.filterNeedle = needle; + if (panel !== visiblePanel()) return; // one entry for the sheet on screen + const after = hiddenRowsOf(panel); + const sheet = panelKey(panel); + const top = undoStack[undoStack.length - 1]; + if (top && top.kind === "hidden" && top.sheet === sheet) { + top.after = after; + top.afterNeedle = needle; + redoStack.length = 0; + updateUndoButtons(); + return; + } + if (!before.length && !after.length) return; // nothing hidden yet + pushHistory({ kind: "hidden", sheet, before, after, beforeNeedle, afterNeedle: needle }); }; panelEls.forEach(wireHeaderSelection); @@ -1919,44 +2113,64 @@ export function renderXlsxViewer(area, data) { findCount.textContent = ""; }; + // #156-A11 — the find scans EVERY sheet, not just the visible one: the + // counter adds the number of sheets holding a hit and stepping onto a match + // brings its tab forward (the match would otherwise stay invisible). const runFind = () => { clearFind(); const needle = findInput.value; if (!needle.trim()) return; - const panel = visiblePanel(); const hay = findCase ? (s) => s : (s) => s.toLowerCase(); const n = hay(needle); - panel.querySelectorAll(".xlsx-table td").forEach((td) => { - const text = td.textContent; - const pos = hay(text).indexOf(n); - if (pos === -1 || !text) return; - // Wrap the match in a by splitting the cell's first text node at - // the hit (cells are text-first; the cached-value span stays untouched). - const first = td.firstChild; - if (first && first.nodeType === 3) { - const mark = document.createElement("mark"); - mark.className = "xlsx-find-hit"; - mark.textContent = text.substr(pos, needle.length); - const tail = first.splitText(pos); - tail.splitText(needle.length); - td.replaceChild(mark, tail); - findMatches.push({ td, mark }); - } + panelEls.forEach((panel) => { + panel.querySelectorAll(".xlsx-table td").forEach((td) => { + const text = td.textContent; + const pos = hay(text).indexOf(n); + if (pos === -1 || !text) return; + // Wrap the match in a by splitting the cell's first text node at + // the hit (cells are text-first; the cached-value span stays untouched). + const first = td.firstChild; + if (first && first.nodeType === 3) { + const mark = document.createElement("mark"); + mark.className = "xlsx-find-hit"; + mark.textContent = text.substr(pos, needle.length); + const tail = first.splitText(pos); + tail.splitText(needle.length); + td.replaceChild(mark, tail); + findMatches.push({ td, mark, panel }); + } + }); }); - findCount.textContent = findMatches.length - ? t("xlsx.find_count", { index: 1, count: findMatches.length }) - : t("xlsx.find_no_match"); - if (findMatches.length) focusMatch(0); + if (!findMatches.length) { + findCount.textContent = t("xlsx.find_no_match"); + return; + } + focusMatch(0); + }; + + /** Show the find counter, mentioning the sheets when hits span several. */ + const updateFindCount = (index) => { + const count = findMatches.length; + const sheets = new Set(findMatches.map((m) => m.panel)).size; + findCount.textContent = sheets > 1 + ? t("xlsx.find_count_sheets", { index, count, sheets }) + : t("xlsx.find_count", { index, count }); }; const focusMatch = (i) => { findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current")); findIndex = (i + findMatches.length) % findMatches.length; - const { td, mark } = findMatches[findIndex]; + const { td, mark, panel } = findMatches[findIndex]; mark.classList.add("xlsx-find-current"); + // A hit in another sheet: activate its tab so the match becomes visible. + if (panel.style.display === "none") { + const idx = panel.dataset.sheet; + area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x.dataset.sheet === idx)); + panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; }); + } // scrollIntoView is missing in JSDOM; guard it (real browsers have it). if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" }); - findCount.textContent = t("xlsx.find_count", { index: findIndex + 1, count: findMatches.length }); + updateFindCount(findIndex + 1); }; // One input drives both the highlight (find) and the row filter (A13): @@ -1984,21 +2198,51 @@ export function renderXlsxViewer(area, data) { const visibleSheetIndex = () => Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0; - const putStructure = async (actions, force = false) => { + /** Inverse of a structure action, or null when it cannot be replayed. */ + const inverseAction = (action) => { + switch (action.op) { + case "row_insert": + return { op: "row_delete", sheet: action.sheet, at: action.at, count: action.count || 1 }; + case "col_insert": + return { op: "col_delete", sheet: action.sheet, at: action.at, count: action.count || 1 }; + case "sheet_rename": + return { op: "sheet_rename", from: action.to, to: action.from }; + case "sheet_add": + return String(action.name || "").length && String(action.name).length <= 31 + ? { op: "sheet_delete", name: action.name } + : null; + case "sheet_duplicate": + return String(action.as || "").length && String(action.as).length <= 31 + ? { op: "sheet_delete", name: action.as } + : null; + default: + return null; // row_delete / col_delete / sheet_delete drop data + } + }; + + const putStructure = async (actions, force = false, { quiet = false, record = true } = {}) => { await api( `/api/file/${encodeURIComponent(data.vault)}/xlsx/structure?path=${encodeURIComponent(data.path)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ actions, force }), + body: JSON.stringify({ actions, force, if_match: revision }), }, ); - showToast(t("xlsx.structure_saved"), "success"); + if (!quiet) showToast(t("xlsx.structure_saved"), "success"); + // #156-A10 — a fully reversible batch goes on the undo stack. + if (record) { + const inverses = actions.map(inverseAction); + if (inverses.every(Boolean)) { + pushHistory({ kind: "structure", before: inverses, after: actions }); + } + } // Re-render from the server so the viewer shows the new structure. const fresh = await api( `/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, ); renderXlsxViewer(area, fresh); + return fresh; }; const structureError = async (err, retryActions) => { @@ -2015,6 +2259,11 @@ export function renderXlsxViewer(area, data) { showToast(t("xlsx.lossy_cancelled"), "info"); return null; } + if (err && err.code === "conflict") { + // #156-A12 — a concurrent writer: keep the user's edits, offer a retry. + showConflict(t("xlsx.conflict_msg")); + return null; + } showToast(`${t("xlsx.structure_error")}: ${err.message || err}`, "error"); return null; }; @@ -2130,36 +2379,802 @@ export function renderXlsxViewer(area, data) { e.target.closest(".xlsx-toolbar").appendChild(menu); }); - // CSV export of the visible sheet (post-trim, pre-save data). - area.querySelector("#xlsx-csv-btn").addEventListener("click", () => { + // ── #156-A11 — sortie : CSV / Markdown / HTML / impression ──────────── + // The four formats all read the SAME grid: the selection's block when a + // multi-cell range is active (this tranche l'écart doc/code de #153 A13, + // qui annonçait « export de la sélection »), the visible sheet otherwise. + // Values are the DISPLAYED ones — the cached formula shadow is a tooltip, + // never data. Rows follow the render (post-trim/post-lazy-load) so what is + // exported is exactly what the user sees. + + /** The whole visible sheet as a matrix of displayed values. */ + const sheetMatrix = (panel) => + [...panel.querySelectorAll(".xlsx-table tbody tr")].map((tr) => + [...tr.querySelectorAll("td")].map((td) => cellTextOf(td)), + ); + + /** What an export should carry, plus the scope/title to name the file. */ + const exportPayload = () => { const panel = visiblePanel(); - const idx = Number(panel.dataset.sheet); - const rows = []; - panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { - rows.push( - [...tr.querySelectorAll("td")].map((td) => { - const clone = td.cloneNode(true); - clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove()); - return clone.textContent; - }), - ); - }); - const csv = rows - .map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";")) - .join("\n"); - const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); + const b = selectionBounds(); + const selected = Boolean( + b && selection && selection.panel === panel && (b.r2 > b.r1 || b.c2 > b.c1), + ); + const base = (sheets[Number(panel.dataset.sheet)]?.name || data.title || data.path || "feuille") + .replace(/\.[^.]+$/, ""); + return { + rows: selected ? selectionMatrix() : sheetMatrix(panel), + selected, + // « Fruits-A1C3 » — a filename-safe version of the range address. + name: selected ? `${base}-${rangeLabel().replace(/[:$]/g, "")}` : base, + scope: t(selected ? "xlsx.export_scope_selection" : "xlsx.export_scope_sheet"), + }; + }; + + const downloadText = (filename, text, mime) => { + const blob = new Blob([text], { type: mime }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); - a.download = `${sheets[idx]?.name || "feuille"}.csv`; + a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); + }; + + const toCsv = (rows) => + rows + .map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";")) + .join("\n"); + + const toMarkdown = (rows) => { + if (!rows.length) return ""; + const head = rows[0]; + return [ + `| ${head.join(" | ")} |`, + `|${head.map(() => "---").join("|")}|`, + ...rows.slice(1).map((r) => `| ${r.join(" | ")} |`), + ].join("\n"); + }; + + const toHtml = (rows, title) => ` + +${escapeHtml(title)} +${rows + .map((r) => `${r.map((v) => ``).join("")}`) + .join("")}
${escapeHtml(v)}
`; + + area.querySelector("#xlsx-csv-btn").addEventListener("click", () => { + const { rows, name, scope } = exportPayload(); + downloadText(`${name}.csv`, `\uFEFF${toCsv(rows)}`, "text/csv;charset=utf-8"); + showToast(t("xlsx.export_done", { scope }), "success"); + }); + + const exportDocument = (kind) => { + const { rows, name, scope } = exportPayload(); + if (kind === "md") { + downloadText(`${name}.md`, toMarkdown(rows), "text/markdown;charset=utf-8"); + } else { + downloadText(`${name}.html`, toHtml(rows, name), "text/html;charset=utf-8"); + } + showToast(t("xlsx.export_done", { scope }), "success"); + }; + + // Print renders the sheet in a hidden 0×0 iframe so the app chrome (toolbar, + // banners, tabs, inspector) never reaches the paper — the frame carries its + // own standalone document. The previous frame is dropped first: at most one + // ever lingers in the page. + const printSheet = () => { + const { rows, name } = exportPayload(); + document.querySelectorAll("body > iframe.xlsx-print-frame").forEach((f) => f.remove()); + document.body.classList.add("xlsx-print"); + const frame = document.createElement("iframe"); + frame.className = "xlsx-print-frame"; + frame.setAttribute("aria-hidden", "true"); + document.body.appendChild(frame); + try { + const doc = frame.contentDocument; + if (doc) { + doc.open(); + doc.write(toHtml(rows, name)); + doc.close(); + const win = frame.contentWindow; + if (win && typeof win.print === "function") win.print(); + } else { + // No same-origin contentDocument (older engines): keep the document + // reachable for a manual print instead of losing the export. + frame.setAttribute("srcdoc", toHtml(rows, name)); + if (typeof window.print === "function") window.print(); + } + } finally { + document.body.classList.remove("xlsx-print"); + } + }; + + const exportMenu = area.querySelector("#xlsx-export-menu"); + if (exportMenu) { + area.querySelector("#xlsx-export-btn").addEventListener("click", () => { + exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block"; + }); + exportMenu.querySelectorAll("[data-xlsx-export]").forEach((item) => { + item.addEventListener("click", () => { + exportMenu.style.display = "none"; + if (item.dataset.xlsxExport === "print") printSheet(); + else exportDocument(item.dataset.xlsxExport); + }); + }); + } // ── #156-A8 — mise en forme en écriture ────────────────────────────────── + // Styles, fusions, largeurs, hauteurs et volets figés partent en une seule + // réécriture verrouillée/atomique (PUT …/xlsx/style), comme les éditions de + // cellules et la structure. Les couleurs sont **choisies** (sélecteur natif), + // jamais codées en dur : le champ `style` ne porte que des valeurs de l'appel. + const selectionRange = () => { + const b = selectionBounds(); + if (b && selection && selection.panel === visiblePanel()) return rangeLabel(); + return cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null) || "A1"; + }; + + const putStyle = async (ops, force = false) => { + const res = await api( + `/api/file/${encodeURIComponent(data.vault)}/xlsx/style?path=${encodeURIComponent(data.path)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ops, force, if_match: revision }), + }, + ); + showToast(t("xlsx.format_applied"), "success"); + // Re-render from the server: the style maps (and the revision) are fresh. + const fresh = await api( + `/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, + ); + renderXlsxViewer(area, fresh); + return fresh; + }; + + const styleError = async (err, retryOps) => { + if (err && err.code === "xlsx_lossy_content") { + const features = (err.details && err.details.features) || lossy; + const labels = features.map((f) => t("xlsx.feature_" + f)).join(", "); + const proceed = await showConfirm({ + title: t("xlsx.lossy_title"), + message: t("xlsx.lossy_confirm", { features: labels }), + confirmLabel: t("xlsx.lossy_confirm_btn"), + danger: true, + }); + if (proceed) return putStyle(retryOps, true); // re-emitted with force + showToast(t("xlsx.lossy_cancelled"), "info"); + return null; + } + if (err && err.code === "conflict") { + showConflict(t("xlsx.conflict_msg")); + return null; + } + showToast(`${t("xlsx.format_error")}: ${err.message || err}`, "error"); + return null; + }; + + const runStyle = async (ops) => { + try { await putStyle(ops); } catch (err) { await styleError(err, ops); } + }; + + // The bold/italic toggles read the style the reader applied (A15 keeps the + // font fragments inline), so a second click removes the effect. + const activeStyleCell = () => + (activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null); + const hasBold = () => { + const td = activeStyleCell(); + return Boolean(td && (td.style.fontWeight === "600" || td.style.fontWeight === "bold")); + }; + const hasItalic = () => { + const td = activeStyleCell(); + return Boolean(td && td.style.fontStyle === "italic"); + }; + const hasUnderline = () => { + const td = activeStyleCell(); + return Boolean(td && td.style.textDecoration === "underline"); + }; + + const formatBtn = area.querySelector("#xlsx-format-btn"); + if (formatBtn) formatBtn.addEventListener("click", () => { + const old = area.querySelector(".xlsx-format-menu"); + if (old) { old.remove(); return; } + const sheetName = sheets[visibleSheetIndex()]?.name || ""; + const menu = document.createElement("div"); + menu.className = "xlsx-structure-menu xlsx-format-menu"; + const item = (label, fn) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "btn-action xlsx-structure-item"; + b.textContent = label; + b.addEventListener("click", () => { menu.remove(); fn(); }); + menu.appendChild(b); + }; + const sep = () => menu.appendChild( + Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }), + ); + const cellStyle = (style) => () => runStyle([ + { op: "cell", sheet: sheetName, range: selectionRange(), style }, + ]); + const numFormat = (fmt) => cellStyle({ number_format: fmt }); + + item(t("xlsx.format_bold"), cellStyle({ bold: !hasBold() })); + item(t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() })); + item(t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() })); + item(t("xlsx.format_clear"), cellStyle({ + bold: false, italic: false, underline: false, + font_color: "", fill_color: "", align: "left", number_format: "General", + })); + sep(); + item(t("xlsx.format_align_left"), cellStyle({ align: "left" })); + item(t("xlsx.format_align_center"), cellStyle({ align: "center" })); + item(t("xlsx.format_align_right"), cellStyle({ align: "right" })); + sep(); + // Colours come from the native picker; applied once when it closes. + const colour = (label, apply) => { + const row = document.createElement("label"); + row.className = "xlsx-format-colour"; + const span = document.createElement("span"); + span.textContent = label; + const input = document.createElement("input"); + input.type = "color"; + input.addEventListener("change", apply); + row.appendChild(span); + row.appendChild(input); + menu.appendChild(row); + }; + colour(t("xlsx.format_font_color"), (e) => runStyle([ + { op: "cell", sheet: sheetName, range: selectionRange(), style: { font_color: e.target.value } }, + ])); + colour(t("xlsx.format_fill_color"), (e) => runStyle([ + { op: "cell", sheet: sheetName, range: selectionRange(), style: { fill_color: e.target.value } }, + ])); + sep(); + item(t("xlsx.format_num_general"), numFormat("General")); + item(t("xlsx.format_num_number"), numFormat("0.00")); + item(t("xlsx.format_num_percent"), numFormat("0.0%")); + item(t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"')); + item(t("xlsx.format_num_date"), numFormat("DD/MM/YYYY")); + item(t("xlsx.format_num_text"), numFormat("@")); + sep(); + item(t("xlsx.format_merge"), () => { + const b = selectionBounds(); + if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) { + showToast(t("xlsx.format_merge_needs_range"), "info"); + return; + } + runStyle([{ op: "merge", sheet: sheetName, range: rangeLabel() }]); + }); + item(t("xlsx.format_unmerge"), () => { + runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]); + }); + sep(); + item(t("xlsx.format_freeze"), () => { + runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]); + }); + item(t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }])); + sep(); + item(t("xlsx.format_col_width"), async () => { + const b = selectionBounds(); + const cols = []; + if (b && selection && selection.panel === visiblePanel()) { + for (let c = b.c1; c <= b.c2; c++) cols.push(columnName(c)); + } else { + const ref = parseRef(selectionRange().split(":")[0]) || { col: 1 }; + cols.push(columnName(ref.col)); + } + const answer = await showPrompt({ + title: t("xlsx.format_col_width"), + message: t("xlsx.format_col_prompt", { col: cols.join(", ") }), + value: "20", + }); + const width = Number(String(answer || "").replace(",", ".")); + if (!answer || !Number.isFinite(width) || width < 0 || width > 255) return; + runStyle(cols.map((col) => ({ op: "col_width", sheet: sheetName, col, width }))); + }); + item(t("xlsx.format_row_height"), async () => { + const b = selectionBounds(); + const rows = []; + if (b && selection && selection.panel === visiblePanel()) { + for (let r = b.r1; r <= b.r2; r++) rows.push(r); + } else { + const ref = parseRef(selectionRange().split(":")[0]) || { row: 1 }; + rows.push(ref.row); + } + const answer = await showPrompt({ + title: t("xlsx.format_row_height"), + message: t("xlsx.format_row_prompt", { row: rows.join(", ") }), + value: "24", + }); + const height = Number(String(answer || "").replace(",", ".")); + if (!answer || !Number.isFinite(height) || height < 0 || height > 409) return; + runStyle(rows.map((row) => ({ op: "row_height", sheet: sheetName, row, height }))); + }); + formatBtn.closest(".xlsx-toolbar").appendChild(menu); }); // ══ #155 — Excel-like selection & context menu ═════════════════════════ const viewerEl = area.querySelector(".xlsx-viewer"); + // ══ #156-A5 — presse-papiers de plage (copier / couper / coller un bloc) ══ + // The grid keeps its own TSV clipboard so a copy → paste round-trip works + // even when the system clipboard is unavailable; the async Clipboard API is + // a best-effort mirror and the fallback source for a block that did not come + // from this grid (e.g. one copied in Excel). Values are always inserted as + // plain text — never HTML (risk R4). + let rangeClipboard = null; // { panel, r1, r2, c1, c2, rows, cut } + let pendingCut = null; // cut source range, cleared by the next paste + + /** Displayed text of a cell (the cached-value shadow is a tooltip, not data). */ + const cellTextOf = (td) => { + if (!td) return ""; + const clone = td.cloneNode(true); + clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove()); + return clone.textContent; + }; + + const toTsv = (rows) => rows.map((line) => line.join("\t")).join("\n"); + + /** The selected range as a matrix of displayed values. */ + const selectionMatrix = () => { + const b = selectionBounds(); + if (!b || !selection) return null; + const rows = []; + for (let r = b.r1; r <= b.r2; r++) { + const line = []; + for (let c = b.c1; c <= b.c2; c++) line.push(cellTextOf(findTd(selection.panel, r, c))); + rows.push(line); + } + return rows; + }; + + /** Best-effort system-clipboard write (silently ignored when unavailable). */ + const writeClipboard = async (text) => { + try { + if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch (_) { /* restricted clipboard: the internal one still works */ } + return false; + }; + + /** Copy the selected range. Returns false when there is nothing to copy. */ + const copySelection = () => { + const b = selectionBounds(); + const rows = selectionMatrix(); + if (!b || !rows || !selection) return false; + rangeClipboard = { panel: selection.panel, ...b, rows, cut: false }; + pendingCut = null; + writeClipboard(toTsv(rows)); + return true; + }; + + /** Cut the selected range: the source is cleared by the next paste (Excel). */ + const cutSelection = () => { + if (!copySelection()) return false; + rangeClipboard.cut = true; + pendingCut = { panel: rangeClipboard.panel, ...selectionBounds() }; + return true; + }; + + /** True when the user has a live text selection inside *td* (edit mode). */ + const hasCellTextSelection = (td) => { + const sel = window.getSelection ? window.getSelection() : null; + return Boolean(sel && !sel.isCollapsed && sel.rangeCount && td && td.contains(sel.anchorNode)); + }; + + /** Put the caret at the end of a cell's text (F2 / navigation → edit). */ + const placeCaretAtEnd = (td) => { + const sel = window.getSelection ? window.getSelection() : null; + if (!sel || !document.createRange) return; + const range = document.createRange(); + range.selectNodeContents(td); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + }; + + const startEditing = (td) => { + td.dataset.editing = "1"; + td.focus(); + placeCaretAtEnd(td); + }; + + /** Insert plain text at the caret inside a cell (no HTML, no execCommand). */ + const insertTextAtCaret = (td, text) => { + const sel = window.getSelection ? window.getSelection() : null; + if (sel && sel.rangeCount && td.contains(sel.anchorNode)) { + const range = sel.getRangeAt(0); + range.deleteContents(); + const node = document.createTextNode(text); + range.insertNode(node); + range.setStartAfter(node); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } else { + td.appendChild(document.createTextNode(text)); + } + td.dataset.editing = "1"; + td.classList.add("xlsx-dirty"); + syncFormulaBar(td); + refreshSaveState(); + }; + + /** Clear a range through the history stack (undoable), skipping *skip*. */ + const clearRange = (panel, b, skip = null) => { + if (!panel || !b) return 0; + let cleared = 0; + for (let r = b.r1; r <= b.r2; r++) { + for (let c = b.c1; c <= b.c2; c++) { + if (skip && r >= skip.r1 && r <= skip.r2 && c >= skip.c1 && c <= skip.c2) continue; + const td = findTd(panel, r, c); + if (!td) continue; + const before = cellTextOf(td); + if (before === "") continue; + td.textContent = ""; + td.classList.toggle("xlsx-dirty", (td.dataset.orig ?? "") !== ""); + recordEdit(td, before, ""); + resignPending(td); + cleared++; + } + } + refreshSaveState(); + return cleared; + }; + + /** Split pasted text into a matrix (TSV from a spreadsheet, or plain lines). */ + const parseMatrix = (text) => { + const raw = String(text ?? "").replace(/\r\n?/g, "\n"); + if (!raw) return []; + const lines = raw.split("\n"); + // A spreadsheet copy ends with a newline: drop the empty row it produces. + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); + return lines.map((line) => line.split("\t")); + }; + + /** Plain-text payload of a clipboard event ("" when unavailable). */ + const clipTextOf = (e) => { + const data = (e && (e.clipboardData || window.clipboardData)) || null; + if (!data || typeof data.getData !== "function") return ""; + try { + return data.getData("text/plain") || data.getData("text") || ""; + } catch (_) { + return ""; + } + }; + + // A range operation that rewrites the cell being edited must not produce a + // second history entry when that cell later loses focus (commitEdit). + const resignPending = (td) => { + if (pendingEdit && pendingEdit.td === td) pendingEdit.before = td.textContent; + }; + + /** Write one value into a cell through the history stack. */ + const setCellValue = (td, value) => { + const before = cellTextOf(td); + if (before === value) return false; + td.textContent = value; + td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? "")); + recordEdit(td, before, value); + resignPending(td); + return true; + }; + + /** Paste a matrix starting at *startTd*; keeps the pasted block selected. */ + const fillFrom = (startTd, matrix) => { + const panel = startTd && startTd.closest(".xlsx-panel"); + const start = startTd && parseRef(cellName(startTd)); + if (!panel || !start || !matrix.length) return null; + const rows = matrix.length; + const cols = matrix.reduce((n, line) => Math.max(n, line.length), 0); + let missing = 0; + for (let i = 0; i < rows; i++) { + for (let j = 0; j < cols; j++) { + const td = findTd(panel, start.row + i, start.col + j); + if (!td) { missing++; continue; } + setCellValue(td, matrix[i][j] ?? ""); + } + } + const target = { + r1: start.row, r2: start.row + rows - 1, + c1: start.col, c2: start.col + cols - 1, + }; + // Excel keeps the pasted block selected: select the whole target range + // (cells outside the rendered window are simply not painted) and tell the + // user when part of the block could not be written. + selection = { + panel, + anchor: { row: target.r1, col: target.c1 }, + focus: { row: target.r2, col: target.c2 }, + }; + applySelection(); + activeTd = startTd; + formulaInput.disabled = false; + formulaInput.value = startTd.textContent; + refreshSaveState(); + if (missing) showToast(t("xlsx.paste_out_of_grid"), "info"); + return target; + }; + + /** Paste one value: replace the cell, or insert at the caret mid-edit. */ + const pasteSingleValue = (td, value) => { + if (hasCellTextSelection(td)) { insertTextAtCaret(td, value); return; } + if (setCellValue(td, value)) { + syncFormulaBar(td); + refreshSaveState(); + } + }; + + /** Common paste path: 1×1 → single cell, otherwise fill the block. */ + const applyPaste = (text, targetTd) => { + const td = targetTd || (activeTd && area.contains(activeTd) ? activeTd : null); + const matrix = parseMatrix(text); + if (!td || !matrix.length) return false; + if (matrix.length === 1 && matrix[0].length === 1) { + pasteSingleValue(td, matrix[0][0]); + } else { + const target = fillFrom(td, matrix); + // A cut is committed once its source is cleared; cells inside the target + // range are kept so cut → paste in place never erases the paste. + if (pendingCut && area.contains(pendingCut.panel)) clearRange(pendingCut.panel, pendingCut, target); + } + pendingCut = null; + if (rangeClipboard) rangeClipboard.cut = false; + return true; + }; + + /** Context-menu “Coller”: internal block first, system clipboard next. */ + const pasteFromClipboard = async () => { + if (rangeClipboard && applyPaste(toTsv(rangeClipboard.rows))) return; + let text = ""; + try { + if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.readText) { + text = await navigator.clipboard.readText(); + } + } catch (_) { /* permission denied / unavailable */ } + if (!text || !applyPaste(text)) showToast(t("xlsx.paste_empty"), "info"); + }; + + // Ctrl+C / Ctrl+X are handled through the clipboard events: they also cover + // the browser's own Edit menu, and a live text selection inside the edited + // cell keeps its native behaviour. + viewerEl.addEventListener("copy", (e) => { + const td = e.target && e.target.closest ? e.target.closest("td[data-cell]") : null; + if (td && hasCellTextSelection(td)) return; // native in-cell copy + const inGrid = e.target && e.target.closest && e.target.closest(".xlsx-panel"); + if (!inGrid || !copySelection()) return; + if (e.clipboardData && e.clipboardData.setData) e.clipboardData.setData("text/plain", toTsv(rangeClipboard.rows)); + e.preventDefault(); + }); + viewerEl.addEventListener("cut", (e) => { + const td = e.target && e.target.closest ? e.target.closest("td[data-cell]") : null; + if (td && hasCellTextSelection(td)) return; // native in-cell cut + const inGrid = e.target && e.target.closest && e.target.closest(".xlsx-panel"); + if (!inGrid || !cutSelection()) return; + if (e.clipboardData && e.clipboardData.setData) e.clipboardData.setData("text/plain", toTsv(rangeClipboard.rows)); + e.preventDefault(); + }); + + // ══ #156-A6 — clavier complet & #156-A7 — zone Nom ══════════════════ + // A cell is in “navigation” mode until the user types (or presses F2); that + // single rule is what lets Delete clear the selection instead of deleting a + // character, and Home/End reach the row instead of the caret. + const isTypingInCell = (td) => Boolean(td && td.dataset.editing === "1"); + + const activeOrFirst = (panel) => { + if (panel && activeTd && panel.contains(activeTd)) return activeTd; + return panel ? panel.querySelector("td[data-cell]") : null; + }; + + const goTo = (td) => { + if (!td) return; + setActiveCell(td); + td.focus(); + if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" }); + }; + + /** Move the `xlsx-active` marker without touching the range selection. */ + const markActive = (td) => { + const prev = area.querySelector("td.xlsx-active"); + if (prev && prev !== td) prev.classList.remove("xlsx-active"); + if (td) td.classList.add("xlsx-active"); + }; + + const gridGeom = (panel) => ({ + rows: panel.querySelectorAll("th.xlsx-rownum").length, + cols: panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length, + }); + + /** Ctrl+flèche — last cell of a data run (or the first non-empty after a gap). */ + const jumpActive = (panel, key, extend) => { + const start = activeOrFirst(panel); + const ref = start && parseRef(cellName(start)); + const step = { + ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0], + }[key]; + if (!ref || !step) return; + const [dr, dc] = step; + const at = (i) => findTd(panel, ref.row + dr * i, ref.col + dc * i); + const blank = (td) => !td || cellTextOf(td).trim() === ""; + let last = 0; + while (at(last + 1)) last++; + if (!last) return; + let i = 1; + if (blank(at(1))) { + while (i < last && blank(at(i))) i++; // skip the gap → next data + } else { + while (i < last && !blank(at(i + 1))) i++; // end of the data run + } + const target = at(i); + if (!target || target === start) return; + if (extend && selection && selection.panel === panel) { + suppressSelectionReset = true; + target.focus(); + suppressSelectionReset = false; + selection.focus = parseRef(cellName(target)) || selection.focus; + applySelection(); + activeTd = target; + formulaInput.value = target.textContent; + return; + } + goTo(target); + }; + + const selectAllCells = (panel) => { + const { rows, cols } = gridGeom(panel); + if (!rows || !cols) return; + selection = { panel, anchor: { row: 1, col: 1 }, focus: { row: rows, col: cols } }; + applySelection(); + }; + + const edgeOfRow = (panel, which) => { + const cur = activeOrFirst(panel); + const ref = cur && parseRef(cellName(cur)); + const { cols } = gridGeom(panel); + if (!ref || !cols) return; + goTo(findTd(panel, ref.row, which === "first" ? 1 : cols)); + }; + + const sheetCorner = (panel, key) => { + if (key === "Home") { goTo(panel.querySelector("td[data-cell]")); return; } + const { rows, cols } = gridGeom(panel); + if (rows && cols) goTo(findTd(panel, rows, cols)); + }; + + const pageMove = (panel, dir) => { + const cur = activeOrFirst(panel); + const ref = cur && parseRef(cellName(cur)); + const { rows } = gridGeom(panel); + if (!ref || !rows) return; + // One screenful: visible height ÷ row height, with a fallback when the + // layout is not measured (JSDOM reports 0 for every box). + const wrapper = panel.querySelector(".csv-table-wrapper"); + const rowEl = panel.querySelector("tbody tr"); + const h = (wrapper && wrapper.clientHeight) || 0; + const rh = (rowEl && rowEl.getBoundingClientRect().height) || 0; + const step = h && rh ? Math.max(1, Math.floor(h / rh) - 1) : 10; + const row = Math.min(rows, Math.max(1, ref.row + dir * step)); + goTo(findTd(panel, row, ref.col)); + }; + + /** #156-A7 — jump to a typed reference ("B12", "A1:B3", "Feuille!B12"). */ + const goToRef = (raw) => { + const value = String(raw || "").trim().replace(/\$/g, ""); + if (!value) return false; + const sheetName = sheetOfRef(value); + const body = value.includes("!") ? value.split("!").pop() : value; + const parts = body.toUpperCase().split(":"); + const from = parseRef(parts[0]); + const to = parts[1] ? parseRef(parts[1]) : from; + if (!from || !to) return false; + let panel = visiblePanel(); + if (sheetName) { + const idx = sheets.findIndex((s) => s.name === sheetName); + const tab = idx >= 0 ? area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`) : null; + if (!tab) return false; + tab.click(); + panel = panelEls[idx]; + } + if (!panel) return false; + const anchorTd = findTd(panel, from.row, from.col); + if (!anchorTd) { showToast(t("xlsx.range_not_visible"), "info"); return false; } + const focusTd = findTd(panel, to.row, to.col) || anchorTd; + selection = { + panel, + anchor: { row: from.row, col: from.col }, + focus: parseRef(cellName(focusTd)) || from, + }; + activeTd = anchorTd; + markActive(anchorTd); + formulaInput.disabled = false; + formulaInput.value = anchorTd.textContent; + // Focusing the anchor must not collapse a typed range back to one cell: + // the focus handler skips the selection reset under this flag. + suppressSelectionReset = true; + anchorTd.focus(); + suppressSelectionReset = false; + selection = { + panel, + anchor: { row: from.row, col: from.col }, + focus: parseRef(cellName(focusTd)) || from, + }; + applySelection(); + if (typeof anchorTd.scrollIntoView === "function") anchorTd.scrollIntoView({ block: "center" }); + return true; + }; + + activeCellEl.addEventListener("focus", () => { activeCellEl.select(); }); + activeCellEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + const okRef = goToRef(activeCellEl.value); + if (!okRef) showToast(t("xlsx.name_box_invalid", { ref: activeCellEl.value }), "error"); + activeCellEl.value = rangeLabel() || ""; + if (activeTd) activeTd.focus(); + } else if (e.key === "Escape") { + e.preventDefault(); + activeCellEl.value = rangeLabel() || ""; + if (activeTd) activeTd.focus(); + } + }); + + // #156-A7 — function-name suggestions for the formula bar. The names are + // localized through the catalog (Excel/Sheets each ship their own spelling). + const fnList = area.querySelector("#xlsx-function-list"); + if (fnList) { + String(t("xlsx.fn_suggestions") || "") + .split(";") + .map((s) => s.trim()) + .filter(Boolean) + .forEach((name) => { + const opt = document.createElement("option"); + opt.value = name; + fnList.appendChild(opt); + }); + } + + // #156-A6 — one grid-level keyboard handler, in priority order: save is + // global (the formula bar keeps it too), then undo/redo, then navigation. + // The formula/find inputs keep their native editing keys. + area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => { + const key = e.key; + const lower = key.length === 1 ? key.toLowerCase() : key; + const mod = e.ctrlKey || e.metaKey; + const inBar = e.target === formulaInput || e.target === activeCellEl; + const panel = (selection && selection.panel) || (e.target.closest && e.target.closest(".xlsx-panel")) || visiblePanel(); + if (!panel) return; + if (mod && lower === "s") { + e.preventDefault(); + if (!saveBtn.disabled) saveBtn.click(); + return; + } + if (inBar) return; // formula/find inputs: native editing keys + if (mod && lower === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; } + if (mod && lower === "y") { e.preventDefault(); redo(); return; } + if (readOnly) return; // navigation below would activate an uneditable grid + if (mod && lower === "a") { e.preventDefault(); selectAllCells(panel); return; } + if (mod && key.startsWith("Arrow")) { e.preventDefault(); jumpActive(panel, key, e.shiftKey); return; } + if (mod && (key === "Home" || key === "End")) { e.preventDefault(); sheetCorner(panel, key); return; } + if (key === "Delete" && !isTypingInCell(activeTd)) { + const b = selectionBounds(); + if (!selection || !b) return; + e.preventDefault(); + clearRange(selection.panel, b); + return; + } + if (!mod && (key === "Home" || key === "End") && !isTypingInCell(activeTd)) { + e.preventDefault(); + edgeOfRow(panel, key === "Home" ? "first" : "last"); + return; + } + if (!mod && (key === "PageUp" || key === "PageDown")) { + e.preventDefault(); + pageMove(panel, key === "PageDown" ? 1 : -1); + } + }); + const contextTargetFrom = (target) => { if (!target || !target.closest) return null; const panel = target.closest(".xlsx-panel"); @@ -2200,15 +3215,9 @@ export function renderXlsxViewer(area, data) { } }; - const clearContents = (panel, b) => { - for (let r = b.r1; r <= b.r2; r++) { - for (let c = b.c1; c <= b.c2; c++) { - const td = findTd(panel, r, c); - if (td && td.textContent !== "") { td.textContent = ""; td.classList.add("xlsx-dirty"); } - } - } - refreshSaveState(); - }; + // #156-A5/A6 — clearing goes through clearRange() so "Effacer le contenu", + // Suppr and the cut source all share the same undoable path. + const clearContents = (panel, b) => clearRange(panel, b); const buildContextItems = (target) => { const panel = target.panel; @@ -2218,6 +3227,11 @@ export function renderXlsxViewer(area, data) { const rowCount = b.r2 - b.r1 + 1; const colCount = b.c2 - b.c1 + 1; const items = []; + // #156-A5 — clipboard entries first, in the spreadsheet order. + items.push({ label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() }); + items.push({ label: t("xlsx.copy"), onClick: () => copySelection() }); + items.push({ label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() }); + items.push({ separator: true }); if (editable) { items.push({ label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) }); items.push({ label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) }); diff --git a/frontend/js/xlsx/command-bar.js b/frontend/js/xlsx/command-bar.js index d346b38..c650eca 100644 --- a/frontend/js/xlsx/command-bar.js +++ b/frontend/js/xlsx/command-bar.js @@ -55,6 +55,9 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) { } if (editable) { actionGroups.push(` + @@ -68,6 +71,16 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) { + + + + `); const actionsHtml = actionGroups.join(''); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 6a38d40..eb39007 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1848,13 +1848,59 @@ "xlsx.load_error": "Could not load the remaining rows", "xlsx.formula_bar_placeholder": "Active cell content", "xlsx.active_cell": "Cell", - "xlsx.find_placeholder": "Search in the sheet…", + "xlsx.name_box_label": "Name Box — jump to a cell or a range", + "xlsx.name_box_hint": "Type a reference (B12, A1:B3) then Enter to jump there", + "xlsx.name_box_invalid": "Unknown reference: {ref}", + "xlsx.copy": "Copy", + "xlsx.cut": "Cut", + "xlsx.paste": "Paste", + "xlsx.paste_empty": "Clipboard empty or unreadable", + "xlsx.paste_out_of_grid": "Part of the pasted block is outside the displayed grid and was skipped", + "xlsx.fn_suggestions": "SUM;AVERAGE;COUNT;COUNTA;MIN;MAX;IF;VLOOKUP;XLOOKUP;SUMIF;SUMIFS;COUNTIF;CONCAT;LEFT;RIGHT;MID;TODAY;NOW;ROUND;ABS;SQRT", + "xlsx.formula_stored_as_text": "{n} value(s) starting with “=” or “@” saved as text — turn on the f(x) button to write real formulas", + "xlsx.find_placeholder": "Search in the workbook…", "xlsx.find_prev": "Previous", "xlsx.find_next": "Next", "xlsx.find_case": "Match case", "xlsx.find_no_match": "No match", "xlsx.find_count": "{index}/{count}", - "xlsx.csv_export": "Export the sheet as CSV", + "xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets", + "xlsx.csv_export": "Export the sheet or the selection as CSV", + "xlsx.export_btn": "Export", + "xlsx.export_title": "Export the selection or the sheet", + "xlsx.export_md": "Export as Markdown", + "xlsx.export_html": "Export as HTML", + "xlsx.print": "Print", + "xlsx.export_scope_sheet": "the sheet", + "xlsx.export_scope_selection": "the selection", + "xlsx.export_done": "Exported {scope}", + "xlsx.format_btn": "Formatting", + "xlsx.format_bold": "Bold", + "xlsx.format_italic": "Italic", + "xlsx.format_underline": "Underline", + "xlsx.format_clear": "Clear formatting", + "xlsx.format_align_left": "Align left", + "xlsx.format_align_center": "Center", + "xlsx.format_align_right": "Align right", + "xlsx.format_font_color": "Text colour", + "xlsx.format_fill_color": "Fill colour", + "xlsx.format_num_general": "General format", + "xlsx.format_num_number": "Number (2 decimals)", + "xlsx.format_num_percent": "Percentage", + "xlsx.format_num_currency": "Currency (€)", + "xlsx.format_num_date": "Date (DD/MM/YYYY)", + "xlsx.format_num_text": "Text", + "xlsx.format_merge": "Merge cells", + "xlsx.format_unmerge": "Unmerge cells", + "xlsx.format_merge_needs_range": "Select at least two cells to merge", + "xlsx.format_freeze": "Freeze panes here", + "xlsx.format_unfreeze": "Unfreeze panes", + "xlsx.format_col_width": "Column width…", + "xlsx.format_col_prompt": "Width of column {col} (0 to 255)", + "xlsx.format_row_height": "Row height…", + "xlsx.format_row_prompt": "Height of row {row} (0 to 409)", + "xlsx.format_applied": "Formatting saved", + "xlsx.format_error": "Formatting failed", "xlsx.sort_asc": "Sort column A→Z", "xlsx.sort_desc": "Sort column Z→A", "xlsx.sort_applied": "Sort applied on {col} — display only, the workbook is unchanged", @@ -1905,6 +1951,7 @@ "xlsx.dashboard_nr_ref": "Reference", "xlsx.dashboard_hint": "Select a range or open the AI assistant to analyse this data.", "xlsx.feature_cached_values": "cached values", + "xlsx.feature_cached_values_unverified": "cached values could not be verified (workbook too large)", "xlsx.feature_slicers": "slicers and timelines", "xlsx.feature_form_controls": "form controls", "xlsx.feature_connections": "connections and queries", @@ -2080,6 +2127,9 @@ "ai.step.xlsx_read": "Workbook read: {value}", "ai.step.xlsx_update": "Cells edited: {value}", "ai.step.xlsx_append": "Rows appended: {value}", + "ai.step.xlsx_search": "Workbook searched: {value}", + "ai.step.xlsx_analyze": "Range analysed: {value}", + "ai.step.xlsx_structure": "Structure changed: {value}", "ai.step.docx_create": "Word document proposed: {value}", "ai.step.csv_create": "CSV file proposed: {value}", "ai.step.pdf_create": "PDF document proposed: {value}", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index cde6c37..d3b9add 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1848,13 +1848,59 @@ "xlsx.load_error": "Chargement de la suite impossible", "xlsx.formula_bar_placeholder": "Contenu de la cellule active", "xlsx.active_cell": "Cellule", - "xlsx.find_placeholder": "Rechercher dans la feuille…", + "xlsx.name_box_label": "Zone Nom — atteindre une cellule ou une plage", + "xlsx.name_box_hint": "Saisissez une référence (B12, A1:B3) puis Entrée pour y aller", + "xlsx.name_box_invalid": "Référence inconnue : {ref}", + "xlsx.copy": "Copier", + "xlsx.cut": "Couper", + "xlsx.paste": "Coller", + "xlsx.paste_empty": "Presse-papiers vide ou illisible", + "xlsx.paste_out_of_grid": "Une partie du collage dépasse la grille affichée et a été ignorée", + "xlsx.fn_suggestions": "SOMME;MOYENNE;NB;NBVAL;MIN;MAX;SI;RECHERCHEV;RECHERCHEX;SOMME.SI;SOMME.SI.ENS;NB.SI;CONCAT;GAUCHE;DROITE;STXT;AUJOURDHUI;MAINTENANT;ARRONDI;ABS;RACINE", + "xlsx.formula_stored_as_text": "{n} valeur(s) commençant par « = » ou « @ » enregistrée(s) comme texte — activez le bouton f(x) pour écrire de vraies formules", + "xlsx.find_placeholder": "Rechercher dans le classeur…", "xlsx.find_prev": "Précédent", "xlsx.find_next": "Suivant", "xlsx.find_case": "Respecter la casse", "xlsx.find_no_match": "Aucune correspondance", "xlsx.find_count": "{index}/{count}", - "xlsx.csv_export": "Exporter la feuille en CSV", + "xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles", + "xlsx.csv_export": "Exporter la feuille ou la sélection en CSV", + "xlsx.export_btn": "Exporter", + "xlsx.export_title": "Exporter la sélection ou la feuille", + "xlsx.export_md": "Exporter en Markdown", + "xlsx.export_html": "Exporter en HTML", + "xlsx.print": "Imprimer", + "xlsx.export_scope_sheet": "la feuille", + "xlsx.export_scope_selection": "la sélection", + "xlsx.export_done": "Export de {scope} terminé", + "xlsx.format_btn": "Mise en forme", + "xlsx.format_bold": "Gras", + "xlsx.format_italic": "Italique", + "xlsx.format_underline": "Souligné", + "xlsx.format_clear": "Effacer la mise en forme", + "xlsx.format_align_left": "Aligner à gauche", + "xlsx.format_align_center": "Centrer", + "xlsx.format_align_right": "Aligner à droite", + "xlsx.format_font_color": "Couleur du texte", + "xlsx.format_fill_color": "Couleur de fond", + "xlsx.format_num_general": "Format général", + "xlsx.format_num_number": "Nombre (2 décimales)", + "xlsx.format_num_percent": "Pourcentage", + "xlsx.format_num_currency": "Monétaire (€)", + "xlsx.format_num_date": "Date (JJ/MM/AAAA)", + "xlsx.format_num_text": "Texte", + "xlsx.format_merge": "Fusionner les cellules", + "xlsx.format_unmerge": "Annuler la fusion", + "xlsx.format_merge_needs_range": "Sélectionnez au moins deux cellules pour fusionner", + "xlsx.format_freeze": "Figer les volets ici", + "xlsx.format_unfreeze": "Libérer les volets", + "xlsx.format_col_width": "Largeur de colonne…", + "xlsx.format_col_prompt": "Largeur de la colonne {col} (0 à 255)", + "xlsx.format_row_height": "Hauteur de ligne…", + "xlsx.format_row_prompt": "Hauteur de la ligne {row} (0 à 409)", + "xlsx.format_applied": "Mise en forme enregistrée", + "xlsx.format_error": "Échec de la mise en forme", "xlsx.sort_asc": "Trier la colonne A→Z", "xlsx.sort_desc": "Trier la colonne Z→A", "xlsx.sort_applied": "Tri appliqué sur {col} — l'affichage seul, le classeur n'est pas modifié", @@ -1905,6 +1951,7 @@ "xlsx.dashboard_nr_ref": "Référence", "xlsx.dashboard_hint": "Sélectionnez une plage ou ouvrez l'assistant IA pour analyser ces données.", "xlsx.feature_cached_values": "valeurs calculées", + "xlsx.feature_cached_values_unverified": "valeurs calculées non vérifiables (classeur trop volumineux)", "xlsx.feature_slicers": "segments et chronologies", "xlsx.feature_form_controls": "contrôles de formulaire", "xlsx.feature_connections": "connexions et requêtes", @@ -2080,6 +2127,9 @@ "ai.step.xlsx_read": "Classeur lu : {value}", "ai.step.xlsx_update": "Cellules modifiées : {value}", "ai.step.xlsx_append": "Lignes ajoutées : {value}", + "ai.step.xlsx_search": "Recherche dans le classeur : {value}", + "ai.step.xlsx_analyze": "Plage analysée : {value}", + "ai.step.xlsx_structure": "Structure modifiée : {value}", "ai.step.docx_create": "Document Word proposé : {value}", "ai.step.csv_create": "Fichier CSV proposé : {value}", "ai.step.pdf_create": "Document PDF proposé : {value}", diff --git a/frontend/style.css b/frontend/style.css index 384ece0..0e700d6 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -11172,8 +11172,11 @@ body.desktop-mode .editor-container { gap: 6px; margin-bottom: 8px; } +/* #156-A7 — the Name Box is editable (“Atteindre”): a plain input styled like + the read-only badge it replaced. */ .xlsx-active-cell { - min-width: 52px; + width: 84px; + box-sizing: border-box; padding: 4px 8px; border: 1px solid var(--border); border-radius: 4px; @@ -11184,6 +11187,10 @@ body.desktop-mode .editor-container { text-align: center; font-variant-numeric: tabular-nums; } +.xlsx-active-cell:focus { + outline: 2px solid var(--accent, #4a90d9); + outline-offset: -2px; +} .xlsx-formula-sep { width: 14px; height: 14px; @@ -11253,6 +11260,72 @@ body.desktop-mode .editor-container { outline-offset: 1px; } +/* #156-A11 — export menu (CSV stays a button; MD/HTML/Print live here). */ +.xlsx-export-dropdown { + position: relative; + display: inline-block; +} +.xlsx-export-menu { + position: absolute; + right: 0; + top: 100%; + z-index: 1000; + min-width: 200px; + padding: 4px; + background: var(--bg, #fff); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18); +} +.xlsx-export-menu .export-menu-item { + display: block; + width: 100%; + padding: 8px 10px; + border: none; + border-radius: 4px; + background: none; + color: var(--text-primary); + cursor: pointer; + text-align: left; + font-size: 13px; +} +.xlsx-export-menu .export-menu-item:hover { + background: var(--surface-hover, rgba(127, 127, 127, 0.12)); +} +.xlsx-print-frame { + position: fixed; + width: 0; + height: 0; + border: 0; +} + +/* #156-A8 — formatting menu: the colour rows pair a label with the native + picker (data-driven colours, no hardcoded swatch). */ +.xlsx-format-menu { + min-width: 230px; + max-height: 70vh; + overflow-y: auto; +} +.xlsx-format-colour { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; +} +.xlsx-format-colour input[type="color"] { + width: 34px; + height: 22px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + cursor: pointer; +} + /* #153 A15 — freeze panes: frozen rows sit UNDER the sticky thead, frozen columns stay left. z-index mirrors thead (3) without covering it. */ .xlsx-table tr.xlsx-frozen-row td, diff --git a/package.json b/package.json index 22f99b1..9cd3929 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.44.1", + "version": "2.45.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/e2e/xlsx-viewer.spec.js b/tests/e2e/xlsx-viewer.spec.js index d055ce6..3d653bf 100644 --- a/tests/e2e/xlsx-viewer.spec.js +++ b/tests/e2e/xlsx-viewer.spec.js @@ -241,17 +241,18 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => { await openXlsx(page, LARGE); // Un clic sur une cellule active la barre avec son nom et son contenu. + // (#156-A7 : la zone Nom est un input, donc on lit sa valeur.) const cell = page.locator('#content-area td[data-cell="B2"]'); await cell.click(); - await expect(page.locator('#xlsx-active-cell')).toHaveText('B2'); + await expect(page.locator('#xlsx-active-cell')).toHaveValue('B2'); const bar = page.locator('#xlsx-formula-input'); await expect(bar).toHaveValue(/Operation 1/); // Les flèches déplacent la cellule active. await cell.press('ArrowDown'); - await expect(page.locator('#xlsx-active-cell')).toHaveText('B3'); + await expect(page.locator('#xlsx-active-cell')).toHaveValue('B3'); await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight'); - await expect(page.locator('#xlsx-active-cell')).toHaveText('C3'); + await expect(page.locator('#xlsx-active-cell')).toHaveValue('C3'); // Éditer depuis la barre marque la cellule dirty, Échap annule. await bar.fill('Operation 2 modifiee'); @@ -260,3 +261,56 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => { await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0); }); }); + +// ── #156 P1 — presse-papiers de plage, clavier complet, zone Nom ──────────── + +test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)', () => { + test('coller une plage, la navigation clavier et la zone Nom (#156 A5/A6/A7)', async ({ page }) => { + await login(page); + await openXlsx(page, LARGE); + + const nameBox = page.locator('#xlsx-active-cell'); + const cellOf = (ref) => page.locator(`#content-area td[data-cell="${ref}"]`); + const textOf = async (ref) => (await cellOf(ref).innerText()).trim(); + + // — A7 : la zone Nom est éditable et sert à « Atteindre ». + await nameBox.fill('B12'); + await nameBox.press('Enter'); + await expect(nameBox).toHaveValue('B12'); + await expect(cellOf('B12')).toHaveClass(/xlsx-active/); + + // — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins. + await cellOf('B12').click(); + await page.keyboard.press('Control+a'); + await expect(nameBox).toHaveValue('A1:C500'); + await page.keyboard.press('Control+Home'); + await expect(nameBox).toHaveValue('A1'); + await page.keyboard.press('Control+End'); + await expect(nameBox).toHaveValue('C500'); + + // — A5 : copier une plage puis la coller ailleurs. Le menu contextuel + // utilise le presse-papiers interne de la grille : aucune permission + // navigateur n'est requise. + const src = { a1: await textOf('A1'), b1: await textOf('B1'), b2: await textOf('B2'), c10: await textOf('C10') }; + await cellOf('A1').click(); + await cellOf('B2').click({ modifiers: ['Shift'] }); + await expect(nameBox).toHaveValue('A1:B2'); + await cellOf('A1').click({ button: 'right' }); + await page.locator('.xlsx-context-item', { hasText: 'Copier' }).click(); + await cellOf('A10').click({ button: 'right' }); + await page.locator('.xlsx-context-item', { hasText: 'Coller' }).click(); + expect(await textOf('A10')).toBe(src.a1); + expect(await textOf('B10')).toBe(src.b1); + expect(await textOf('B11')).toBe(src.b2); + expect(await textOf('C10')).toBe(src.c10); + + // — A6 : Suppr vide la sélection (et seulement elle). + await cellOf('A10').click(); + await cellOf('B11').click({ modifiers: ['Shift'] }); + await expect(nameBox).toHaveValue('A10:B11'); + await page.keyboard.press('Delete'); + expect(await textOf('A10')).toBe(''); + expect(await textOf('B11')).toBe(''); + expect(await textOf('C10')).toBe(src.c10); + }); +}); diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs index 3856b38..64e0172 100644 --- a/tests/frontend/xlsx-viewer.test.mjs +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -108,6 +108,9 @@ async function test(name, fn) { // #154-A2 — themed dialogs live in document.body; clear any left behind by a // previous test so the helpers always reach the current one. document.querySelectorAll(".obsigate-modal-overlay").forEach((n) => n.remove()); + // #156-A10 — the undo/redo stacks are keyed by file and every helper mounts + // the same vault/path: reset them so tests stay independent. + resetXlsxHistory(); try { await fn(); console.log(` ✓ ${name}`); @@ -119,7 +122,7 @@ async function test(name, fn) { } } -const { renderXlsxViewer } = await import( +const { renderXlsxViewer, resetXlsxHistory } = await import( pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "viewer.js")).href ); // Load the FR catalog so t() resolves the real strings. @@ -140,7 +143,7 @@ const sheetHtml = (value) => // successive windows a lazy-loading sheet will request. let apiQueue = []; -function mount({ lossy = [], sheet = {} } = {}) { +function mount({ lossy = [], sheet = {}, revision = null } = {}) { const area = document.getElementById("content-area"); area.innerHTML = ""; renderXlsxViewer(area, { @@ -149,6 +152,8 @@ function mount({ lossy = [], sheet = {} } = {}) { is_xlsx: true, xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }], xlsx_lossy_features: lossy, + // #156-A12 — optimistic-concurrency token returned by the read. + ...(revision ? { xlsx_revision: revision } : {}), }); return area; } @@ -160,6 +165,19 @@ function mountTruncated({ total = 520, rows = 1 } = {}) { }); } +/** + * Mount a CSV exactly as the backend serves it: a single `html` table and NO + * `xlsx_sheets` (the shape that used to crash the save path, BUG-096). + */ +function mountCsv(html = sheetHtml("100")) { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + renderXlsxViewer(area, { + vault: "V", path: "budget.csv", is_csv: true, is_markdown: false, html, + }); + return area; +} + /** Mark a cell dirty the way a user edit would. */ function editCell(area, ref, text) { const td = area.querySelector(`td[data-cell="${ref}"]`); @@ -334,7 +352,10 @@ await test("a 409 conflict shows a non-blocking retry banner and keeps the edits nextResponse = () => ({ ok: true, status: 200, body: {} }); banner.querySelector(".xlsx-banner-retry").click(); await new Promise((r) => setTimeout(r, 10)); - assert.equal(calls.length, 2); + // #156-A12 — the retry re-reads the file first (to pick up the current + // revision), then re-sends the save: one extra request. + assert.equal(calls.length, 3); + assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry"); }); @@ -479,7 +500,7 @@ await test("a failed window fetch keeps the footnote and shows an error toast", await test("the formula bar starts empty and disabled", () => { const area = mount(); - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "—"); + assert.equal(area.querySelector("#xlsx-active-cell").value, ""); assert.equal(area.querySelector("#xlsx-formula-input").disabled, true); }); @@ -490,7 +511,7 @@ await test("focusing a cell shows its name and content in the bar", () => { // JSDOM does not run the default focus behaviour on dispatchEvent, so go // through the real API: td.focus(); - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1"); assert.equal(area.querySelector("#xlsx-formula-input").value, "100"); assert.equal(area.querySelector("#xlsx-formula-input").disabled, false); }); @@ -517,13 +538,13 @@ await test("Tab and arrows move to the neighbour cell", () => { const a1 = area.querySelector('td[data-cell="A1"]'); a1.focus(); a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B1"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "B1"); const b1 = area.querySelector('td[data-cell="B1"]'); b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B2"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "B2"); const b2 = area.querySelector('td[data-cell="B2"]'); b2.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A2"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A2"); }); await test("Enter commits and Shift+Tab goes backwards", () => { @@ -533,7 +554,7 @@ await test("Enter commits and Shift+Tab goes backwards", () => { a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); const b1 = area.querySelector('td[data-cell="B1"]'); b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })); - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1"); }); await test("a saved edit from the bar resets the dirty flag and orig value", async () => { @@ -551,7 +572,7 @@ await test("a saved edit from the bar resets the dirty flag and orig value", asy // ── A13 — sort / filter / find / CSV export ───────────────────────────────── -const mountGrid = () => { +const mountGrid = (revision = null) => { const area = document.getElementById("content-area"); area.innerHTML = ""; const grid = @@ -565,6 +586,7 @@ const mountGrid = () => { vault: "V", path: "data.xlsx", is_xlsx: true, xlsx_sheets: [{ name: "Fruits", html: grid, rows: 3, cols: 2, total_rows: 3, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false }], xlsx_lossy_features: [], + ...(revision ? { xlsx_revision: revision } : {}), }); return area; }; @@ -834,6 +856,22 @@ await test("a csv shows no tab strip and no + button", () => { assert.equal(area.querySelector("#xlsx-tab-add"), null); }); +await test("saving an edited csv PUTs /csv/save (payload without xlsx_sheets)", async () => { + // BUG-096 — a CSV read payload has no `xlsx_sheets`; building the save job + // from `sheets[0].name` used to throw a TypeError before any request, so a + // CSV edit could never be saved from the viewer. + const area = mountCsv(); + editCell(area, "A1", "modifié"); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(calls.length, 1, "exactly one save request"); + assert.match(calls[0].url, /\/api\/file\/V\/csv\/save\?path=budget\.csv/); + assert.deepEqual(calls[0].body.cells, { A1: "modifié" }); + // Committed: the cell is no longer dirty and the button is disabled again. + assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); + assert.equal(area.querySelector("#xlsx-save-btn").disabled, true); +}); + await test("the + button adds a sheet through the structure endpoint", async () => { const area = mount(); apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT @@ -967,7 +1005,7 @@ await test("clicking a named range reveals its first cell in the grid", async () const row = area.querySelector(".xlsx-range-row[data-ref]"); assert.ok(row, "the named range row is rendered"); row.click(); - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1", "the anchor cell becomes active"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1", "the anchor cell becomes active"); assert.ok(area.querySelector('td[data-cell="A1"]').classList.contains("xlsx-active")); }); @@ -1014,7 +1052,7 @@ await test("clear contents empties the selected cells (local edit)", () => { await test("clicking a column header selects the whole column", () => { const area = mount(); area.querySelector(".xlsx-table thead th:nth-child(2)").click(); // column A - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:A2"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:A2"); const selected = [...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort(); assert.deepEqual(selected, ["A1", "A2"]); }); @@ -1022,7 +1060,7 @@ await test("clicking a column header selects the whole column", () => { await test("clicking a row header selects the whole row", () => { const area = mount(); area.querySelector("th.xlsx-rownum").click(); // row 1 - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B1"); }); await test("dragging from a cell to another selects the range", () => { @@ -1033,7 +1071,7 @@ await test("dragging from a cell to another selects the range", () => { a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 })); b2.dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 })); viewer.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true })); - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B2"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B2"); assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4); }); @@ -1042,7 +1080,7 @@ await test("Shift+Arrow extends the selection from the anchor", () => { const a1 = area.querySelector('td[data-cell="A1"]'); a1.focus(); a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true, bubbles: true })); - assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B1"); assert.equal(area.querySelectorAll("td.xlsx-selected").length, 2); }); @@ -1057,6 +1095,778 @@ await test("the context menu module exposes open/close helpers", async () => { assert.equal(mod.isContextMenuOpen(), false); }); +// ── #156 (A5/A6/A7) — presse-papiers, clavier complet, zone Nom ──────────── + +/** #156-A5 — a 6×6 grid (A1:F6) so a pasted block has room to land. */ +const mountWide = () => { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + const cols = ["A", "B", "C", "D", "E", "F"]; + const head = '' + + cols.map((c) => `${c}`).join("") + ""; + let body = ""; + for (let r = 1; r <= 6; r++) { + body += `${r}` + + cols.map((c) => `${c}${r}`).join("") + ""; + } + body += ""; + const html = `
${head}${body}
`; + renderXlsxViewer(area, { + vault: "V", path: "data.xlsx", is_xlsx: true, + xlsx_sheets: [{ name: "Grille", html, rows: 6, cols: 6, total_rows: 6, total_cols: 6, max_rows: 500, max_cols: 40, truncated: false }], + xlsx_lossy_features: [], + }); + return area; +}; + +/** + * Dispatch a copy/cut/paste event carrying a plain-text payload (JSDOM has no + * real clipboard, so `clipboardData` is a minimal stub). + */ +const clipboardEvent = (type, area, ref, text) => { + // A leftover document selection would make the handler think the user is + // editing a cell: start from a clean selection. + const sel = w.getSelection(); + if (sel && sel.removeAllRanges) sel.removeAllRanges(); + const td = area.querySelector(`td[data-cell="${ref}"]`); + assert.ok(td, `cell ${ref} exists`); + const store = { text }; + const ev = new w.Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(ev, "clipboardData", { + value: { + getData: (kind) => (kind === "text/plain" || kind === "text" ? store.text : ""), + setData: (kind, value) => { + if (kind === "text/plain" || kind === "text") store.text = value; + }, + }, + }); + td.dispatchEvent(ev); + return { td, store, ev }; +}; + +/** Select A1:B2 by dragging (the gesture the grid listens to). */ +const dragSelect = (area, from, to) => { + area.querySelector(`td[data-cell="${from}"]`).dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 })); + area.querySelector(`td[data-cell="${to}"]`).dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 })); + area.querySelector(".xlsx-viewer").dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true })); +}; + +const selectedRefs = (area) => + [...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort(); + +const cellTexts = (area, refs) => refs.map((r) => area.querySelector(`td[data-cell="${r}"]`).textContent); + +const toastCount = () => document.querySelectorAll("#toast-container .toast").length; + +const pressKey = (target, key, mods = {}) => + target.dispatchEvent(new w.KeyboardEvent("keydown", { key, bubbles: true, ...mods })); + +// ── A5 — presse-papiers de plage ──────────────────────────────────────────── + +await test("copying a range exports TSV and pasting it fills the target block", () => { + const area = mountWide(); + dragSelect(area, "A1", "B2"); + const copied = clipboardEvent("copy", area, "A1", ""); + assert.equal(copied.ev.defaultPrevented, true, "the range copy is handled by the grid"); + assert.equal(copied.store.text, "A1\tB1\nA2\tB2", "TSV block on the clipboard"); + + const pasted = clipboardEvent("paste", area, "D5", copied.store.text); + assert.equal(pasted.ev.defaultPrevented, true); + assert.deepEqual(cellTexts(area, ["D5", "E5", "D6", "E6"]), ["A1", "B1", "A2", "B2"]); + assert.equal(area.querySelector('td[data-cell="F6"]').textContent, "F6", "the rest of the grid is untouched"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "D5:E6", "the pasted block stays selected"); + assert.deepEqual(selectedRefs(area), ["D5", "D6", "E5", "E6"]); + assert.equal(area.querySelector("#xlsx-save-btn").disabled, false, "the paste is unsaved work"); +}); + +await test("a block copied in Excel pastes as a range (trailing newline ignored)", () => { + const area = mountWide(); + clipboardEvent("paste", area, "A3", "1\t2\n3\t4\n"); + assert.deepEqual(cellTexts(area, ["A3", "B3", "A4", "B4"]), ["1", "2", "3", "4"]); + assert.equal(area.querySelector('td[data-cell="A5"]').textContent, "A5", "no phantom fifth row"); +}); + +await test("a single-cell paste replaces the cell content", () => { + const area = mountWide(); + const td = area.querySelector('td[data-cell="C4"]'); + td.focus(); + clipboardEvent("paste", area, "C4", "remplacé"); + assert.equal(td.textContent, "remplacé"); + assert.equal(td.classList.contains("xlsx-dirty"), true); + assert.equal(area.querySelector("#xlsx-formula-input").value, "remplacé", "the bar follows"); +}); + +await test("cutting a range clears the source once it is pasted elsewhere", () => { + const area = mountWide(); + area.querySelector("th.xlsx-rownum").click(); // row 1 of a 6-column grid + const cut = clipboardEvent("cut", area, "A1", ""); + assert.equal(cut.store.text, "A1\tB1\tC1\tD1\tE1\tF1"); + clipboardEvent("paste", area, "A5", cut.store.text); + assert.deepEqual(cellTexts(area, ["A5", "B5"]), ["A1", "B1"]); + assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "", "the cut source is cleared"); + assert.equal(area.querySelector('td[data-cell="B1"]').textContent, ""); +}); + +await test("cut then paste in place keeps the pasted values", () => { + const area = mountWide(); + area.querySelector("th.xlsx-rownum").click(); // A1:F1 + const cut = clipboardEvent("cut", area, "A1", ""); + clipboardEvent("paste", area, "A1", cut.store.text); + assert.deepEqual(cellTexts(area, ["A1", "B1"]), ["A1", "B1"], "no self-erase"); +}); + +await test("the context menu copies and pastes a block", () => { + const area = mountWide(); + openMenuOn(area, "A1"); + assert.ok(menuItem(FR["xlsx.copy"]), "Copier"); + assert.ok(menuItem(FR["xlsx.cut"]), "Couper"); + assert.ok(menuItem(FR["xlsx.paste"]), "Coller"); + area.querySelector("th.xlsx-rownum").click(); + openMenuOn(area, "A1"); + menuItem(FR["xlsx.copy"]).click(); + openMenuOn(area, "A4"); + menuItem(FR["xlsx.paste"]).click(); + assert.equal(area.querySelector('td[data-cell="A4"]').textContent, "A1"); + assert.equal(area.querySelector('td[data-cell="B4"]').textContent, "B1"); +}); + +await test("pasting with a stale clipboard is a no-op with a hint", async () => { + const area = mountWide(); + const before = toastCount(); + openMenuOn(area, "A1"); + menuItem(FR["xlsx.paste"]).click(); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(toastCount(), before + 1, "the user is told the clipboard is unusable"); + assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "A1", "nothing was written"); +}); + +await test("a block that overflows the rendered grid is trimmed and signalled", () => { + const area = mountWide(); + const before = toastCount(); + clipboardEvent("paste", area, "E6", "1\t2\t3\n4\t5\t6"); + assert.deepEqual(cellTexts(area, ["E6", "F6"]), ["1", "2"]); + assert.equal(area.querySelector('td[data-cell="F7"]'), null); + assert.equal(toastCount(), before + 1, "the overflow is announced"); +}); + +await test("a pasted block is undoable cell by cell", () => { + const area = mountWide(); + clipboardEvent("paste", area, "D5", "x\ty"); + area.querySelector("#xlsx-undo-btn").click(); + assert.equal(area.querySelector('td[data-cell="E5"]').textContent, "E5", "the last pasted cell is back"); + area.querySelector("#xlsx-undo-btn").click(); + assert.equal(area.querySelector('td[data-cell="D5"]').textContent, "D5"); +}); + +await test("the cached-value shadow never leaks into a copied block", () => { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + const html = '
' + + '' + + '' + + "
A
1=SOMME(B1:B2)' + + '42
"; + renderXlsxViewer(area, { + vault: "V", path: "data.xlsx", is_xlsx: true, + xlsx_sheets: [{ name: "F", html }], + xlsx_lossy_features: [], + }); + area.querySelector('td[data-cell="A1"]').focus(); + const copied = clipboardEvent("copy", area, "A1", ""); + assert.equal(copied.store.text, "=SOMME(B1:B2)", "the formula, not Excel's shadow value"); +}); + +// ── A6 — clavier complet ──────────────────────────────────────────────────── + +await test("Ctrl+S saves the pending edits", async () => { + const area = mountGrid(); + editCell(area, "A1", "Ctrl+S"); + pressKey(area.querySelector(".xlsx-viewer"), "s", { ctrlKey: true }); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(calls.length, 1, "one save request"); + assert.match(calls[0].url, /\/xlsx\/save/); + assert.deepEqual(calls[0].body.cells, { A1: "Ctrl+S" }); +}); + +await test("Suppr clears the selected range and stays undoable", () => { + const area = mountGrid(); + area.querySelector("th.xlsx-rownum").click(); // A1:B1 + pressKey(area.querySelector(".xlsx-viewer"), "Delete"); + assert.deepEqual(cellTexts(area, ["A1", "B1"]), ["", ""]); + assert.equal(area.querySelector('td[data-cell="A2"]').textContent, "Abricot", "only the selection"); + area.querySelector("#xlsx-undo-btn").click(); + assert.equal(area.querySelector('td[data-cell="B1"]').textContent, "3"); + area.querySelector("#xlsx-undo-btn").click(); + assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "Banane"); +}); + +await test("Suppr while typing in a cell deletes text instead of the selection", () => { + const area = mountGrid(); + const td = area.querySelector('td[data-cell="A1"]'); + td.focus(); + pressKey(td, "x"); // typing switches the cell to edit mode + assert.equal(td.dataset.editing, "1", "typing enters edit mode"); + pressKey(td, "Delete"); + assert.equal(td.textContent, "Banane", "the caret key was not hijacked"); +}); + +await test("F2 enters the cell for text editing", () => { + const area = mountGrid(); + const td = area.querySelector('td[data-cell="A1"]'); + td.focus(); + assert.equal(td.dataset.editing, "", "navigation mode after a plain focus"); + pressKey(td, "F2"); + assert.equal(td.dataset.editing, "1"); +}); + +await test("Maj+Entrée inserts a line break inside the cell", () => { + const area = mountGrid(); + const td = area.querySelector('td[data-cell="A1"]'); + td.focus(); + pressKey(td, "Enter", { shiftKey: true }); + assert.ok(td.textContent.includes("\n"), `expected a newline in ${JSON.stringify(td.textContent)}`); + assert.equal(td.classList.contains("xlsx-dirty"), true); +}); + +await test("Ctrl+A selects the rendered sheet, Ctrl+Home/End walk its corners", () => { + const area = mountGrid(); + const viewer = area.querySelector(".xlsx-viewer"); + area.querySelector('td[data-cell="B2"]').focus(); + pressKey(viewer, "a", { ctrlKey: true }); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B3"); + assert.equal(area.querySelectorAll("td.xlsx-selected").length, 6); + pressKey(viewer, "Home", { ctrlKey: true }); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1"); + pressKey(viewer, "End", { ctrlKey: true }); + assert.equal(area.querySelector("#xlsx-active-cell").value, "B3"); +}); + +await test("Home/End reach the row edges, Ctrl+flèches the data run", () => { + const area = mountWide(); + const viewer = area.querySelector(".xlsx-viewer"); + area.querySelector('td[data-cell="C3"]').focus(); + pressKey(viewer, "End"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "F3"); + pressKey(viewer, "Home"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A3"); + pressKey(viewer, "ArrowDown", { ctrlKey: true }); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A6", "end of the filled column"); +}); + +await test("PageDown/PageUp move by a screenful", () => { + const area = mountWide(); + const viewer = area.querySelector(".xlsx-viewer"); + area.querySelector('td[data-cell="A1"]').focus(); + pressKey(viewer, "PageDown"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A6", "clamped to the last rendered row"); + pressKey(viewer, "PageUp"); + assert.equal(area.querySelector("#xlsx-active-cell").value, "A1"); +}); + +// ── A7 — zone Nom éditable & aide à la saisie ────────────────────────────── + +await test("typing a reference in the Name Box activates that cell", () => { + const area = mountGrid(); + const box = area.querySelector("#xlsx-active-cell"); + box.value = "B3"; + pressKey(box, "Enter"); + assert.equal(box.value, "B3"); + assert.equal(area.querySelector('td[data-cell="B3"]').classList.contains("xlsx-active"), true); + assert.equal(area.querySelector("#xlsx-formula-input").value, "2"); +}); + +await test("the Name Box accepts a range and normalises it", () => { + const area = mountGrid(); + const box = area.querySelector("#xlsx-active-cell"); + box.value = "$a1:$b2"; + pressKey(box, "Enter"); + assert.equal(box.value, "A1:B2"); + assert.deepEqual(selectedRefs(area), ["A1", "A2", "B1", "B2"]); +}); + +await test("an unknown reference is refused and the previous address restored", () => { + const area = mountGrid(); + area.querySelector('td[data-cell="A1"]').focus(); + const box = area.querySelector("#xlsx-active-cell"); + const before = toastCount(); + box.value = "XYZ"; + pressKey(box, "Enter"); + assert.equal(box.value, "A1"); + assert.equal(toastCount(), before + 1, "the user is warned"); +}); + +await test("a reference naming another sheet switches tab", () => { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + const other = '
' + + '' + + '
A
1Autre
'; + renderXlsxViewer(area, { + vault: "V", path: "data.xlsx", is_xlsx: true, + xlsx_sheets: [ + { name: "Feuille1", html: sheetHtml("100") }, + { name: "Autre", html: other }, + ], + xlsx_lossy_features: [], + }); + const box = area.querySelector("#xlsx-active-cell"); + box.value = "Autre!A1"; + pressKey(box, "Enter"); + assert.equal(box.value, "A1"); + assert.equal(area.querySelector('.xlsx-tab[data-sheet="1"]').classList.contains("active"), true); + assert.equal(area.querySelector('.xlsx-panel[data-sheet="1"]').style.display, "", "the target sheet is shown"); +}); + +await test("the formula bar offers function-name suggestions", () => { + const area = mountGrid(); + const list = area.querySelector("#xlsx-function-list"); + assert.ok(list, "the datalist exists"); + const names = [...list.querySelectorAll("option")].map((o) => o.value); + assert.ok(names.length >= 10, `expected a list of functions, got ${names.length}`); + assert.ok(names.includes("SOMME"), names.join(",")); + assert.equal(area.querySelector("#xlsx-formula-input").getAttribute("list"), "xlsx-function-list"); +}); + +// ── #156-A9 — calcul : pas de moteur, l'écart est annoncé ─────────────────── + +await test("saving a value starting with '=' warns it is stored as text (#156-A9)", async () => { + const area = mountGrid(); + const before = toastCount(); + editCell(area, "A1", "=B1*2"); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(calls.length, 1, "the save still goes out"); + assert.equal(calls[0].body.allow_formula, false); + const added = [...document.querySelectorAll("#toast-container .toast")].slice(before); + assert.ok(added.some((n) => n.textContent.includes("f(x")), "the f(x) hint is shown"); +}); + +// ── #156-A10 — undo/redo unifié ───────────────────────────────────────────── + +await test("undo replays an edit, a clear, then a sort (#156-A10)", () => { + const area = mountGrid(); + const undoBtn = area.querySelector("#xlsx-undo-btn"); + const colA = () => [...area.querySelectorAll('td[data-cell^="A"]')].map((td) => td.textContent); + + const a1 = area.querySelector('td[data-cell="A1"]'); + a1.focus(); + editCell(area, "A1", "modifié"); + a1.dispatchEvent(new w.Event("blur")); + area.querySelector("th.xlsx-rownum").click(); + pressKey(area.querySelector(".xlsx-viewer"), "Delete"); + openMenuOn(area, "B1"); + menuItem(FR["xlsx.sort_desc"]).click(); + assert.deepEqual(colA(), ["Cerise", "Abricot", ""], "sorted descending with the cleared row last"); + + undoBtn.click(); // the sort + assert.deepEqual(colA(), ["", "Abricot", "Cerise"], "the row order is restored"); + undoBtn.click(); // the clear (B1) + assert.equal(area.querySelector('td[data-cell="B1"]').textContent, "3"); + undoBtn.click(); // the clear (A1) + assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "modifié"); + undoBtn.click(); // the edit + assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "Banane"); + assert.equal(undoBtn.disabled, true, "the whole history is consumed"); +}); + +await test("a row insertion is undoable through the structure endpoint (#156-A10)", async () => { + const area = mountGrid(); + apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); + apiQueue.push({ + ok: true, status: 200, + body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] }, + }); + openMenuOn(area, "A2"); + menuItem(FR["xlsx.insert_row_above"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(calls[0].body.actions, [{ op: "row_insert", sheet: "Fruits", at: 2, count: 1 }]); + assert.equal(area.querySelector("#xlsx-undo-btn").disabled, false, "the history survived the re-render"); + + area.querySelector("#xlsx-undo-btn").click(); + await new Promise((r) => setTimeout(r, 10)); + assert.match(calls[2].url, /\/xlsx\/structure/); + assert.deepEqual(calls[2].body.actions, [{ op: "row_delete", sheet: "Fruits", at: 2, count: 1 }], "the inverse batch"); +}); + +await test("a destructive structure action is not undoable (#156-A10)", async () => { + const area = mountGrid(); + apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); + apiQueue.push({ + ok: true, status: 200, + body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] }, + }); + openMenuOn(area, "A2"); + menuItem(FR["xlsx.delete_rows"].replace("{n}", "1")).click(); + clickDialog("confirm"); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(calls[0].body.actions, [{ op: "row_delete", sheet: "Fruits", at: 2, count: 1 }]); + assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true, "a delete cannot be replayed"); +}); + +await test("the filter is recorded once and cleared by a single undo (#156-A10)", () => { + const area = mountGrid(); + const input = area.querySelector("#xlsx-find-input"); + const hidden = () => [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display === "none").length; + input.value = "ab"; + input.dispatchEvent(new w.Event("input", { bubbles: true })); + input.value = "abr"; + input.dispatchEvent(new w.Event("input", { bubbles: true })); + assert.equal(hidden(), 2, "two rows are hidden"); + area.querySelector("#xlsx-undo-btn").click(); + assert.equal(hidden(), 0, "one undo clears the whole typing session"); + assert.equal(input.value, "", "the field follows"); +}); + +// ── #156-A12 — concurrence optimiste (ETag / If-Match) ────────────────────── + +await test("a save carries the read revision and refreshes it from the response (#156-A12)", async () => { + const area = mount({ revision: "rev-1" }); + editCell(area, "A1", "42"); + apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(calls[0].body.if_match, "rev-1", "the read token is sent back"); + + // A second save chains on the revision the first response returned. + editCell(area, "A2", "43"); + apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-3" } }); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(calls[1].body.if_match, "rev-2"); +}); + +await test("a stale revision is repaired by re-reading before the retry (#156-A12)", async () => { + const area = mount({ revision: "rev-1" }); + editCell(area, "A1", "42"); + // 1) the save is refused as stale, 2) the retry re-reads the file, 3) the + // re-send succeeds — all queued up front so the re-read really answers. + apiQueue.push({ + ok: false, status: 409, + body: { detail: "changed on disk", code: "conflict", details: { reason: "stale_revision", current_revision: "rev-9" } }, + }); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 10)); + const banner = area.querySelector(".xlsx-banner-conflict"); + assert.ok(banner, "the conflict banner is shown"); + assert.equal(calls[0].body.if_match, "rev-1"); + + // The retry re-reads the file, then re-sends with the CURRENT revision. + apiQueue.push({ + ok: true, status: 200, + body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_revision: "rev-9", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [] }, + }); + apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-10" } }); + banner.querySelector(".xlsx-banner-retry").click(); + await new Promise((r) => setTimeout(r, 10)); + assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/); + assert.equal(calls[2].body.if_match, "rev-9", "the retry uses the revision just read"); + assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); +}); + +await test("a structural write carries the revision too (#156-A12)", async () => { + const area = mountGrid("rev-1"); + apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); + apiQueue.push({ + ok: true, status: 200, + body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] }, + }); + openMenuOn(area, "A2"); + menuItem(FR["xlsx.insert_row_above"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(calls[0].body.if_match, "rev-1"); +}); + +// ── #156-A8 — mise en forme en écriture ──────────────────────────────────── + +/** + * Make `ref` the ACTIVE cell: the grid wires setActiveCell() on the cell's + * focus event, and td.focus() runs it in JSDOM (a synthetic click would not). + */ +const activateCell = (area, ref) => { + const td = area.querySelector(`td[data-cell="${ref}"]`); + td.focus(); + return td; +}; + +const openFormatMenu = (area) => { + area.querySelector("#xlsx-format-btn").click(); + return area.querySelector(".xlsx-format-menu"); +}; + +const formatItem = (area, label) => { + const b = [...area.querySelectorAll(".xlsx-format-menu .xlsx-structure-item")] + .find((item) => item.textContent === label); + assert.ok(b, `format item "${label}" is present`); + return b; +}; + +/** Queue the PUT then the re-read the formatting flow performs. */ +const queueStyleWrite = () => { + apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); + apiQueue.push({ + ok: true, status: 200, + body: { + is_xlsx: true, vault: "V", path: "data.xlsx", + xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], + xlsx_lossy_features: [], + }, + }); +}; + +await test("the formatting menu is offered on an editable workbook only (#156-A8)", () => { + const area = mountGrid(); + assert.ok(area.querySelector("#xlsx-format-btn"), "the button is there"); + assert.equal(area.querySelector("#xlsx-format-btn").getAttribute("title"), FR["xlsx.format_btn"]); + const csv = mountCsv(); + assert.equal(csv.querySelector("#xlsx-format-btn"), null, "no formatting on a csv"); +}); + +await test("bold applied to a selection goes to /xlsx/style and re-renders (#156-A8)", async () => { + const area = mountGrid(); + dragSelect(area, "A1", "B2"); + openFormatMenu(area); + queueStyleWrite(); + formatItem(area, FR["xlsx.format_bold"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.match(calls[0].url, /\/xlsx\/style\?path=data\.xlsx/); + assert.deepEqual(calls[0].body.ops, [ + { op: "cell", sheet: "Fruits", range: "A1:B2", style: { bold: true } }, + ]); + assert.equal(calls[0].body.force, false); + assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/, "the viewer re-reads the file"); +}); + +await test("the bold toggle removes an existing bold (#156-A8)", async () => { + const area = mountGrid(); + activateCell(area, "A1").style.fontWeight = "600"; + openFormatMenu(area); + queueStyleWrite(); + formatItem(area, FR["xlsx.format_bold"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(calls[0].body.ops, [ + { op: "cell", sheet: "Fruits", range: "A1", style: { bold: false } }, + ]); +}); + +await test("alignment, underline and number formats are written (#156-A8)", async () => { + const area = mountGrid(); + activateCell(area, "B2"); + openFormatMenu(area); + queueStyleWrite(); + formatItem(area, FR["xlsx.format_align_center"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(calls[0].body.ops[0].style, { align: "center" }); + + // `calls` keeps accumulating (same test): the second write is calls[2]. + const area2 = mountGrid(); + activateCell(area2, "B2"); + openFormatMenu(area2); + queueStyleWrite(); + formatItem(area2, FR["xlsx.format_num_percent"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(calls.length, 4, "PUT + re-read for each formatting write"); + assert.deepEqual(calls[2].body.ops[0].style, { number_format: "0.0%" }); + assert.equal(calls[2].body.ops[0].range, "B2"); +}); + +await test("merging needs a real range (#156-A8)", async () => { + const area = mountGrid(); + activateCell(area, "A1"); + const before = toastCount(); + openFormatMenu(area); + formatItem(area, FR["xlsx.format_merge"]).click(); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(calls.length, 0, "nothing is written for a single cell"); + assert.equal(toastCount(), before + 1, "the user is told why"); +}); + +await test("the merge action carries the selected range (#156-A8)", async () => { + const area = mountGrid(); + dragSelect(area, "A1", "B1"); + openFormatMenu(area); + queueStyleWrite(); + formatItem(area, FR["xlsx.format_merge"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(calls[0].body.ops, [{ op: "merge", sheet: "Fruits", range: "A1:B1" }]); +}); + +await test("freezing uses the active cell, unfreezing sends an empty one (#156-A8)", async () => { + const area = mountGrid(); + activateCell(area, "B3"); + openFormatMenu(area); + queueStyleWrite(); + formatItem(area, FR["xlsx.format_freeze"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(calls[0].body.ops, [{ op: "freeze", sheet: "Fruits", cell: "B3" }]); + + const area2 = mountGrid(); + openFormatMenu(area2); + queueStyleWrite(); + formatItem(area2, FR["xlsx.format_unfreeze"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(calls[2].body.ops, [{ op: "freeze", sheet: "Fruits", cell: "" }]); +}); + +await test("the column width prompt writes one op per selected column (#156-A8)", async () => { + const area = mountGrid(); + dragSelect(area, "A1", "B2"); + openFormatMenu(area); + formatItem(area, FR["xlsx.format_col_width"]).click(); + await new Promise((r) => setTimeout(r, 5)); + const input = lastDialog()?.querySelector('[data-dialog="input"]'); + assert.ok(input, "a themed prompt asks for the width"); + input.value = "30"; + clickDialog("confirm"); + queueStyleWrite(); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(calls[0].body.ops, [ + { op: "col_width", sheet: "Fruits", col: "A", width: 30 }, + { op: "col_width", sheet: "Fruits", col: "B", width: 30 }, + ]); +}); + +await test("a lossy refusal asks once then retries the style with force (#156-A8)", async () => { + const area = mount({ lossy: ["cached_values"] }); + activateCell(area, "A1"); + openFormatMenu(area); + apiQueue.push(lossyError); // 1) the style write is refused as lossy + apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); // 2) forced retry + apiQueue.push({ + ok: true, status: 200, + body: { + is_xlsx: true, vault: "V", path: "data.xlsx", + xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], + xlsx_lossy_features: [], + }, + }); // 3) the re-read + formatItem(area, FR["xlsx.format_italic"]).click(); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(calls.length, 1, "nothing is retried before the answer"); + const dialog = lastDialog(); + assert.ok(dialog, "a themed confirmation is shown"); + // The 409 payload lists the features (slicers), not the mount's list. + assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent); + clickDialog("confirm"); + await new Promise((r) => setTimeout(r, 10)); + const retry = calls.find((c) => c.body && c.body.force === true); + assert.ok(retry, "the retry goes out with force"); + assert.match(retry.url, /\/xlsx\/style\?path=data\.xlsx/); +}); + +// ── #156-A11 — sortie (CSV/MD/HTML/impression) + recherche classeur ──────── + +/** + * Click an export control and capture the file name and the payload it built. + * The real /Blob constructors are swapped out because JSDOM has neither + * downloads nor object URLs. + */ +const captureExport = (area, selector) => { + const realCreate = document.createElement.bind(document); + const realBlob = globalThis.Blob; + const anchor = realCreate("a"); + let href = ""; + let text = ""; + Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href }); + document.createElement = (tag) => (tag === "a" ? anchor : realCreate(tag)); + URL.createObjectURL = () => "blob:x"; + URL.revokeObjectURL = () => {}; + globalThis.Blob = class extends realBlob { + constructor(parts, opts) { + super(parts, opts); + text = parts.join(""); + } + }; + try { + area.querySelector(selector).click(); + } finally { + document.createElement = realCreate; + globalThis.Blob = realBlob; + } + return { anchor, text, href }; +}; + +await test("the CSV export carries the selection when a range is active (#156-A11)", () => { + const area = mountGrid(); + dragSelect(area, "A1", "B2"); + const { anchor, text } = captureExport(area, "#xlsx-csv-btn"); + assert.equal(anchor.download, "Fruits-A1B2.csv", "the file name pins the range"); + assert.equal(text, "\uFEFFBanane;3\nAbricot;10", text); +}); + +await test("without a selection the export still carries the whole sheet (#156-A11)", () => { + const area = mountGrid(); + const { anchor, text } = captureExport(area, "#xlsx-csv-btn"); + assert.equal(anchor.download, "Fruits.csv"); + assert.equal(text.split("\n").length, 3, text); + assert.ok(text.includes("Cerise"), text); +}); + +await test("the export menu offers Markdown and writes a table (#156-A11)", () => { + const area = mountGrid(); + area.querySelector("#xlsx-export-btn").click(); + assert.equal(area.querySelector("#xlsx-export-menu").style.display, "block"); + const { anchor, text } = captureExport(area, '[data-xlsx-export="md"]'); + assert.equal(anchor.download, "Fruits.md"); + assert.equal(text.split("\n")[0], "| Banane | 3 |"); + assert.equal(text.split("\n")[1], "|---|---|"); + assert.equal(area.querySelector("#xlsx-export-menu").style.display, "none", "the menu closes"); +}); + +await test("the export menu writes a standalone HTML document (#156-A11)", () => { + const area = mountGrid(); + area.querySelector("#xlsx-export-btn").click(); + const { anchor, text } = captureExport(area, '[data-xlsx-export="html"]'); + assert.equal(anchor.download, "Fruits.html"); + assert.ok(text.startsWith(""), text.slice(0, 40)); + assert.ok(text.includes("Banane"), text); + assert.ok(text.includes("Cerise"), text); +}); + +await test("printing builds an off-screen frame and never touches the app chrome (#156-A11)", () => { + const area = mountGrid(); + area.querySelector("#xlsx-export-btn").click(); + area.querySelector('[data-xlsx-export="print"]').click(); + const frame = document.querySelector("body > iframe.xlsx-print-frame"); + assert.ok(frame, "a print frame is created"); + const doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document); + assert.ok(doc, "the frame carries its own document"); + assert.ok(doc.body.textContent.includes("Banane"), doc.body.textContent); + assert.equal(document.body.classList.contains("xlsx-print"), false, "the print class is cleared"); + // A second print does not pile frames up. + area.querySelector("#xlsx-export-btn").click(); + area.querySelector('[data-xlsx-export="print"]').click(); + assert.equal(document.querySelectorAll("body > iframe.xlsx-print-frame").length, 1); +}); + +await test("the find scans every sheet and steps across tabs (#156-A11)", () => { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + const oneCell = (value) => + '
' + + '' + + `` + + "
AB
1${value}x
"; + renderXlsxViewer(area, { + vault: "V", path: "data.xlsx", is_xlsx: true, + xlsx_sheets: [ + { name: "Un", html: oneCell("pomme") }, + { name: "Deux", html: oneCell("pomme") }, + ], + xlsx_lossy_features: [], + }); + const input = area.querySelector("#xlsx-find-input"); + input.value = "pomme"; + input.dispatchEvent(new w.Event("input", { bubbles: true })); + assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 2, "both sheets are scanned"); + assert.ok(area.querySelector("#xlsx-find-count").textContent.includes("2 feuilles"), area.querySelector("#xlsx-find-count").textContent); + assert.equal(area.querySelector('.xlsx-panel[data-sheet="1"]').style.display, "none", "sheet 2 stays hidden while its hit is not focused"); + area.querySelector("#xlsx-find-next").click(); + assert.equal(area.querySelector('.xlsx-tab[data-sheet="1"]').classList.contains("active"), true, "the tab of the hit is activated"); + assert.equal(area.querySelector('.xlsx-panel[data-sheet="1"]').style.display, "", "the sheet holding the hit is shown"); + assert.equal(area.querySelectorAll("mark.xlsx-find-current").length, 1, "exactly one hit is current"); + assert.ok(area.querySelector('.xlsx-panel[data-sheet="1"] mark.xlsx-find-current'), "the current hit sits in sheet 2"); +}); + // ── Report ────────────────────────────────────────────────────────────────── console.log(`\n${passCount}/${testCount} tests passed\n`); process.exit(passCount === testCount ? 0 : 1); diff --git a/tests/test_spreadsheet_tools.py b/tests/test_spreadsheet_tools.py index 0fbed68..8cb4a32 100644 --- a/tests/test_spreadsheet_tools.py +++ b/tests/test_spreadsheet_tools.py @@ -223,3 +223,197 @@ class TestAppendXlsxRows: "vault": "Vault", "path": "classeur.xlsx", "sheet": "Budget", "rows": [], }) + + +# ── #156-A14 — formats étendus, recherche, analyse de plage, structure ────── + + +@pytest.fixture +def notes_csv(vault: Path) -> Path: + """A `;`-separated CSV — the locale the viewer sniffs (BUG-098).""" + path = vault / "notes.csv" + path.write_text("Poste;Montant\nLoyer;900\nCourses;250\n", encoding="utf-8") + return path + + +class TestRegistryExtended: + def test_read_tools_include_search_and_analyze(self): + for name in ("search_workbook", "analyze_range"): + spec = get_tool(name) + assert spec is not None + assert spec.risk == ToolRisk.READ + + def test_structure_tool_requires_confirmation(self, vault, workbook): + spec = get_tool("edit_xlsx_structure") + assert spec is not None + assert spec.risk == ToolRisk.WRITE + with pytest.raises(ToolConfirmationRequired): + call_tool("edit_xlsx_structure", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}], + }) + + +class TestXlsmSupport: + def test_reads_and_edits_an_xlsm(self, vault): + path = vault / "macro.xlsm" + wb = openpyxl.Workbook() + wb.active.title = "Donnees" + wb.active.append(["A", "B"]) + wb.active.append([1, 2]) + wb.save(path) + + out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "macro.xlsm"}) + assert out.ok and out.data["sheets"][0]["name"] == "Donnees" + md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "macro.xlsm"}) + assert "| A | B |" in md.data["markdown"] + + edit = call_tool("update_xlsx_cells", _ctx(), { + "vault": "Vault", "path": "macro.xlsm", "sheet": "Donnees", + "cells": {"A2": "9"}, + }, confirm=True) + assert edit.ok + wb2 = openpyxl.load_workbook(path) + assert wb2["Donnees"]["A2"].value == 9 + wb2.close() + + +class TestCsvSupport: + def test_lists_reads_and_searches_a_csv(self, vault, notes_csv): + out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "notes.csv"}) + assert out.ok + assert out.data["sheets"] == [ + {"name": "notes", "total_rows": 3, "total_cols": 2, "truncated": False} + ] + md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "notes.csv"}) + assert "| Poste | Montant |" in md.data["markdown"] + found = call_tool("search_workbook", _ctx(), { + "vault": "Vault", "path": "notes.csv", "query": "Loyer", + }) + assert found.ok + assert found.data["total"] == 1 + assert found.data["matches"][0] == {"sheet": "notes", "cell": "A2", "value": "Loyer"} + assert found.data["by_sheet"] == {"notes": 1} + + def test_updates_a_csv_keeping_its_delimiter(self, vault, notes_csv): + out = call_tool("update_xlsx_cells", _ctx(), { + "vault": "Vault", "path": "notes.csv", "cells": {"B2": "1000"}, + }, confirm=True) + assert out.ok + assert "Loyer;1000" in notes_csv.read_text(encoding="utf-8-sig") + + def test_append_rows_refuses_a_csv(self, vault, notes_csv): + with pytest.raises(ToolError): + call_tool("append_xlsx_rows", _ctx(), { + "vault": "Vault", "path": "notes.csv", + "sheet": "notes", "rows": [["x"]], + }, confirm=True) + + def test_structure_refuses_a_csv(self, vault, notes_csv): + with pytest.raises(ToolError): + call_tool("edit_xlsx_structure", _ctx(), { + "vault": "Vault", "path": "notes.csv", + "actions": [{"op": "sheet_add", "name": "X"}], + }, confirm=True) + + def test_wrong_extension_is_still_refused(self, vault): + with pytest.raises(ToolError): + call_tool("search_workbook", _ctx(), { + "vault": "Vault", "path": "note.md", "query": "a", + }) + + +class TestSearchWorkbook: + def test_scans_every_sheet_with_counts(self, vault, workbook): + out = call_tool("search_workbook", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", "query": "o", + }) + assert out.ok + assert out.data["by_sheet"]["Budget"] > 0 + assert out.data["by_sheet"]["Notes"] == 1 # « bonjour » : une seule cellule + assert out.data["truncated"] is False + assert out.data["total"] == sum(out.data["by_sheet"].values()) + + def test_case_sensitive_and_sheet_scope(self, vault, workbook): + loose = call_tool("search_workbook", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", "query": "LOYER", + }) + assert loose.data["total"] == 1 + strict = call_tool("search_workbook", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", "query": "LOYER", + "case_sensitive": True, + }) + assert strict.data["total"] == 0 + scoped = call_tool("search_workbook", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", "query": "bonjour", + "sheet": "Budget", + }) + assert scoped.data["total"] == 0 + + def test_empty_query_rejected(self, vault, workbook): + with pytest.raises(ToolError): + call_tool("search_workbook", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", "query": " ", + }) + + +class TestAnalyzeRange: + def test_aggregates_a_column(self, vault, workbook): + out = call_tool("analyze_range", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "sheet": "Budget", "range": "B2:B3", + }) + assert out.ok + stats = out.data["numeric"] + assert stats["count"] == 2 + assert stats["sum"] == 1150 + assert stats["mean"] == 575 + assert stats["min"] == 250 and stats["max"] == 900 + + def test_whole_sheet_and_fr_separators(self, vault): + path = vault / "prix.xlsx" + wb = openpyxl.Workbook() + ws = wb.active + ws.append(["Article", "Prix"]) + ws.append(["A", "1 234,5"]) + ws.append(["B", 10]) + wb.save(path) + out = call_tool("analyze_range", _ctx(), {"vault": "Vault", "path": "prix.xlsx"}) + assert out.ok + assert out.data["numeric"]["count"] == 2 + assert out.data["numeric"]["max"] == 1234.5 + assert out.data["cells"] == 6 # 2 en-têtes + 4 valeurs + + def test_bad_range_rejected(self, vault, workbook): + with pytest.raises(ToolError): + call_tool("analyze_range", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", "range": "pas-une-plage", + }) + + +class TestEditXlsxStructure: + def test_inserts_a_row_and_survives_a_reload(self, vault, workbook): + out = call_tool("edit_xlsx_structure", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}], + }, confirm=True) + assert out.ok and out.data["actions"] == 1 + wb = openpyxl.load_workbook(workbook) + assert wb["Budget"]["A3"].value == "Loyer" # la ligne 2 a été poussée + wb.close() + + def test_adds_a_sheet(self, vault, workbook): + out = call_tool("edit_xlsx_structure", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", + "actions": [{"op": "sheet_add", "name": "Trois"}], + }, confirm=True) + assert out.ok + wb = openpyxl.load_workbook(workbook) + assert "Trois" in wb.sheetnames + wb.close() + + def test_empty_actions_rejected(self, vault, workbook): + with pytest.raises(ToolError): + call_tool("edit_xlsx_structure", _ctx(), { + "vault": "Vault", "path": "classeur.xlsx", "actions": [], + }) diff --git a/tests/test_xlsx_formats.py b/tests/test_xlsx_formats.py index 8ef20ea..03a7faa 100644 --- a/tests/test_xlsx_formats.py +++ b/tests/test_xlsx_formats.py @@ -95,9 +95,32 @@ class TestXlsmEditable: with zipfile.ZipFile(_vault_file(client, xlsm_book)) as zf: assert "xl/vbaProject.bin" in zf.namelist() - def test_lossy_gate_is_skipped_for_xlsm(self, formats_vault, xlsm_book): + def test_sheet_window_is_served_for_xlsm(self, client, xlsm_book): + """BUG-097 — a .xlsm is editable, so its lazy row windows must work.""" + resp = client.get( + f"/api/file/{VAULT}/xlsx/sheet", + params={"path": xlsm_book, "sheet": "Data", "offset": 0, "limit": 100}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["sheet"] == "Data" + assert 'data-cell="A2"' in body["html"] + assert body["has_more"] is False + + def test_sheet_window_still_refuses_other_formats(self, client, formats_vault): + (formats_vault / "plain.csv").write_text("a,b\n", encoding="utf-8") + resp = client.get( + f"/api/file/{VAULT}/xlsx/sheet", + params={"path": "plain.csv", "sheet": "Feuille1"}, + ) + assert resp.status_code == 415 + + def test_lossy_gate_is_skipped_for_xlsm(self, client, formats_vault, xlsm_book): from backend.services.mutations import edit_xlsx_cells + # `client` is required: the service resolves the vault through the app + # registry (the test used to pass only thanks to state leaked by an + # earlier test in this class). # No force flag: the save must succeed despite the vbaProject part # (it would raise xlsx_lossy_content on a plain .xlsx). result = edit_xlsx_cells(VAULT, xlsm_book, "Data", {"A2": "Alan"}) @@ -149,6 +172,22 @@ class TestLegacyReadOnly: assert sheet["rows"] == 0 +class TestCsvDelimiter: + """BUG-098 — the delimiter is detected, not assumed to be a comma.""" + + def test_sniffing_handles_the_common_separators(self): + from backend.xlsx_reader import sniff_csv_delimiter + + assert sniff_csv_delimiter("a;b\n1;2\n") == ";" + assert sniff_csv_delimiter("a,b\n1,2\n") == "," + assert sniff_csv_delimiter("a\tb\n1\t2\n") == "\t" + + def test_single_column_falls_back_to_a_comma(self): + from backend.xlsx_reader import sniff_csv_delimiter + + assert sniff_csv_delimiter("juste\nune\ncolonne\n") == "," + + class TestCsvEditable: def test_read_renders_the_xlsx_shaped_table(self, client, formats_vault): (formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8") @@ -197,6 +236,42 @@ class TestCsvEditable: text = _vault_file(client, "quotes.csv").read_text(encoding="utf-8") assert '"Martin, Pierre"' in text + def test_semicolon_csv_renders_multiple_columns(self, client, formats_vault): + """BUG-098 — a French (semicolon) CSV is no longer a single column.""" + (formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8") + html = client.get( + f"/api/file/{VAULT}", params={"path": "fr.csv"} + ).json()["html"] + assert 'data-cell="B1"' in html and 'data-cell="B2"' in html + assert "Paris" in html + + def test_semicolon_is_preserved_when_saving(self, client, formats_vault): + """BUG-098 — the write-back keeps the file's own delimiter.""" + (formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8") + resp = client.put( + f"/api/file/{VAULT}/csv/save", + params={"path": "fr.csv"}, + json={"cells": {"B2": "Lyon"}}, + ) + assert resp.status_code == 200 + text = _vault_file(client, "fr.csv").read_text(encoding="utf-8") + assert "Nom;Ville" in text + assert "Ada;Lyon" in text + assert "," not in text # the source delimiter is not switched to a comma + + def test_quoted_semicolon_survives_a_round_trip(self, client, formats_vault): + (formats_vault / "quote-fr.csv").write_text( + 'Nom;Note\n"Dupont; Jean";ok\n', encoding="utf-8" + ) + client.put( + f"/api/file/{VAULT}/csv/save", + params={"path": "quote-fr.csv"}, + json={"cells": {"B2": "modifié"}}, + ) + text = _vault_file(client, "quote-fr.csv").read_text(encoding="utf-8") + assert '"Dupont; Jean"' in text + assert ";modifié" in text + def test_bad_reference_is_refused(self, client, formats_vault): (formats_vault / "liste.csv").write_text("a\n", encoding="utf-8") resp = client.put( diff --git a/tests/test_xlsx_styles.py b/tests/test_xlsx_styles.py index b8638b1..1484cbe 100644 --- a/tests/test_xlsx_styles.py +++ b/tests/test_xlsx_styles.py @@ -35,6 +35,20 @@ def styled_book(test_vault_dir: str) -> str: return str(path) +@pytest.fixture +def plain_book(test_vault_dir: str) -> str: + """An unstyled workbook — the write side starts from a blank slate.""" + path = Path(test_vault_dir) / "plain.xlsx" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Data" + ws.append(["Nom", "Valeur"]) + ws.append(["a", 1]) + ws.append(["b", 2]) + wb.save(path) + return str(path) + + class TestStyleReading: def test_bold_italic_and_font_color(self, styled_book): from backend.xlsx_reader import read_sheet_styles @@ -82,6 +96,160 @@ class TestStyleReading: assert read_sheet_styles(bad, "Data") == {} +class TestStyleWriting: + """#156-A8 — the viewer can now WRITE formatting, not just read it. + + One locked, atomic rewrite through ``PUT …/xlsx/style``: styles (bold, + italic, underline, colours, alignment, number format), merges, column + widths, row heights and frozen panes. + """ + + def _style(self, client, path, ops, **extra): + return client.put( + f"/api/file/{VAULT}/xlsx/style", + params={"path": path}, + json={"ops": ops, **extra}, + ) + + def test_styles_survive_a_reload(self, client, plain_book): + resp = self._style(client, "plain.xlsx", [ + { + "op": "cell", "sheet": "Data", "range": "A1:B1", + "style": { + "bold": True, "italic": True, "underline": True, + "font_color": "#cc0000", "fill_color": "#ffe08a", + "align": "center", "number_format": "0.00", + }, + }, + ]) + assert resp.status_code == 200 + assert resp.json()["revision"] + wb = openpyxl.load_workbook(plain_book) + ws = wb["Data"] + assert ws["A1"].font.bold is True and ws["A1"].font.italic is True + assert ws["A1"].font.underline == "single" + assert ws["A1"].font.color.rgb == "FFCC0000" + assert ws["A1"].fill.start_color.rgb == "FFFFE08A" + assert ws["A1"].alignment.horizontal == "center" + assert ws["B1"].number_format == "0.00" + assert ws["A1"].value == "Nom", "the value is untouched" + assert not ws["A2"].font.bold, "a neighbour outside the range is untouched" + wb.close() + + def test_the_written_style_is_read_back_by_the_viewer(self, client, plain_book): + self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "fill_color": "#ffe08a"}}, + ]) + resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"}) + assert resp.status_code == 200 + sheet = resp.json()["xlsx_sheets"][0] + style = sheet["styles"]["A1"] + assert "font-weight:600" in style and "background:#ffe08a" in style + + def test_merges_sizes_and_freeze(self, client, plain_book): + resp = self._style(client, "plain.xlsx", [ + {"op": "merge", "sheet": "Data", "range": "A1:B1"}, + {"op": "col_width", "sheet": "Data", "col": "A", "width": 24}, + {"op": "row_height", "sheet": "Data", "row": 2, "height": 30}, + {"op": "freeze", "sheet": "Data", "cell": "A2"}, + ]) + assert resp.status_code == 200 + wb = openpyxl.load_workbook(plain_book) + ws = wb["Data"] + assert [str(r) for r in ws.merged_cells.ranges] == ["A1:B1"] + assert ws.column_dimensions["A"].width == 24 + assert ws.row_dimensions[2].height == 30 + assert str(ws.freeze_panes) == "A2" + wb.close() + + resp = self._style(client, "plain.xlsx", [ + {"op": "unmerge", "sheet": "Data", "range": "A1:B1"}, + {"op": "freeze", "sheet": "Data", "cell": ""}, + ]) + assert resp.status_code == 200 + wb = openpyxl.load_workbook(plain_book) + ws = wb["Data"] + assert not list(ws.merged_cells.ranges) + assert ws.freeze_panes is None + wb.close() + + def test_a_style_can_be_cleared(self, client, plain_book): + self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "font_color": "#cc0000", "fill_color": "#ffe08a"}}, + ]) + resp = self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": False, "font_color": "", "fill_color": "", "number_format": "General"}}, + ]) + assert resp.status_code == 200 + wb = openpyxl.load_workbook(plain_book) + ws = wb["Data"] + assert ws["A1"].font.bold in (None, False) + assert ws["A1"].font.color is None + assert ws["A1"].fill.fill_type is None + assert ws["A1"].number_format == "General" + wb.close() + + def test_invalid_inputs_are_refused(self, client, plain_book): + bad_style = self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A1", "style": {"blink": True}}, + ]) + assert bad_style.status_code == 400 + assert self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A1", "style": {}}, + ]).status_code == 400 + assert self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A1", "style": {"fill_color": "rouge"}}, + ]).status_code == 400 + assert self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "pas-une-plage", "style": {"bold": True}}, + ]).status_code == 400 + assert self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Nope", "range": "A1", "style": {"bold": True}}, + ]).status_code == 400 + assert self._style(client, "plain.xlsx", [{"op": "spin", "sheet": "Data"}]).status_code == 400 + assert self._style(client, "plain.xlsx", []).status_code == 400 + assert self._style(client, "plain.xlsx", [ + {"op": "col_width", "sheet": "Data", "col": "A", "width": 900}, + ]).status_code == 400 + assert self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A1", "style": {"align": "diagonal"}}, + ]).status_code == 400 + + def test_a_csv_is_refused(self, client, test_vault_dir): + (Path(test_vault_dir) / "style.csv").write_text("a,b\n1,2\n", encoding="utf-8") + resp = self._style(client, "style.csv", [ + {"op": "cell", "sheet": "x", "range": "A1", "style": {"bold": True}}, + ]) + assert resp.status_code == 400 + + def test_a_stale_revision_is_refused_and_the_fresh_one_accepted(self, client, plain_book): + first = self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}}, + ]) + assert first.status_code == 200 + stale = self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}}, + ], if_match="deadbeef-1") + assert stale.status_code == 409 + assert stale.json()["details"]["reason"] == "stale_revision" + fresh = self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}}, + ], if_match=first.json()["revision"]) + assert fresh.status_code == 200 + assert self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A3", "style": {"bold": True}}, + ], if_match=42).status_code == 400 + + def test_a_backup_is_written_before_the_rewrite(self, client, plain_book): + from backend.services.backups import get_backup_dir + + assert self._style(client, "plain.xlsx", [ + {"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}}, + ]).status_code == 200 + backups = list(get_backup_dir(VAULT, "plain.xlsx").glob("plain.xlsx.*.bak")) + assert backups, "the rewrite is backed up first" + + class TestEndpointWiring: def test_read_response_carries_styles_merges_freeze(self, client, styled_book): resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"}) diff --git a/tests/test_xlsx_viewer.py b/tests/test_xlsx_viewer.py index f17b005..1c90f75 100644 --- a/tests/test_xlsx_viewer.py +++ b/tests/test_xlsx_viewer.py @@ -474,6 +474,64 @@ class TestXlsxLossyGuard: assert inspect_workbook(bad) == [] +# ── BUG-099 — the cached-value probe must never fail silently ────────────── + + +def _probe_zip(tmp_path: Path, sheets: list[bytes]) -> Path: + """Build a minimal archive holding the given worksheet XML parts.""" + path = tmp_path / "probe.xlsx" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + for i, blob in enumerate(sheets, start=1): + zf.writestr(f"xl/worksheets/sheet{i}.xml", blob) + return path + + +class TestXlsxCachedValueProbe: + """BUG-099 — an exhausted probe is reported, never mistaken for clean.""" + + def test_exhausted_budget_is_reported_as_unverified( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + from backend import xlsx_reader + + # Budget smaller than the only sheet: the scan cannot reach its end. + monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 64) + monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 64) + book = _probe_zip(tmp_path, [b"" + b"filler" * 1024]) + + # Contre-preuve: the old code returned [] here — an interrupted scan was + # indistinguishable from a completed one, so the write guard stayed + # silent and the cached values could be dropped without warning. + assert xlsx_reader.inspect_workbook(book) == ["cached_values_unverified"] + + def test_a_huge_first_sheet_does_not_starve_the_next_one( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + from backend import xlsx_reader + + # Per-sheet cap (1 KiB) well under the total allowance (4 KiB): a single + # shared budget would spend everything on sheet 1 and never look at + # sheet 2, where the only cached formula lives. + monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 1024) + monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 4096) + cached = b'1+12' + book = _probe_zip(tmp_path, [b"filler" * 2048, cached]) + + assert "cached_values" in xlsx_reader.inspect_workbook(book) + + def test_a_completed_clean_scan_reports_nothing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + from backend import xlsx_reader + + monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 4096) + monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 8192) + book = _probe_zip(tmp_path, [b"", b""]) + + # No cached value and no budget issue: no false positive either. + assert xlsx_reader.inspect_workbook(book) == [] + + # ── #153 A2 — atomic write ─────────────────────────────────────────────── @@ -741,3 +799,164 @@ class TestXlsxFormulaGuard: ws = openpyxl.load_workbook(xlsx_file)["Budget"] assert ws["B3"].value == 42 and isinstance(ws["B3"].value, int) assert ws["B4"].value == -3.5 + + +class TestMetaCache: + """#156-A13 — ``read_workbook_meta`` is cached on (path, mtime_ns, size). + + A truncated sheet is fetched window by window: without the cache every + window paid a full normal-mode workbook load just for the style / merge / + freeze maps of one sheet. + """ + + def test_second_call_is_served_from_the_cache(self, xlsx_file, monkeypatch): + from backend import xlsx_reader + + path = Path(xlsx_file) + xlsx_reader.invalidate_workbook_meta(path) + real_load = xlsx_reader.load_workbook + loads = [] + + def counting(*args, **kwargs): + loads.append(1) + return real_load(*args, **kwargs) + + monkeypatch.setattr(xlsx_reader, "load_workbook", counting) + first = xlsx_reader.read_workbook_meta(path) + assert len(loads) == 1, "la première lecture charge le classeur" + second = xlsx_reader.read_workbook_meta(path) + assert len(loads) == 1, "la seconde lecture ne recharge pas le classeur" + assert second is first, "la même carte est servie (lecture seule côté appelant)" + assert "Budget" in first + + def test_invalidation_forces_a_reload(self, xlsx_file, monkeypatch): + from backend import xlsx_reader + + path = Path(xlsx_file) + xlsx_reader.invalidate_workbook_meta(path) + real_load = xlsx_reader.load_workbook + loads = [] + + def counting(*args, **kwargs): + loads.append(1) + return real_load(*args, **kwargs) + + monkeypatch.setattr(xlsx_reader, "load_workbook", counting) + xlsx_reader.read_workbook_meta(path) + xlsx_reader.invalidate_workbook_meta(path) + xlsx_reader.read_workbook_meta(path) + assert len(loads) == 2, "l'invalidation explicite force un rechargement" + + def test_rewriting_the_file_invalidates_the_cache(self, xlsx_file): + from backend import xlsx_reader + + path = Path(xlsx_file) + xlsx_reader.invalidate_workbook_meta(path) + assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == [] + wb = openpyxl.load_workbook(xlsx_file) + wb["Budget"].merge_cells("A1:B1") + wb.save(xlsx_file) + wb.close() + # (mtime_ns, size) changed: the cached maps cannot be served any more. + assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == ["A1:B1"] + + def test_two_windows_share_one_metadata_load(self, xlsx_file, monkeypatch): + from backend import xlsx_reader + + path = Path(xlsx_file) + xlsx_reader.invalidate_workbook_meta(path) + real_load = xlsx_reader.load_workbook + normal = [] + + def counting(*args, **kwargs): + if not kwargs.get("read_only"): + normal.append(1) + return real_load(*args, **kwargs) + + monkeypatch.setattr(xlsx_reader, "load_workbook", counting) + assert xlsx_reader.read_sheet_window(path, "Budget", 0, 1) is not None + assert xlsx_reader.read_sheet_window(path, "Budget", 1, 1) is not None + assert len(normal) == 1, "les deux fenêtres partagent une seule lecture de métadonnées" + + +class TestOptimisticConcurrency: + """#156-A12 — the read exposes a revision, a write may require it. + + A stale ``if_match`` (the file changed since it was read: Excel, the + watcher, another worker) is refused with 409 ``conflict`` instead of + silently overwriting the other writer. + """ + + def _save(self, client, body, path="budget.xlsx"): + return client.put( + f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body + ) + + def _revision(self, client, path="budget.xlsx"): + resp = client.get(f"/api/file/{VAULT}", params={"path": path}) + assert resp.status_code == 200 + return resp.json()["xlsx_revision"] + + def test_the_read_exposes_a_revision_and_the_write_refreshes_it(self, client, xlsx_file): + rev = self._revision(client) + assert isinstance(rev, str) and rev + resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev}) + assert resp.status_code == 200 + assert resp.json()["revision"] and resp.json()["revision"] != rev + + def test_a_stale_if_match_is_refused_and_writes_nothing(self, client, xlsx_file): + rev = self._revision(client) + assert self._save( + client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev} + ).status_code == 200 + # The token is stale now: the client must re-read before writing again. + resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "deux"}, "if_match": rev}) + assert resp.status_code == 409 + assert resp.json()["code"] == "conflict" + assert resp.json()["details"]["reason"] == "stale_revision" + assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value is None + + def test_omitting_if_match_keeps_last_writer_wins(self, client, xlsx_file): + resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "sans-jeton"}}) + assert resp.status_code == 200 + assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value == "sans-jeton" + + def test_an_invalid_if_match_is_rejected(self, client, xlsx_file): + resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "x"}, "if_match": 42}) + assert resp.status_code == 400 + + def test_the_structure_endpoint_honours_if_match(self, client, xlsx_file): + rev = self._revision(client) + actions = [{"op": "row_insert", "sheet": "Budget", "at": 1}] + ok = client.put( + f"/api/file/{VAULT}/xlsx/structure", + params={"path": "budget.xlsx"}, + json={"actions": actions, "if_match": rev}, + ) + assert ok.status_code == 200 + stale = client.put( + f"/api/file/{VAULT}/xlsx/structure", + params={"path": "budget.xlsx"}, + json={"actions": actions, "if_match": rev}, + ) + assert stale.status_code == 409 + assert stale.json()["code"] == "conflict" + + def test_the_csv_endpoint_honours_if_match(self, client, test_vault_dir): + csv_path = Path(test_vault_dir) / "notes.csv" + csv_path.write_text("a,b\n1,2\n", encoding="utf-8") + rev = self._revision(client, "notes.csv") + ok = client.put( + f"/api/file/{VAULT}/csv/save", + params={"path": "notes.csv"}, + json={"cells": {"A1": "x"}, "if_match": rev}, + ) + assert ok.status_code == 200 + assert ok.json()["revision"] != rev + stale = client.put( + f"/api/file/{VAULT}/csv/save", + params={"path": "notes.csv"}, + json={"cells": {"A1": "y"}, "if_match": rev}, + ) + assert stale.status_code == 409 + assert csv_path.read_text(encoding="utf-8").startswith("x")